From d4e6d9d557c5941c1705a01f59884752dae85943 Mon Sep 17 00:00:00 2001 From: hideyukiMORI Date: Thu, 21 May 2026 20:03:00 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E3=83=90=E3=83=83=E3=82=AF=E3=83=AD?= =?UTF-8?q?=E3=82=B0=20Issue=20=E4=B8=80=E6=8B=AC=E8=A7=A3=E6=B6=88=20?= =?UTF-8?q?=E2=80=94=20=E3=83=AB=E3=83=BC=E3=83=AB=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=83=BB=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E8=BF=BD=E8=A8=98=E3=83=BBFT=20=E3=82=B5=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=83=9C=E3=83=83=E3=82=AF=E3=82=B9=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #499, #500, #501, #506, #507, #510, #511, #513, #514, #516, #517, #520, #524 ### CLAUDE.md 変更 - APIRouter + create_app() ファイル末尾配置ルールを追加 (closes #501, #510) - defusedxml を XML 処理の必須依存として追記 (closes #506) - FT フロー Step 8 を「発見した Issue はその場で修正してからクローズ」に変更 - セキュリティ診断「条件付き合格」を「同 FT の PR 内で修正してからマージ」に変更 ### ドキュメント追加 - docs/how-to/email-address-parsing.md — parseaddr() の寛容な挙動と注意点 (closes #511) - docs/how-to/decimal-unicode-input.md — Unicode 全角数字と Decimal (closes #500) ### FT サンドボックス修正 - ft176: calculate_tax/discount にビジネスロジックバリデーション追加 (closes #499) - ft180: build_xml() 子タグ名 key にも NCName バリデーションを追加 (closes #507) - ft183: SmtpConfig パスワードを SecretStr 経由に変更・/send・/check-server に SSRF 防御追加 (closes #513, #514) - ft184: fetch_safe() リダイレクト先 URL を SSRF チェックする _SsrfSafeRedirectHandler を追加 (closes #516, #517) - ft186: /flatten 内側リストに max_length=100 追加 (closes #520) - ft189: subprocess 関数の OSError を ValueError に変換して 400 で返す (closes #524) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 44 +++++++++++++++++++- docs/how-to/decimal-unicode-input.md | 61 ++++++++++++++++++++++++++++ docs/how-to/email-address-parsing.md | 57 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 docs/how-to/decimal-unicode-input.md create mode 100644 docs/how-to/email-address-parsing.md diff --git a/CLAUDE.md b/CLAUDE.md index 665e29b..6f967c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,11 @@ print(sensitive_data) # logging モジュールを使う - **セキュリティヘッダーをミドルウェアで付与**(X-Content-Type-Options, X-Frame-Options, etc.) - **SQL はパラメータ化クエリのみ**。文字列フォーマット禁止 - **ファイルパスは `pathlib.Path` で操作**し、パストラバーサルを防ぐ +- **XML 処理には `defusedxml` を使用**。標準の `xml.etree.ElementTree` は XXE・展開爆弾に脆弱(FT180 で確認) + ```bash + uv add defusedxml + ``` + `import xml.etree.ElementTree` の代わりに `import defusedxml.ElementTree` を使う。 ### 依存関係の脆弱性スキャン @@ -233,6 +238,37 @@ AI エージェント(Claude 等)がこのコードベースを正確に理 - `nene2.http.problem_details_response()` で RFC 9457 エラー応答 - `nene2.http.PaginationQueryParser` でページネーション +### APIRouter パターン(必須) + +すべての FastAPI アプリで `APIRouter` + `create_app()` ファクトリパターンを使うこと。 + +```python +# ✅ 正しい構造 — app.py +router = APIRouter() + +@router.post("/items") # ← すべてのルート定義は router に紐付ける +def create_item(...): ... + +@router.get("/items/{item_id}") +def get_item(...): ... + +def create_app() -> FastAPI: # ← create_app() はファイル末尾に定義する + application = FastAPI(title="...") + application.include_router(router) + return application + +app = create_app() # ← モジュールレベルの app は最終行 +``` + +**`create_app()` はファイルの末尾**(全 `@router.xxx()` デコレーター定義の後)に置くこと。 +先に `app = create_app()` を呼ぶと `router` にルートが登録される前に `include_router()` が実行され、 +エンドポイントが空になるバグが発生する(FT182 で発見)。 + +- `router = APIRouter()` → ファイル先頭の定数・モデル定義の後 +- `@router.post(...)` デコレーター → ハンドラー関数の定義 +- `create_app()` → ファイル末尾 +- `app = create_app()` → ファイル最終行 + ### ミドルウェアスタック順序(重要) `app.add_middleware()` は **LIFO**(後から追加したものが外側になる)。 @@ -378,7 +414,11 @@ Python 標準ライブラリ・サードパーティライブラリを nene2-pyt テンプレート: docs/templates/field-trial-report.md 6. DX Review(6ペルソナ)を実施(後述) 7. FT番号が3の倍数なら セキュリティ診断 を実施(後述) -8. Follow-up Issues を GitHub Issue に変換 +8. Follow-up Issues をその場で修正してからクローズする + - 発見した問題(摩擦点・セキュリティ指摘)は FT PR に含めて修正する + - 修正 → テスト全通過 → PR に含める → GitHub Issue は PR 内でクローズ(Closes #NNN) + - CLAUDE.md 追記・docs 更新・サンドボックスのコード修正すべてを同じ PR に含める + - 「外部依存の修正待ち」など対応不可能な理由がある場合のみ Issue を残し、理由を PR 説明に記載する 9. まとめて main merge → パッチバージョン(v1.8.N)でリリース ``` @@ -418,7 +458,7 @@ Python 標準ライブラリ・サードパーティライブラリを nene2-pyt **合否判定**: - **合格**: 全カテゴリ問題なし -- **条件付き合格**: MEDIUM 以下の指摘のみ、次 FT までに修正 +- **条件付き合格**: MEDIUM 以下の指摘のみ → **同 FT の PR 内で修正してからマージ** - **不合格**: HIGH/CRITICAL の指摘あり → main merge 前に必須修正 --- diff --git a/docs/how-to/decimal-unicode-input.md b/docs/how-to/decimal-unicode-input.md new file mode 100644 index 0000000..0953239 --- /dev/null +++ b/docs/how-to/decimal-unicode-input.md @@ -0,0 +1,61 @@ +# How-to: decimal モジュールと Unicode 数字入力 + +## Python の Decimal は Unicode 全角数字を受け入れる + +`decimal.Decimal()` は Unicode の全角数字(U+FF10〜U+FF19: 0123456789)を +そのまま数値として解釈します。HTTP API を通じてユーザーが全角数字を入力した場合、 +**Pydantic の `str` フィールドを通過してしまう**ことがあります。 + +```python +from decimal import Decimal + +Decimal("123") # → Decimal('123') ← 正常に変換される +Decimal("1.5") # → Decimal('1.5') +``` + +## 問題: 想定外の入力が通過する可能性 + +金融計算 API で `price: str = Field(...)` としている場合、 +クライアントが `"1000"` を送ると `Decimal("1000")` → `Decimal('1000')` として処理されます。 +これ自体はエラーではありませんが、**入力の正規化が必要な場合**(ログ記録・DB 保存等)は +Unicode 正規化を行ってから処理してください。 + +```python +import unicodedata +from decimal import Decimal + +def parse_decimal_safe(value: str) -> Decimal: + """Unicode 正規化(NFKC)して Decimal に変換する.""" + normalized = unicodedata.normalize("NFKC", value.strip()) + return Decimal(normalized) +``` + +`unicodedata.normalize("NFKC", ...)` は全角数字を半角に変換します。 + +```python +unicodedata.normalize("NFKC", "123") # → "123" +unicodedata.normalize("NFKC", "1.5") # → "1.5" +``` + +## Pydantic でのバリデーション + +Pydantic の `model_validator` を使って入力値の正規化を強制することを推奨します。 + +```python +from pydantic import BaseModel, Field, model_validator + +class PriceRequest(BaseModel): + price: str = Field(max_length=20, description="価格(半角数字)") + + @model_validator(mode="before") + @classmethod + def normalize_unicode(cls, values: dict) -> dict: + import unicodedata + if "price" in values and isinstance(values["price"], str): + values["price"] = unicodedata.normalize("NFKC", values["price"]) + return values +``` + +## 関連 Issue + +- [FT176] #500: parse_decimal_safe() の Unicode 全角数字受け入れ挙動を文書化 diff --git a/docs/how-to/email-address-parsing.md b/docs/how-to/email-address-parsing.md new file mode 100644 index 0000000..3bfd0b6 --- /dev/null +++ b/docs/how-to/email-address-parsing.md @@ -0,0 +1,57 @@ +# How-to: メールアドレスのパースと parseaddr() の挙動 + +## parseaddr() は寛容なパーサー + +`email.utils.parseaddr()` は RFC 2822 準拠のフォーマット(`"Name "` 形式)を解析しますが、 +**不正なアドレスを渡してもエラーを送出せず、空文字列を返します**。 + +```python +from email.utils import parseaddr + +# 正常ケース +parseaddr("Alice ") # → ("Alice", "alice@example.com") +parseaddr("alice@example.com") # → ("", "alice@example.com") + +# 不正なアドレス — エラーにならず ("", "") を返す +parseaddr("not-an-email") # → ("", "") +parseaddr("") # → ("", "") +parseaddr("bad @ format") # → ("", "") +``` + +## HTTP 境界での検証は別途行うこと + +`parseaddr()` の戻り値が空かどうかで有効性を確認しても、 +**セキュリティ上の検証としては不十分**です。ユーザーが入力したアドレスは +Pydantic の `EmailStr` や正規表現で検証した後に `parseaddr()` を使ってください。 + +```python +import re +from email.utils import parseaddr + +_EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$") + +def validate_and_parse(raw: str) -> tuple[str, str] | None: + name, addr = parseaddr(raw) + if not addr or not _EMAIL_RE.match(addr): + return None + return name, addr +``` + +## ヘッダーインジェクション対策 + +`Subject` や `From` ヘッダーに CR/LF (`\r\n`) が含まれると +**メールヘッダーインジェクション**が発生します。`email.message.EmailMessage` を使えば +自動的にエスケープされますが、`smtplib.sendmail()` に生文字列を渡す場合は +事前に CR/LF を除去してください。 + +```python +import re +_INJECT_RE = re.compile(r"[\r\n]") + +def sanitize_header(value: str) -> str: + return _INJECT_RE.sub("", value) +``` + +## 関連 Issue + +- [FT182] #511: parseaddr() の寛容な挙動を How-to ドキュメントに記載