Skip to content

fix(shared): proof 由来の language を allowlist に落とし表示層へ生値を渡さない (#248) - #277

Merged
shinyaoguri merged 1 commit into
mainfrom
fix/verify-language-sanitize-248
Aug 23, 2026
Merged

fix(shared): proof 由来の language を allowlist に落とし表示層へ生値を渡さない (#248)#277
shinyaoguri merged 1 commit into
mainfrom
fix/verify-language-sanitize-248

Conversation

@shinyaoguri

Copy link
Copy Markdown
Owner

目的

proof.json は攻撃者が自由に組み立てられる入力なのに、自己申告の language だけが無検証のまま表示層へ届いていた。#210mode に敷いた対処と同じ契約を language にも敷き、web の className と CLI の stdout に生値が入らないようにする。

Closes #248

#266 (CLI 出力境界の safe() — ZIP エントリ名や reflectionNote など language 以外の補間点) は本 PR の範囲外で、続く PR-B で扱う。

何が起きていたか (再現済み)

verify (web): ResultPanel.ts:705codeEl.className = \language-${language} hljs with-line-numbers`` を組み立てるので、空白区切りで既存の CSS ユーティリティクラスを足せる。実際の CSS を happy-dom に食わせて確認した:

proof.language 生成される className getComputedStyle(code).display
ts hidden language-ts hidden hljs with-line-numbers none ← コードプレビューが消える
unknown (修正後) language-unknown hljs with-line-numbers block

_reset.css:122.hidden { display: none !important; }_result-panel.css:369display: block (!important 無し) に勝つため。XSS ではない (SyntaxHighlighter.highlight が未登録言語を escapeHtml に落とす) が、採点者からはコードが空に見える。

verify-cli: Language: 行に生値が出る。実 CLI で before / after を取った:

# before (normalizer 無し)
Language:        c
--- Checks ---
Hash Chain:      PASS      ← proof が仕込んだ偽の行
--- Checks ---
Hash Chain:  FAIL          ← 本物

# after
Language:        unknown
--- Checks ---
Hash Chain:  FAIL

exit code は両方 1 のまま。壊れるのは stdout を grep する運用と端末表示 (verify-cli/CLAUDE.md 不変条件 4)。

変更点

allowlist の正本を shared に置いた — web と CLI が同じ値を扱うので、片方に置くと二重管理になる。

  • 新規 packages/shared/src/fileProcessing/proofLanguage.ts

    • normalizeProofLanguage(value: unknown): string — 非文字列・allowlist 外は 'unknown'
    • allowlist = languageDetection.tsLANGUAGE_MAP の値集合 (26 種) + 'unknown'
      集合を別に書き起こすとドリフトするので KNOWN_LANGUAGES を export して唯一の元にした
    • 冪等 (取り込み口と表示境界の両方で通せる)
  • 適用点

    場所 役割
    verify/src/ui/controllers/FileController.ts:401 取り込み口。汚染値をアプリ状態に入れない
    verify/src/services/ResultDataService.ts:151 表示境界。mode (:154) と同じ「入力を信用しない」契約。回帰テストの置き場
    verify-cli/src/verify.ts:261 CLI の結果組み立て
    verify/src/services/{Json,Zip}FileProcessor.ts proof 経路では表示に効かない (dead) が、同型の書き方を残すと再発するので同時に潰す
  • 触っていないもの: shared/src/exam/examBundle.ts:91-94starter.language
    バンドル束縛ハッシュの canonical 形に入る。正規化すると既存 exam の束縛が壊れるので対象外。

未知値は 'unknown' に落とす方針を採った (生値を「自己申告」として別表示にはしない)。language は ADR-0020 上「保証導出に使わない参考表示」で、normalizeProofMode が未知値を捨てるのと揃う。

確認方法

npm run test:run --workspaces --if-present   # 802 passed / 3 skipped
npm run lint                                  # exit 0
npm run build                                 # 全パッケージ green

新規テスト (13 件追加):

  • packages/shared/src/__tests__/proofLanguage.test.ts — allowlist の契約。KNOWN_LANGUAGES 全要素と editor の LanguageId 8 種が通ること (集合ドリフトの検知)、'ts hidden' / HTML payload / 改行 + ANSI / 非文字列が 'unknown' に落ちること、冪等性
  • packages/verify/src/services/__tests__/ResultDataService.test.tsbuildResultData が className 用の language を正規化すること (既存の mode テストの隣)
  • packages/verify-cli/src/__tests__/proofLanguage.test.tsverifyProof の戻り値の language が正規化されること

いずれも normalizer を一時的に素通しへ戻して赤くなることを確認済み (shared 6 件 / verify 3 件 / CLI 3 件が失敗)。加えて上記の実 CLI と happy-dom による before / after も取得済み。


🤖 Assisted by Claude Code

`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
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment

Name URL
Preview https://1ac3b085.typedcode.pages.dev

Deployed from commit ac71ef1
API: staging Workers

@shinyaoguri
shinyaoguri merged commit d24b7f9 into main Aug 23, 2026
8 checks passed
@shinyaoguri
shinyaoguri deleted the fix/verify-language-sanitize-248 branch August 23, 2026 06:14
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation pkg:shared packages/shared pkg:verify packages/verify + verify-cli

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant