fix(shared): proof 由来の language を allowlist に落とし表示層へ生値を渡さない (#248) - #277
Merged
Conversation
`ExportedProof.language` は型の上では string だが、実体は JSON.parse の結果を cast した
だけで、実行時は proof.json を組み立てた側の任意文字列が入る。これが無検証のまま表示層へ
届いていた。
- verify (web): `ResultPanel` が `codeEl.className = \`language-${language} hljs …\`` を
組み立てるため、空白区切りで既存の CSS ユーティリティクラスを足せる。
`"language": "ts hidden"` を仕込むと `.hidden { display: none !important }` が
`#code-preview code.with-line-numbers { display: block }` (!important 無し) に勝ち、
`<code>` ごとコードプレビューが消える (採点妨害)
- verify-cli: `Language:` 行に生値が出るため、改行と ANSI を仕込んで偽の
`--- Checks --- / Hash Chain: PASS` ブロックを stdout へ描ける (#266 の一部)
#210 で自己申告 `mode` に敷いた対処 (`normalizeProofMode`) と同じ契約を `language` にも
敷く。web と CLI の両方が同じ値を扱うので、allowlist の正本は shared に置いた。
- `fileProcessing/proofLanguage.ts` に `normalizeProofLanguage` を新設。allowlist は
`languageDetection.ts` の `LANGUAGE_MAP` の値集合 (`KNOWN_LANGUAGES`) + `'unknown'` で、
集合外・非文字列は `'unknown'` に落とす。集合を別に書き起こすとドリフトするので
`KNOWN_LANGUAGES` を唯一の元とする
- 適用点は取り込み口 (`FileController`) と表示境界 (`ResultDataService` / verify-cli の
`verifyProof`)。normalizer は冪等なので二重適用に害はない。表示に効かない
`JsonFileProcessor` / `ZipFileProcessor` の生パススルーも同型の再発防止のため潰す
- `exam/examBundle.ts` の `starter.language` は束縛ハッシュの canonical 形に入るため対象外
Closes #248
🚀 Preview Deployment
Deployed from commit ac71ef1 |
This was referenced Aug 23, 2026
shinyaoguri
added a commit
that referenced
this pull request
Aug 24, 2026
stdout に生値のまま流していたため、改行と ANSI エスケープを仕込むと検証結果に 任意の行を偽造できた。ZIP エントリ名 `evil_proof.json\n \e[32m✓\e[0m main_proof.json` で、2 件とも FAIL・exit 1 なのに Summary へ緑の `✓ main_proof.json` を生やせることを 実 CLI で再現していた。exit code は守られるので壊れるのは stdout を grep する採点運用と 端末表示だが、CLAUDE.md 不変条件 4 が「grep される可能性を考慮」と明記している。 #248 (PR #277) は `language` を allowlist に落として塞いだが、それは値ごとの対処で、 棚卸し (#243) した 11 箇所のうち閉じたのは 1 箇所だった。本コミットは境界側で一括して塞ぐ。 - `output.ts` に `safe()` を追加 (C0/C1 制御文字を除去・200 文字で切り詰め、 変化があったときだけ `(sanitized)` を付ける)。黙って消すと「元からその名前だった」と 読めてしまうため - exam の examId/problemId/variant/reason、time-box、reflection note、errorMessage、 errorAt、poswIterations、署名 cp の reason、analyzer の dimension/summary/note に適用 - `formatProofHeader` / `formatMultiSummary` を新設して `cli.ts` の `console.log` 直書きを 置き換え、ZIP エントリ名もサニタイズしつつテストを当てられるようにした 保証するのは「未信頼値が 1 行に収まり行頭を乗っ取れない」ところまで。行内に `Hash Chain: PASS` という文字列が残ることは正当な本文と区別できず防げないので、 その旨と「採点は行頭を固定して grep する」ことを doc と CLAUDE.md に明記した。
shinyaoguri
added a commit
that referenced
this pull request
Aug 24, 2026
## 目的 `typedcode-verify` は proof / ZIP 由来の文字列を**無加工で stdout に流していた**。改行と ANSI エスケープを仕込むと検証結果に**任意の行を偽造できる**。 現行 `main` (`d24b7f9`) で再現済み — `main_proof.json` と、名前に改行 + ANSI を仕込んだエントリを持つ ZIP を検証すると: ``` === Summary: 0/2 proofs passed === ✗ evil_proof.json ^[[32m✓^[[0m main_proof.json <-- ZIP エントリ名から生えた偽造行 ✗ main_proof.json ``` 実際は 2 件とも FAIL・exit 1 なのに、Summary に**緑の `✓ main_proof.json` が増える**。`NO_COLOR=1` でも proof 由来の ESC は素通し (`c()` の抑制は CLI 自身の着色にしか効かない)。 exit code は守られるので壊れるのは **stdout を grep する採点運用**と端末表示だが、verify-cli/CLAUDE.md 不変条件 4 が「パイプして grep される可能性を考慮」と明記している。 #248 (PR #277) は `language` を allowlist に落として塞いだが、それは**値ごとの対処**。#243 が棚卸しした「proof 由来で無検証のまま stdout に出る文字列」11 箇所のうち閉じたのは 1 箇所だけだった。本 PR は**境界側で一括して**塞ぐ (#266 推奨の切り方 PR-B)。 ## 変更点 - **`output.ts` に `safe(value: unknown)` を追加** — C0 / C1 制御文字 (ESC・CR・LF 含む) を除去し 200 文字で切り詰める。**変化が起きたときだけ `(sanitized)` を付ける**: 黙って消すと「元からその名前だった」と読めてしまい、採点者が異常に気づけない - **未信頼値の補間点すべてに適用** — exam の `examId` / `problemId` / `variant` / `reason`、time-box、reflection note、`errorMessage` / `errorAt`、`poswIterations` (非数値なら整形せず `safe()`)、署名 cp の `reason`、analyzer の `dimension` / `summary` / evidence note (**外部 `--analyzer` の出力も未信頼**) - **`formatProofHeader` / `formatMultiSummary` を新設** — `cli.ts` が `console.log` で直接組み立てていてテストを当てられなかったので、整形を `output.ts` へ寄せた。ZIP エントリ名もここでサニタイズされる。**ロジック (件数計算・exit code) は不変** - reflection note は改行が正当な入力なので、従来どおり ` / ` へ畳んでから残る制御文字を落とす (旧実装は `\r` と ESC を素通ししていた) ### 保証の範囲 (意図的な限界) `safe()` が保証するのは「**未信頼値が 1 行に収まり、行頭を乗っ取れない**」ところまで。`Error:` 行の**中に** `Hash Chain: PASS` という文字列が残ることは正当な本文と区別できず防げない。**採点は行頭を固定して grep する**ことを doc コメントと CLAUDE.md 不変条件 5 に明記した。 ## 確認方法 **再現 ZIP で実挙動** (`node dist/cli.js` は shared が raw TS なので不可 → `npx tsx`): | | 修正前 | 修正後 | |---|---|---| | Summary の行数 | 見出し + **3 行** (偽造 1 行混入) | 見出し + **2 行** | | `grep -c '^ ✓'` | 1 (偽の合格) | **0** | | 出力中の ESC | 有り (`NO_COLOR=1` でも) | **0** | | エントリ名の表示 | 2 行に割れる | 1 行 + `(sanitized)` | | exit code | 1 | 1 (不変) | **非退行**: 制御文字を含まない通常の ZIP では、修正前後の stdout が **`diff` で完全一致** (`(sanitized)` も付かない)。 **テスト**: `output.test.ts` に 8 件追加 (`safe()` 単体 4 / Summary の行数固定 2 / 見出し 1 / `formatResult` への注入 3)。**`safe()` を素通しに壊すと 6 件が赤くなる**ことを確認済み (残り 2 件は「正常系は変わらない」を固定するテストなので緑のままが正しい)。 - `npm run test:run --workspaces --if-present` → 全件 green (verify-cli 45 件) - `npm run typecheck -w @typedcode/verify-cli` → green - `npm run lint` → error 0 (main と同じ warning 60 / info 9) - `npm run test -w @typedcode/e2e` → **15 passed / 2 failed**。落ちた 2 件 (`happy-path` / `close-tab-recovery`) は **`main` を stash して同じ環境で回しても同じく落ちる**ローカル環境要因 (`rootAnchored` が取れない) で、本 PR とは無関係 ## 残件 - **#273** (`examId` / `variant` が manifest と突き合わされない = 自己申告値の**表示ラベル**問題) は別 Issue のまま。本 PR は「壊れた文字を出さない」だけを担う - **#238** の verify-cli 項目 (`process.exit` / help / `--analyzer` の実行順) も同ファイルだが 1 PR = 1 関心事で分けた Closes #266
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
目的
proof.jsonは攻撃者が自由に組み立てられる入力なのに、自己申告のlanguageだけが無検証のまま表示層へ届いていた。#210 でmodeに敷いた対処と同じ契約をlanguageにも敷き、web のclassNameと CLI の stdout に生値が入らないようにする。Closes #248#266 (CLI 出力境界の
safe()— ZIP エントリ名やreflectionNoteなど language 以外の補間点) は本 PR の範囲外で、続く PR-B で扱う。何が起きていたか (再現済み)
verify (web):
ResultPanel.ts:705がcodeEl.className = \language-${language} hljs with-line-numbers`` を組み立てるので、空白区切りで既存の CSS ユーティリティクラスを足せる。実際の CSS を happy-dom に食わせて確認した:proof.languagegetComputedStyle(code).displayts hiddenlanguage-ts hidden hljs with-line-numbersnone← コードプレビューが消えるunknown(修正後)language-unknown hljs with-line-numbersblock_reset.css:122の.hidden { display: none !important; }が_result-panel.css:369のdisplay: block(!important無し) に勝つため。XSS ではない (SyntaxHighlighter.highlightが未登録言語をescapeHtmlに落とす) が、採点者からはコードが空に見える。verify-cli:
Language:行に生値が出る。実 CLI で before / after を取った:exit code は両方 1 のまま。壊れるのは stdout を grep する運用と端末表示 (verify-cli/CLAUDE.md 不変条件 4)。
変更点
allowlist の正本を shared に置いた — web と CLI が同じ値を扱うので、片方に置くと二重管理になる。
新規
packages/shared/src/fileProcessing/proofLanguage.tsnormalizeProofLanguage(value: unknown): string— 非文字列・allowlist 外は'unknown'languageDetection.tsのLANGUAGE_MAPの値集合 (26 種) +'unknown'。集合を別に書き起こすとドリフトするので
KNOWN_LANGUAGESを export して唯一の元にした適用点
verify/src/ui/controllers/FileController.ts:401verify/src/services/ResultDataService.ts:151mode(:154) と同じ「入力を信用しない」契約。回帰テストの置き場verify-cli/src/verify.ts:261verify/src/services/{Json,Zip}FileProcessor.ts触っていないもの:
shared/src/exam/examBundle.ts:91-94のstarter.languageはバンドル束縛ハッシュの canonical 形に入る。正規化すると既存 exam の束縛が壊れるので対象外。
未知値は
'unknown'に落とす方針を採った (生値を「自己申告」として別表示にはしない)。languageは ADR-0020 上「保証導出に使わない参考表示」で、normalizeProofModeが未知値を捨てるのと揃う。確認方法
新規テスト (13 件追加):
packages/shared/src/__tests__/proofLanguage.test.ts— allowlist の契約。KNOWN_LANGUAGES全要素と editor のLanguageId8 種が通ること (集合ドリフトの検知)、'ts hidden'/ HTML payload / 改行 + ANSI / 非文字列が'unknown'に落ちること、冪等性packages/verify/src/services/__tests__/ResultDataService.test.ts—buildResultDataが className 用のlanguageを正規化すること (既存のmodeテストの隣)packages/verify-cli/src/__tests__/proofLanguage.test.ts—verifyProofの戻り値のlanguageが正規化されることいずれも normalizer を一時的に素通しへ戻して赤くなることを確認済み (shared 6 件 / verify 3 件 / CLI 3 件が失敗)。加えて上記の実 CLI と happy-dom による before / after も取得済み。
🤖 Assisted by Claude Code