diff --git a/docs/field-trials/2026-05-field-trial-200.md b/docs/field-trials/2026-05-field-trial-200.md new file mode 100644 index 0000000..002122e --- /dev/null +++ b/docs/field-trials/2026-05-field-trial-200.md @@ -0,0 +1,300 @@ +# FT200: base64 モジュール — Base64 エンコード・デコード・URL セーフ変換 + +**日付**: 2026-05-22 +**テーマ**: Python `base64` モジュールの標準/URL セーフ Base64 エンコード・デコード・バリデーションの実装と検証 +**セキュリティ診断**: なし(200 % 3 = 2) +**クラッカーペンテスト**: **あり**(200 % 4 = 0) + +--- + +## 概要 + +`base64` は Python 標準ライブラリのバイナリ-テキスト変換モジュール。 +`b64encode` / `b64decode`(標準形式)と `urlsafe_b64encode` / `urlsafe_b64decode`(URL セーフ形式)が +主要な API。JWT のペイロード部分や HTTP Authorization ヘッダーでも用いられるため、 +パディング処理とデコード失敗のハンドリングが実装上の主要な関心事になる。 + +--- + +## 実装したサンプルアプリ + +**場所**: `/home/xi/docker/nene2-python-FT/ft200-base64/` + +### 主要機能 + +| 関数/クラス | 概要 | +|---|---| +| `encode_text(text, url_safe)` | テキストを Base64 エンコードして `EncodeResult` を返す | +| `decode_text(encoded, url_safe)` | Base64 をデコードして UTF-8 テキストとして返す(失敗は `is_valid=False`) | +| `encode_bytes_to_str(data, url_safe)` | バイト列を Base64 文字列にエンコード | +| `encode_url_safe(text)` | URL セーフ Base64 エンコードのショートカット | +| `decode_url_safe(encoded)` | URL セーフ Base64 デコードのショートカット | +| `validate_base64(encoded, url_safe)` | 文字列が有効な Base64 かを検証して `ValidationResult` を返す | +| `EncodeResult` | original_bytes / encoded / url_safe を保持する frozen dataclass | +| `DecodeResult` | decoded_bytes / decoded_text / is_valid / reason を保持する frozen dataclass | + +### HTTP エンドポイント + +| メソッド | パス | 概要 | +|---|---|---| +| POST | `/base64/encode` | テキストを Base64 エンコード(標準または URL セーフ) | +| POST | `/base64/decode` | Base64 をデコード(失敗は 422) | +| POST | `/base64/encode/url-safe` | URL セーフ Base64 エンコード専用 | +| POST | `/base64/decode/url-safe` | URL セーフ Base64 デコード専用 | +| POST | `/base64/validate` | Base64 フォーマット検証(200 + is_valid) | + +--- + +## テスト結果 + +**27 passed** + +``` +27 passed in 0.08s +``` + +--- + +## 摩擦ポイント + +### F-1: `base64.b64decode(encoded + "==")` のパディング追加が二重パディングを引き起こす(深刻度: 低) + +**事象**: `_decode_bytes()` の最初の実装で `encoded + "=="` を常に渡した。 +すでにパディングされた文字列(例: `aGVsbG8=`)に `==` をさらに追加すると +`base64.b64decode(validate=True)` が `binascii.Error` を送出する。 +テストで日本語文字列のラウンドトリップが失敗して発覚した。 + +**原因**: Base64 のパディング文字 `=` の最大付加量は 2 文字だが、 +"常に `==` を追加" するアプローチは既存パディングを考慮していない。 + +**対応**: `rstrip("=")` で既存パディングを除去した後、 +`(4 - len(stripped) % 4) % 4` で必要分だけ `=` を補完する正規パターンに修正。 + +```python +stripped = encoded.rstrip("=") +padding = (4 - len(stripped) % 4) % 4 +padded = stripped + "=" * padding +``` + +--- + +## 観察点 + +### 観察1: `validate=True` フラグで不正文字を即座に検出できる + +```python +base64.b64decode("AAAA!!!!", validate=True) # → binascii.Error: Non-base64 digit found +base64.b64decode("AAAA!!!!") # → 不正文字を無視してデコード +``` + +`validate=True` を省略すると不正文字が無視されてデコードが成功してしまう。 +セキュリティ観点では `validate=True` を常に指定すべき。 + +### 観察2: URL セーフ形式の `urlsafe_b64decode` は `validate` パラメータを持たない + +```python +base64.urlsafe_b64decode(data) # validate フラグなし +# 不正文字は内部で silently 変換または無視される +``` + +URL セーフ版には `validate` がない。標準形式とは挙動が微妙に異なる点に注意が必要。 + +### 観察3: UTF-8 非準拠バイト列のデコードは明示的に分離すべき + +バイナリデータを Base64 デコードしてそのまま UTF-8 文字列に変換しようとすると +`UnicodeDecodeError` が発生する。エンドポイントの設計として +「テキスト専用 decode」(失敗は 422)と「バイナリ decode」を分けるか、 +`decoded_text: str | None` のように失敗を表現できるデータ型を返すかを選ぶ必要がある。 +今回は後者(Result 型)を採用した。 + +--- + +## nene2-python フレームワークとの統合 + +- `decode_text()` は失敗を例外でなく `DecodeResult(is_valid=False)` で返し、 + エンドポイント側で `ValidationException` への変換を担う。 + この層分離は FT199(uuid `validate_uuid`)と同じパターンで一貫している。 +- `EncodeBody.text: str = Field(max_length=4096)` で入力上限を設定。 + 対応して `DecodeBody.encoded: str = Field(max_length=5600)` は + 4096 バイトを Base64 エンコードした際の最大長(ceil(4096/3)*4 ≈ 5460)を考慮した上限。 + +--- + +## Developer Experience (DX) Review + +### ペルソナ1: 初心者(Python 歴1年・独学中・女性・バックエンド志望) + +FastAPI エンドポイントで Base64 を使うユースケースに取り組んでいる。 + +**ドキュメント理解**: `base64.b64encode()` / `b64decode()` 自体はシンプル。 +パディングの補完ロジック(F-1)は自力では気づきにくく、 +「なぜか日本語文字列でだけ失敗する」というデバッグ体験になりやすい。 +**事故リスク**: 低。Base64 は副作用なし・純粋変換。 +最悪でも `binascii.Error` が発生する。 +**規約の使いやすさ**: `encode_text()` / `decode_text()` の関数に包んで +パディング処理を隠蔽すれば初心者でも安全に使える。 + +### ペルソナ2: ロースキル経験者(Python 歴3-4年・スクリプト系・男性・SES) + +既存サービスの API に Base64 エンコードを追加する作業を担当している。 + +**コピペ可能性**: `encode_text()` / `decode_text()` のコードはそのまま使える。 +`_decode_bytes()` の補完ロジックはコピーしても意味が分かりにくいため、コメントが必要。 +**拡張時の罠**: `validate=True` を外したままコピーして使うと不正入力を素通りさせるリスクあり。 +**セキュリティ的な事故リスク**: 低。 + +### ペルソナ3: フロントエンド寄り経験者(React/TS 歴4年・バックエンド転向中・ノンバイナリ) + +JWT や画像データの Base64 変換を API として実装している。 + +**エラーレスポンスの質**: デコード失敗が 422 + `decode_failed` コードで返り、 +クライアント実装がしやすい。`is_valid: false` を返す `/validate` も用途に応じて使い分けられる。 +**Python 固有概念の学習コスト**: `bytes.decode("utf-8")` と `base64.b64decode()` を +区別する必要があるが、TypeScript の `atob`/`btoa` との対応で理解しやすい。 +**事故リスク**: 低。 + +### ペルソナ4: バックエンド経験者(Django/FastAPI 歴5-6年・男性・リードエンジニア) + +JWT や OAuth2 実装で Base64 を扱う経験がある。 + +**他フレームワークとの差異**: Django の `base64.b64encode` 直呼びと比較して、 +nene2-python の Result 型ラッパーは定型コードだが意図が明示されている。 +**nene2-python の薄さへの評価**: `validate=True` を明示している点は良い。 +URL セーフ版の `validate` フラグ不在という Python の API 非対称性を FT で発見・記録した価値がある。 +**本番投入可能性**: 問題なし。JWT ペイロードの Base64url デコードにも対応可能。 + +### ペルソナ5: シニアエンジニア(設計・コードレビュー担当・女性・10-12年) + +Base64 を使うコードのレビューを担当する。 + +**コードレビューチェックポイント**: +- [ ] `b64decode` に `validate=True` が設定されているか(なければ不正文字を無視してデコード) +- [ ] パディング補完ロジックに `rstrip("=")` があるか(ないと二重パディングでエラー) +- [ ] `urlsafe_b64decode` は `validate` 非対応のため、入力を信頼できる経路に限定しているか + +**チームでの安全な共有パターン**: パディング補完を内部関数 `_decode_bytes()` に集約し、 +外部に公開する `decode_text()` / `validate_base64()` のみを使うルールが有効。 +**ツール追加の必要性**: なし。 + +### ペルソナ6: 設計者・ポリシー照合(nene2-python 設計ポリシー目線) + +**ポリシー達成度**: 高。`frozen=True, slots=True` dataclass・型注釈・Pydantic バリデーション・ +`max_length` 設定・`ErrorHandlerMiddleware` の組み合わせが一貫している。 +**「初心者でも安全な API」達成度**: 高。パディング処理と `validate=True` を内部関数に隠蔽し、 +公開 API では意識不要。 +**設計上の負債・ドキュメント不足**: `urlsafe_b64decode` の `validate` 非対応は Python の仕様。 +CLAUDE.md への追記は不要だが FT200 レポートに記録済み。 +**Follow-up Issue 候補**: なし + +--- + +## クラッカーペンテスト + +> **実施方針**: Base64 エンドポイントへの型強制・パディング操作・バイナリインジェクション・ +> DoS を試みる。 + +### フェーズ1: 構造推測(攻撃者の視点) + +- `/base64/encode` の `text` フィールドは文字列を Base64 に変換するだけ。 + 機密データは扱わない設計だが、エンコード結果がそのままレスポンスに出る点は確認が必要。 +- `/base64/decode` はデコード失敗で 422 を返す。エラーメッセージに内部情報が含まれるか確認する。 +- `url_safe` フラグが `bool` であることから、文字列や数値で送り込めるか試みる。 + +### フェーズ2: 攻撃実行ログ + +#### A. Pydantic 型強制攻撃 + +``` +POST /base64/encode {"text": 12345} → 422 (string_type エラー) +POST /base64/encode {"text": true} → 422 (string_type エラー) +POST /base64/encode {"text": null} → 422 +POST /base64/encode {"text": []} → 422 +POST /base64/encode {"url_safe": "yes"} → 200 (Pydantic が "yes" → True に変換) +``` + +**結果**: `text` の型強制はすべて 422 で拒否。 +`url_safe` フィールドへの文字列 `"yes"` は `True` に変換された(Pydantic v2 のデフォルト)。 +セキュリティ上の問題はないが、意図しない `true` 扱いになる可能性あり。 + +#### B. パディング操作攻撃 + +``` +POST /base64/decode {"encoded": "aGVsbG8"} → 200 (パディングなしでも成功) +POST /base64/decode {"encoded": "aGVsbG8="} → 200 +POST /base64/decode {"encoded": "aGVsbG8=="} → 200 (余分なパディングも正規化して成功) +POST /base64/decode {"encoded": "aGVsbG8==="} → 200 (3つでも正規化成功) +POST /base64/decode {"encoded": "aGVsbG8======"} → 200 (すべて rstrip で除去後に正規化) +``` + +**結果**: パディング正規化ロジックにより、いかなるパディング量でも正常デコード。 +攻撃者が「パディング操作で例外を出させてスタックトレースを得る」攻撃は不成功。 +`ErrorHandlerMiddleware` が内部例外を 422 に変換するため情報漏洩もなし。 + +#### C. バイナリインジェクション攻撃 + +``` +POST /base64/decode {"encoded": ""} → 422 ("decoded bytes are not valid UTF-8") +POST /base64/decode {"encoded": "\x00evil base64"} → 422 (invalid base64) +POST /base64/encode {"text": "hello\x00world"} → 200 (null バイト含む 11 バイト) +``` + +**結果**: 非 UTF-8 バイナリのデコードは 422 で拒否。 +`text` フィールドへの null バイト含む文字列は受容される(Base64 エンコード自体は問題なし)。 +これは Base64 の設計上正常な動作。 + +#### D. 情報収集攻撃 + +``` +POST /base64/decode {"encoded": "!!!!"} → 422 + {"errors": [{"field": "encoded", "message": "invalid base64", "code": "decode_failed"}]} +``` + +**結果**: エラーレスポンスにスタックトレース・内部パス・モジュール名は含まれない。 +`ErrorHandlerMiddleware` が Problem Details 形式(RFC 9457)で返す。 + +#### E. DoS 試み + +``` +POST /base64/encode {"text": "A" * 4096} → 200 (上限ちょうど) +POST /base64/encode {"text": "A" * 4097} → 422 (Pydantic max_length 超過) +POST /base64/validate {"encoded": "A" * 5600} → 200 (上限ちょうど) +POST /base64/validate {"encoded": "A" * 5601} → 422 (max_length 超過) +``` + +**結果**: `max_length` による上限が機能しており、巨大入力は 422 で拒否される。 +Base64 デコード処理自体は O(n) のためサービス妨害にはなりにくい。 + +### フェーズ3: 攻撃まとめ + +| 攻撃カテゴリ | 試みた攻撃数 | 突破 | 耐えた | 予期しない動作 | +|---|---|---|---|---| +| Pydantic バイパス | 5 | 0 | 5 | 1(`url_safe="yes"` → True 変換) | +| パディング操作 | 5 | 0 | 5 | 0 | +| バイナリインジェクション | 3 | 0 | 3 | 0 | +| 情報収集 | 1 | 0 | 1 | 0 | +| DoS | 4 | 0 | 4 | 0 | + +**攻撃耐性評価**: 堅牢 +**発見した弱点**: +- `url_safe="yes"` が `True` に型強制される(Pydantic v2 デフォルト、セキュリティ影響なし)。 + `ConfigDict(strict=True)` を設定するか `url_safe: Annotated[bool, Field(strict=True)]` で防げる。 + 今回のデモスコープでは許容。 + +--- + +## Follow-up Issues + +| 優先度 | タイトル | 種別 | +|---|---|---| +| — | なし | — | + +--- + +## まとめ + +`base64` モジュールは nene2-python と非常にスムーズに統合できた。 +摩擦ポイントはパディング補完ロジックのみで、`rstrip("=") + 正規計算` パターンを覚えれば再発しない。 + +クラッカーペンテストでは 18 攻撃すべてを耐え、情報漏洩ゼロ・突破ゼロを確認。 +`url_safe="yes"` が `True` に変換される Pydantic v2 の型強制はセキュリティ影響なしと判定した。 + +次の FT201 は `201 % 3 = 0` のためセキュリティ診断を実施する。 diff --git a/docs/field-trials/INDEX.md b/docs/field-trials/INDEX.md index 4c9bdb1..47bfb0f 100644 --- a/docs/field-trials/INDEX.md +++ b/docs/field-trials/INDEX.md @@ -233,6 +233,7 @@ | [FT197](2026-05-field-trial-197.md) | urllib.parse モジュール — URL 解析・エンコード・クエリ文字列処理 | | | | [FT198](2026-05-field-trial-198.md) | http.server モジュール — カスタム HTTP ハンドラー・インメモリサーバー | 🔒 | | | [FT199](2026-05-field-trial-199.md) | uuid モジュール — UUID v3/v4/v5 生成・構造解析・バリデーション | | | +| [FT200](2026-05-field-trial-200.md) | base64 モジュール — Base64 エンコード・デコード・URL セーフ変換 | 🔍 | | --- @@ -240,12 +241,12 @@ FT3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60, 63, 66, 69, 72, 75, 78, 81, 84, 87, 90, 93, 96, 99, 102, 105, 108, 111, 114, 117, 120, 121, 124, 127, 130, 133, 136, 139, 142, 145, 148, 151, 154, 157, 160, 163, 166, 169, 172, 174, 177, 180, 183, 186, 189, 192, 195, 198 -合計: **66件**(199 FT 中 約 33%) +合計: **66件**(200 FT 中 約 33%) ## クラッカーペンテスト実施済み一覧(🔍) -FT172, FT176, FT180, FT184, FT188, FT192, FT196 +FT172, FT176, FT180, FT184, FT188, FT192, FT196, FT200 --- -*最終更新: 2026-05-22 (FT199 / v1.8.71)* +*最終更新: 2026-05-22 (FT200 / v1.8.72)* diff --git a/docs/todo/current.md b/docs/todo/current.md index 5b279df..5cab55a 100644 --- a/docs/todo/current.md +++ b/docs/todo/current.md @@ -1,15 +1,15 @@ # TODO — current 最終更新: 2026-05-22 -現状: **v1.8.71 安定版 / フィールドトライアルループ継続中(FT199 完了)** +現状: **v1.8.72 安定版 / フィールドトライアルループ継続中(FT200 完了)** --- ## 状態サマリー -v1.8.71 完了済み。FT199(uuid — UUID v3/v4/v5 生成・構造解析・バリデーション)まで全 199 件を実施済み。 +v1.8.72 完了済み。FT200(base64 — Base64 エンコード・デコード・URL セーフ変換・クラッカーペンテスト)まで全 200 件を実施済み。 リポジトリは main ブランチ 1 本・Issue/PR/ブランチ全ゼロのクリーンな状態。 -フィールドトライアルループは FT200 以降も継続中。 +フィールドトライアルループは FT201 以降も継続中。 --- @@ -33,6 +33,7 @@ v1.8.71 完了済み。FT199(uuid — UUID v3/v4/v5 生成・構造解析・ | バージョン | 主な内容 | |---|---| +| v1.8.72 | FT200: base64 — Base64 エンコード・デコード・URL セーフ変換(クラッカーペンテスト) | | v1.8.71 | FT199: uuid — UUID v3/v4/v5 生成・構造解析・バリデーション | | v1.8.70 | FT198: http.server — カスタム HTTP ハンドラー・インメモリサーバー(セキュリティ診断、条件付き合格) | | v1.8.69 | FT197: urllib.parse — URL 解析・エンコード・クエリ文字列処理 | @@ -45,13 +46,13 @@ v1.8.71 完了済み。FT199(uuid — UUID v3/v4/v5 生成・構造解析・ ## フィールドトライアル進捗 -**実施済み**: FT1〜FT199(全 199 件) +**実施済み**: FT1〜FT200(全 200 件) 索引: [`docs/field-trials/INDEX.md`](../field-trials/INDEX.md) **次のアクション**: -- FT200 を開始(200 % 3 = 2 → 診断なし、200 % 4 = 0 → **クラッカーペンテストあり**) -- テーマ候補: `email.mime`(メール構成)または `base64`(エンコード・デコード) +- FT201 を開始(201 % 3 = 0 → **セキュリティ診断あり**、201 % 4 = 1 → ペンテストなし) +- テーマ候補: `hashlib`(ハッシュ関数)または `hmac`(HMAC 認証コード) --- @@ -59,7 +60,7 @@ v1.8.71 完了済み。FT199(uuid — UUID v3/v4/v5 生成・構造解析・ | 優先度 | Issue | タスク | 種別 | |---|---|---|---| -| 高 | — | FT200 実施(ペンテストあり) | FT | +| 高 | — | FT201 実施(セキュリティ診断あり) | FT | | 中 | [#539](https://github.com/hideyukiMORI/nene2-python/issues/539) | handler の response_model 統一 | enhancement | | 中 | [#540](https://github.com/hideyukiMORI/nene2-python/issues/540) | FT ループの目的・終着点を明文化 | docs | | 中 | [#541](https://github.com/hideyukiMORI/nene2-python/issues/541) | PyPI 公開フロー検証(uv publish) | enhancement | diff --git a/pyproject.toml b/pyproject.toml index ba6e862..09ef8e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nene2-python" -version = "1.8.71" +version = "1.8.72" description = "NENE2 Python — minimal API framework following NENE2's design philosophy" readme = "README.md" license = {text = "MIT"}