Coverage for models / responses.py: 97.74%
214 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-17 01:43 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-17 01:43 +0000
1"""JSONPlaceholder APIレスポンスモデル
3XSS攻撃防止のため、ユーザー生成コンテンツフィールドに
4html.escape()サニタイゼーションを適用したPydanticモデル。
5(email・websiteはhtml.escape対象外: emailはEmailStr RFC準拠バリデーション、
6websiteはURL形式のためhtmlコンテキスト出力時は呼び出し元でエスケープ)
7モデル値はAPIレスポンスの意味論を保つ。HTML等への出力時のエスケープ責務は呼び出し元が持つ。
9実務推奨パターン:
101. Defense in Depth: 型検証 + サニタイゼーション + 長さ制限
112. Fail-Safe: バリデーションエラーは明確なエラーメッセージで
123. 最小権限: 必要最小限のフィールドのみ公開
14学習目標:
15- Pydantic field_validatorによるカスタムバリデーション
16- XSS保護のベストプラクティス
17- 型安全なAPIレスポンス処理
18"""
20import html
21import re
22import unicodedata
23from typing import Annotated
24from urllib.parse import ParseResult, quote, unquote, urlparse, urlunparse
26from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
28# RFC 3986 準拠のスキーム検出パターン(scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":")
29_SCHEME_RE: re.Pattern[str] = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
30_HTML_META_RE: re.Pattern[str] = re.compile(r'[<>"\'&]')
31_PERCENT_CTRL_RE: re.Pattern[str] = re.compile(
32 r"%[01][0-9a-f]|%7f", # C0制御文字(%00-%1f)およびDEL(%7f)を検出
33 # 注: C1制御文字(%80-%9f)は対象外。
34 # UTF-8/IRI由来のpercent-encoded内容と重複するため、グローバル拒否ではなく
35 # netlocのみunquote(errors="strict")で不正UTF-8として検出する。
36 # 注: %20(スペース)は制御文字ではないため非対象
37 re.IGNORECASE,
38)
39# 不完全な%シーケンス検出 — unquoteがリテラル扱いするためUnicodeDecodeErrorが発生しない
40# IGNORECASE flag で大文字小文字を統合(可読性目的、機能等価: r"%(?![0-9a-fA-F]{2})" と同一)
41_INCOMPLETE_PCT_RE: re.Pattern[str] = re.compile(r"%(?![0-9a-f]{2})", re.IGNORECASE)
42_ASCII_WHITESPACE_RE: re.Pattern[str] = re.compile(r"[ \t\n\r\f\v]")
43_VARIATION_SELECTORS: frozenset[str] = frozenset(
44 {chr(codepoint) for codepoint in range(0xFE00, 0xFE10)}
45 | {chr(codepoint) for codepoint in range(0xE0100, 0xE01F0)}
46)
47_WEBSITE_NORMALIZED_MAX_LENGTH: int = 2048
48_INVISIBLE_CATEGORIES = frozenset({"Cf", "Cc", "Zs", "Zl", "Zp"})
49# Cs(孤立サロゲート)を _INVISIBLE_CATEGORIES と合算した除去セット(1回目パスで使用)
50_STRIP_CATEGORIES = _INVISIBLE_CATEGORIES | frozenset({"Cs"})
53# URL正規化中のUnicodeカテゴリ参照を十分に吸収する余裕を持たせる。
54# 通常のURLで登場する文字種は数百程度だが、テスト・攻撃入力では多様な
55# 制御文字/不可視文字を含むため、余裕を持った上限にして再計算を避ける。
56def _is_strippable_char(c: str, categories: frozenset[str]) -> bool:
57 """不可視文字として除去すべき文字か判定する。
59 Variation Selectors は Mn に分類されるが、結合文字(例: U+0301)は保持し、
60 NFD由来のホスト名をサイレントに別文字列へ改変しない。
61 """
62 return c != " " and (unicodedata.category(c) in categories or c in _VARIATION_SELECTORS)
65def _strip_invisible_chars(v: str) -> str:
66 """不可視文字・制御文字・Unicode空白をURL文字列から除去(NFKC正規化含む2パス処理)
68 URLスキームバイパス防止のため、_STRIP_CATEGORIES(= _INVISIBLE_CATEGORIES | {"Cs"})
69 に属するUnicodeカテゴリを2パスの内包表記で除去する
70 (パス1: NFKC正規化前・Cs含む全カテゴリ、パス2: NFKC正規化後・Cs除外):
72 - Cs: Surrogate(孤立サロゲート U+D800-U+DFFF)— 有効なUnicode文字列に
73 含まれるべきでないためnormalize()前に除去(データ整合性)
74 - Cf: Format文字(Bidi制御, ゼロ幅文字, Word Joiner等)
75 - Cc: 制御文字(C0/C1制御文字, DEL等)
76 - Mn: 非スペーシングマーク(Variation Selectors U+FE00-U+FE0F と
77 Variation Selectors Supplement U+E0100-U+E01EF は個別除去。
78 結合文字 U+0300等は保持し、NFD由来のホスト名改変を避ける)
79 - Zs: Unicode空白(NBSP, Ogham Space, 全角空白等。U+0020通常スペースは
80 Zsカテゴリに属するが、c == " " の特例条件で保持)
81 ※ NFKC正規化前にも除去(U+3000等はNFKC後にU+0020へ変換される副作用を防止。
82 U+1680等はNFKC変換対象外だが一括除去でスキームバイパスを防止)
83 - Zl: 行区切り(U+2028 Line Separator)
84 - Zp: 段落区切り(U+2029 Paragraph Separator)
86 Python に同梱の Unicode バージョン内の新規文字に自動対応する。
87 (Unicode バージョン自体の更新には Python バージョンアップが必要)
88 """
89 # パス1: Cs(孤立サロゲート)と不可視文字を一括除去
90 # _STRIP_CATEGORIES = _INVISIBLE_CATEGORIES | {"Cs"} で Cs の個別除去を統合
91 # NFKC前にZs等を除去(NFKC後にU+0020へ変換される副作用防止)
92 # 全角英字(Ll/Lu等)はNFKC前に残し、NFKC正規化でASCIIに変換される
93 pre_filtered = "".join(c for c in v if not _is_strippable_char(c, _STRIP_CATEGORIES))
94 normalized = unicodedata.normalize("NFKC", pre_filtered)
95 # パス2: NFKC後に新たに生成された不可視文字を除去
96 # (Csは再出現しないため_INVISIBLE_CATEGORIESのみ)
97 return "".join(c for c in normalized if not _is_strippable_char(c, _INVISIBLE_CATEGORIES))
100# =============================================================================
101# ユーティリティ関数
102# =============================================================================
105def sanitize_user_content(value: str) -> str:
106 """ユーザー生成コンテンツをHTMLエスケープでサニタイズ
108 XSS攻撃を防ぐため、HTMLエスケープを適用。
109 特殊文字(<, >, &, ", ')をHTMLエンティティに変換。
111 Args:
112 value: サニタイズ対象の文字列
114 Returns:
115 サニタイズ済み文字列
117 Raises:
118 ValueError: value が str 型でない場合
120 Note:
121 主にPydantic field_validator経由で使用されます。
122 以前は str | None を受理していたが、現在は str のみ受理する。
123 None を渡した場合は ValueError が発生する。
125 Examples:
126 >>> sanitize_user_content("<script>alert('XSS')</script>")
127 '<script>alert('XSS')</script>'
129 """
130 if not isinstance(value, str):
131 raise ValueError(f"文字列が必要です(受け取った型: {type(value).__name__})")
132 # quote=True: シングルクォート、ダブルクォートもエスケープ
133 return html.escape(value, quote=True)
136def _validate_netloc(parsed: ParseResult) -> None:
137 """netloc のバリデーション.
139 存在確認・空白文字拒否・不正percent decode拒否・userinfo禁止・
140 HTMLメタ文字拒否・hostname解決チェックを行う。
141 """
142 if not parsed.netloc:
143 raise ValueError("有効なホスト名が含まれていません")
144 # 不正ポート文字列バイパス対策: parsed.port は整数でない場合 ValueError を送出する
145 # (例: https://example.com:abc/path は netloc チェックをパスするが port アクセスで検出)
146 try:
147 _ = parsed.port
148 except ValueError as e:
149 raise ValueError("ポートが無効です(整数値でなければなりません)") from e
150 # 多層防御: parsed.username/password に加え netloc の "@" リテラルも検査
151 # (urlparse が特定のエンコード済み入力で username=None を返すエッジケース対策)
152 try:
153 decoded_netloc = unquote(parsed.netloc, errors="strict")
154 except UnicodeDecodeError as e:
155 raise ValueError(f"URLに不正なパーセントエンコードが含まれています: {e}") from e
156 # raw と decoded 両方をチェック(%エンコードバイパス対策: https://example.com%20evil.com 等)
157 if _ASCII_WHITESPACE_RE.search(parsed.netloc) or _ASCII_WHITESPACE_RE.search(decoded_netloc):
158 raise ValueError("ホスト名に空白文字が含まれています")
159 has_at = "@" in parsed.netloc or "@" in decoded_netloc
160 if has_at:
161 raise ValueError("URLにuserinfo(ユーザー名/パスワード)は指定できません")
162 # @が含まれない場合のみ username/password を確認(urlparseのエッジケース補完)
163 # (urlparse が特定のエンコード済み入力で username=None を返すエッジケース対策)
164 try:
165 has_userinfo = parsed.username is not None or parsed.password is not None
166 except (ValueError, OverflowError) as e: # fmt: skip
167 # parsed.username/password は内部で独自にunquoteするため、L135-137のチェックとは独立
168 raise ValueError(f"URLのuserinfoパースに失敗しました(netloc={parsed.netloc!r})") from e
169 if has_userinfo: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 raise ValueError("URLにuserinfo(ユーザー名/パスワード)は指定できません")
171 # ホスト部にHTMLメタ文字(<, >, ", ', &)が含まれる場合は拒否
172 if _HTML_META_RE.search(parsed.netloc) or _HTML_META_RE.search(decoded_netloc):
173 raise ValueError("ホスト名に不正な文字が含まれています")
174 # hostname が None になるケース(例: 不正な IPv6 形式)を _normalize_url に渡す前に排除
175 if not parsed.hostname: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 raise ValueError("有効なホスト名が含まれていません")
179def _normalize_url(parsed: ParseResult) -> str:
180 """RFC 3986 §6.2.2.1(Case Normalization)に従いスキームとホスト部を小文字正規化する。
182 パス・パラメータ・クエリ・フラグメントは RFC 3986 §3.3–§3.5 の構文定義に従い
183 URLエンコードする。§6.2.2.2 の既存 %xx シーケンスのヘックス大文字化は未実施
184 (新規エンコード分は quote() が UPPERCASE で出力する)。
185 """
186 # ParseResultはnamedtupleだが、tuple直接指定でコードの意図を明示する
187 # パス・クエリ・フラグメントのXSS文字をURLエンコード(%を安全文字に含め二重エンコード防止)
188 # RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
189 # XSS防止: ' (single quote) を safe から除外 → %27 にエンコード
190 # &はquery/fragmentでパラメータ区切りとして必要なため保持(HTML出力時は呼び出し元でエスケープ)
191 # -._~ は Python quote() の _ALWAYS_SAFE に含まれるが、RFC 仕様との対応を明示
192 safe_path = quote(parsed.path, safe="/:@!$()*+,;=%-._~")
193 # RFC 3986 §3.3 (params は path の一部として扱う)
194 safe_params = quote(parsed.params, safe=";=@:!$()*+,/%-._~")
195 # RFC 3986 §3.4 query = *( pchar / "/" / "?" )
196 safe_query = quote(parsed.query, safe="=&+:@!$()*,;/?%-._~")
197 # RFC 3986 §3.5 fragment = *( pchar / "/" / "?" )
198 # フラグメントは path/query より "&" と "?" を緩く扱い、unreserved 文字は過剰エンコードしない
199 safe_fragment = quote(parsed.fragment, safe=":@!$&()*+,;=/?%-._~")
200 # hostname は urlparse が自動小文字化済み。netloc.lower() ではなく
201 # hostname + port で再構成し、percent-encoded 文字の大文字16進を保持する
202 hostname = parsed.hostname
203 if not hostname:
204 # _validate_netloc の `not parsed.hostname` で空文字列・None ケースは排除済み
205 # ここは直接呼び出し時のセーフガード(通常パスでは到達しない)
206 raise ValueError(f"ホスト名の解決に失敗しました(netloc={parsed.netloc!r})")
207 if ":" in hostname:
208 hostname = f"[{hostname}]"
209 netloc = f"{hostname}:{parsed.port}" if parsed.port is not None else hostname
210 return urlunparse(
211 (
212 parsed.scheme.lower(),
213 netloc,
214 safe_path,
215 safe_params,
216 safe_query,
217 safe_fragment,
218 )
219 )
222def _ensure_website_max_length(url: str) -> str:
223 """正規化後URL長の上限チェック(_WEBSITE_NORMALIZED_MAX_LENGTH文字)."""
224 if len(url) > _WEBSITE_NORMALIZED_MAX_LENGTH:
225 raise ValueError(
226 f"URL補完後の長さが上限{_WEBSITE_NORMALIZED_MAX_LENGTH}文字を超過しています({len(url)}文字)"
227 )
228 return url
231def _validate_scheme_less_url(sanitized: str) -> None:
232 """スキームなしURLのバリデーション: パーセントエンコード・パス・フラグメント・クエリを検証する。
234 Args:
235 sanitized: 前処理済み(不可視文字除去・strip済み)のURL文字列
237 Raises:
238 ValueError: 不正なパーセントエンコード、パス、フラグメント、クエリが含まれる場合
239 """
240 # errors='strict': 不正なパーセントエンコードをサイレント置換せず明示的エラーとして扱う
241 try:
242 decoded = unquote(sanitized, errors="strict")
243 except UnicodeDecodeError as e:
244 raise ValueError(f"URLに不正なパーセントエンコードが含まれています: {e}") from e
245 # 不完全な%シーケンス(例: %、%GG)はunquoteがリテラル扱いするため個別チェック
246 if _INCOMPLETE_PCT_RE.search(sanitized):
247 raise ValueError("URLに不完全なパーセントエンコードが含まれています")
248 if "/" in sanitized or "/" in decoded:
249 raise ValueError("スキームなしURLにパスは指定できません")
250 # %23(#)と %3F(?)のバイパス検出: decoded に含まれる#/?も検出
251 if "#" in sanitized or "#" in decoded:
252 raise ValueError("スキームなしURLにフラグメントは指定できません")
253 if "?" in sanitized or "?" in decoded:
254 raise ValueError("スキームなしURLにクエリは指定できません")
257# =============================================================================
258# 投稿関連モデル
259# =============================================================================
262class Post(BaseModel):
263 """ブログ投稿モデル
265 JSONPlaceholder /posts エンドポイントのレスポンス。
266 title, bodyフィールドにXSS保護を適用。
268 Attributes:
269 id: 投稿ID(1以上)
270 user_id: 投稿者ユーザーID(1以上)
271 title: 投稿タイトル(サニタイズ済み、最大200文字)
272 body: 投稿本文(サニタイズ済み、最大5000文字)
274 """
276 id: int = Field(..., ge=1, description="投稿ID")
277 user_id: int = Field(..., ge=1, alias="userId", description="投稿者ユーザーID")
278 title: str = Field(..., max_length=200, description="投稿タイトル")
279 body: str = Field(..., max_length=5000, description="投稿本文")
281 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid")
283 @field_validator("title", "body")
284 @classmethod
285 def sanitize_post_content(cls, v: str) -> str:
286 """投稿のタイトルと本文をサニタイズする。"""
287 return sanitize_user_content(v)
290class Comment(BaseModel):
291 """コメントモデル
293 JSONPlaceholder /comments エンドポイントのレスポンス。
294 name, bodyフィールドにXSS保護(html.escape)を適用。
295 emailはEmailStr型でRFC構文チェックのみ(html.escape非適用)。
297 Attributes:
298 id: コメントID(1以上)
299 post_id: 親投稿ID(1以上)
300 name: コメント投稿者名(サニタイズ済み、最大100文字)
301 email: コメント投稿者メールアドレス
302 (RFC構文チェック済み・DNS検証なし、最大100文字。
303 html.escape 非適用 — HTML出力時は呼び出し元で html.escape(email) 必須)
304 body: コメント本文(サニタイズ済み、最大2000文字)
306 """
308 id: int = Field(..., ge=1, description="コメントID")
309 post_id: int = Field(..., ge=1, alias="postId", description="親投稿ID")
310 name: str = Field(..., max_length=100, description="コメント投稿者名")
311 email: Annotated[
312 EmailStr,
313 Field(
314 max_length=100,
315 description="コメント投稿者メールアドレス(RFC構文チェック済み、DNS検証なし)",
316 ),
317 ]
318 body: str = Field(..., max_length=2000, description="コメント本文")
320 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid")
322 @field_validator("name", "body")
323 @classmethod
324 def sanitize_comment_content(cls, v: str) -> str:
325 """コメントの名前、本文をサニタイズする。"""
326 return sanitize_user_content(v)
329# =============================================================================
330# ユーザー関連モデル
331# =============================================================================
334class Geo(BaseModel):
335 """地理座標モデル
337 Addressモデルのネストされたフィールド。
338 JSONPlaceholderでは緯度経度が文字列で返される。
340 Attributes:
341 lat: 緯度(文字列形式、サニタイズ済み、最大50文字)
342 lng: 経度(文字列形式、サニタイズ済み、最大50文字)
344 Note:
345 lat/lngは数値座標文字列(例: "-40.7128")のため、
346 URLスキームバイパス防止を目的とする _strip_invisible_chars は非適用。
347 XSSはhtml.escape(sanitize_user_content経由)で対処。
349 Raises:
350 ValueError: lat/lng が str 型でない場合
352 """
354 lat: str = Field(..., max_length=50, description="緯度")
355 lng: str = Field(..., max_length=50, description="経度")
357 model_config = ConfigDict(extra="forbid")
359 @field_validator("lat", "lng")
360 @classmethod
361 def sanitize_geo_content(cls, v: str) -> str:
362 """地理座標をサニタイズする。"""
363 return sanitize_user_content(v)
366class Address(BaseModel):
367 """住所モデル
369 Userモデルのネストされたフィールド。
370 street, suite, city, zipcodeフィールドにXSS保護を適用。
372 Attributes:
373 street: 通り名(サニタイズ済み、最大200文字)
374 suite: 部屋番号/建物名(サニタイズ済み、最大100文字)
375 city: 市区町村(サニタイズ済み、最大100文字)
376 zipcode: 郵便番号(サニタイズ済み、最大20文字)
377 geo: 地理座標(ネストされたGeoモデル)
379 """
381 street: str = Field(..., max_length=200, description="通り名")
382 suite: str = Field(..., max_length=100, description="部屋番号/建物名")
383 city: str = Field(..., max_length=100, description="市区町村")
384 zipcode: str = Field(..., max_length=20, description="郵便番号")
385 geo: Geo = Field(..., description="地理座標")
387 model_config = ConfigDict(extra="forbid")
389 @field_validator("street", "suite", "city", "zipcode")
390 @classmethod
391 def sanitize_address_content(cls, v: str) -> str:
392 """住所情報をサニタイズする。"""
393 return sanitize_user_content(v)
396class Company(BaseModel):
397 """企業情報モデル
399 Userモデルのネストされたフィールド。
400 name, catch_phrase, bsフィールドにXSS保護を適用。
402 Attributes:
403 name: 企業名(サニタイズ済み、最大100文字)
404 catch_phrase: キャッチフレーズ(サニタイズ済み、最大200文字)
405 bs: ビジネススローガン(サニタイズ済み、最大200文字)
407 """
409 name: str = Field(..., max_length=100, description="企業名")
410 catch_phrase: str = Field(
411 ...,
412 max_length=200,
413 alias="catchPhrase",
414 description="キャッチフレーズ",
415 )
416 bs: str = Field(..., max_length=200, description="ビジネススローガン")
418 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid")
420 @field_validator("name", "catch_phrase", "bs")
421 @classmethod
422 def sanitize_company_content(cls, v: str) -> str:
423 """企業情報をサニタイズする。"""
424 return sanitize_user_content(v)
427class User(BaseModel):
428 """ユーザーモデル
430 JSONPlaceholder /users エンドポイントのレスポンス。
431 name, username, phoneフィールドにXSS保護(html.escape)を適用。
432 websiteフィールドはhtml.escape対象外。不可視文字除去(_strip_invisible_chars)・allowlist方式スキームバリデーション適用。
433 emailはEmailStr型でRFC構文チェック(DNS検証なし)。
435 Attributes:
436 id: ユーザーID(1以上)
437 name: ユーザー名(サニタイズ済み、最大100文字)
438 username: ユーザー名(英数字、サニタイズ済み、最大50文字)
439 email: メールアドレス(EmailStr RFC構文チェック済み・DNS検証なし、最大100文字。
440 html.escape 非適用。HTML出力時は呼び出し元で html.escape(email) 必須)
441 address: 住所情報(ネストされたAddressモデル)
442 phone: 電話番号(サニタイズ済み、最大50文字)
443 website: ウェブサイトURL(制御文字除去・前後空白除去・http/httpsスキーム検証済み、
444 入力時最大2048文字・正規化後2048文字以内)
445 company: 企業情報(ネストされたCompanyモデル)
447 """
449 id: int = Field(..., ge=1, description="ユーザーID")
450 name: str = Field(..., max_length=100, description="ユーザー名")
451 username: str = Field(..., max_length=50, description="ユーザー名(英数字)")
452 email: Annotated[
453 EmailStr,
454 Field(max_length=100, description="メールアドレス(RFC構文チェック済み、DNS検証なし)"),
455 ]
456 address: Address = Field(..., description="住所情報")
457 phone: str = Field(..., max_length=50, description="電話番号")
458 website: str = Field(
459 ...,
460 min_length=1,
461 max_length=2048,
462 description="ウェブサイトURL(入力時最大2048文字・正規化後2048文字以内、制御文字除去・前後空白除去・http/httpsスキーム検証済み)",
463 )
464 company: Company = Field(..., description="企業情報")
466 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid")
468 @field_validator("name", "username", "phone")
469 @classmethod
470 def sanitize_user_fields(cls, v: str) -> str:
471 """ユーザー情報フィールド(name, username, phone)をサニタイズする。"""
472 return sanitize_user_content(v)
474 @field_validator("website", mode="before")
475 @classmethod
476 def validate_website_scheme(cls, v: object) -> str:
477 """websiteフィールドのURLスキーム検証(allowlist方式)
479 RFC 3986準拠のスキーム検出で、http://とhttps://のみ許可する。
480 スキームなしドメイン(例: hildegard.org)はhttps://を自動補完する。
481 domain:portパターン(例: example.com:8080)はスキームが明示されていない
482 場合は拒否する(http://example.com:8080 は許可)。
483 javascript:, data:, ftp:, file: 等の危険スキームおよびプロトコル相対URL(//)は全て拒否。
484 http/httpsスキームおよびホスト部はRFC 3986 Section 6.2.2.1に従い小文字に正規化。
486 Args:
487 v: バリデーション対象の値(mode="before"のため任意型。
488 str以外の場合はValueErrorを送出)
490 Returns:
491 バリデーション済みURL文字列(スキームなしの場合はhttps://を補完、
492 制御文字除去・前後空白除去・RFC 3986 §6.2.2.1正規化済み、
493 最大2048文字以内)
495 Raises:
496 ValueError: 以下のいずれかの場合:
497 - 入力が文字列でない
498 - 制御文字除去後にURLが空
499 - パーセントエンコードされた制御文字(%00-%1f および %7f(DEL))を含む
500 - プロトコル相対URL(//始まり)
501 - http/https以外の危険スキーム
502 - 有効なホスト名なし
503 - ホスト名にHTMLメタ文字(<, >, ", ', &)を含む
504 - ポートが無効(整数値でない)
505 - userinfoを含む(例: https://user@host — RFC 3986 バイパス防止)
506 - スキームなしURLにパス(/)が含まれる(ドメインのみ許可)
507 - スキームなしURLにポートが含まれる
508 (例: 192.168.1.1:8080 — IPアドレス:portはスキームなしとして検出。
509 ドメイン名:port(例: example.com:8080)は_SCHEME_REがスキームとして
510 構文マッチするため「危険なURLスキーム」として先に拒否される)
511 - スキームなしURLに不正なパーセントエンコードが含まれる
512 (例: example.com%80 — UTF-8として不正なバイト列)
513 - 不完全なパーセントエンコードが含まれる(スキーム有無を問わず)
514 (例: % 単独、%GG 等の不正な16進シーケンス)
516 Note:
517 websiteフィールドの値をHTMLコンテキストへ出力する際は、
518 呼び出し元で html.escape() を適用すること(URLはhtml.escape対象外のため)。
520 """
521 if not isinstance(v, str):
522 raise ValueError(f"文字列が必要です(受け取った型: {type(v).__name__})")
524 sanitized = _strip_invisible_chars(v).strip()
525 # min_length=1 は真の空文字列を、ここでは制御文字のみの文字列を捕捉(2段階チェック)
526 if not sanitized:
527 raise ValueError("websiteが空になりました(制御文字除去後)")
528 # CRLF injection防止: パーセントエンコードされた制御文字を拒否(%00-%1f全範囲)
529 # _strip_invisible_chars は実際の制御文字を除去するが、
530 # %0d%0a 等のエンコード形式はバイパスする
531 sanitized_lower = sanitized.lower()
532 if _PERCENT_CTRL_RE.search(sanitized_lower):
533 raise ValueError("URLにパーセントエンコードされた制御文字が含まれています")
534 # 不完全な%シーケンス検出(全ブランチ共通 — http/httpsおよびスキームなし両対応)
535 # _validate_scheme_less_url でも同様にチェックするが多層防御として二重確認
536 if _INCOMPLETE_PCT_RE.search(sanitized): 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 raise ValueError("URLに不完全なパーセントエンコードが含まれています")
538 # プロトコル相対URLを明示的に拒否(攻撃面削減)
539 if sanitized_lower.startswith("//"):
540 raise ValueError("プロトコル相対URLは許可されていません")
541 # urlparseは各分岐で1回のみ呼び出す
542 # (http/httpsブランチと補完ブランチで入力が異なるため共通化不可)
543 if sanitized_lower.startswith(("http://", "https://")):
544 # _validate_netloc / _normalize_url の ValueError はそのまま伝播
545 # NOTE: sanitized(元の大文字混在)を使用 — スキーム小文字化は _normalize_url に委譲
546 # (sanitized_lower は path/query の大文字を失うため使用不可)
547 parsed = urlparse(sanitized)
548 _validate_netloc(parsed)
549 return _ensure_website_max_length(_normalize_url(parsed))
550 # RFC 3986スキーム検出: http/https以外のスキームが存在すれば拒否
551 # is_domain_portロジックを削除: domain:portはスキームなし扱いのため
552 # http(s)://を明示しない限り拒否(例: example.com:8080 → ValueError)
553 if _SCHEME_RE.match(sanitized_lower):
554 raise ValueError("危険なURLスキームが検出されました")
555 # スキームなし → https:// を補完して検証
556 # _validate_netloc / _normalize_url の ValueError はそのまま伝播
557 # 設計意図: スキームなしURLはドメインのみ許可(パス付きURLは拒否)
558 # パーセントエンコード済み %2F によるバイパスも防止
559 _validate_scheme_less_url(sanitized)
560 parsed = urlparse("https://" + sanitized)
561 _validate_netloc(parsed)
562 # スキームなし補完後のポートチェック:
563 # IPアドレス:port形式(例: 192.168.1.1:8080)がここに到達する
564 # (ドメイン:port形式(例: example.com:8080)は _SCHEME_RE にマッチし
565 # 「危険なURLスキーム」として上流で拒否されるため、このチェックに到達しない)
566 if parsed.port is not None:
567 raise ValueError(
568 "スキームなしURLにポートは指定できません(http(s)://を明示してください)"
569 )
570 return _ensure_website_max_length(_normalize_url(parsed))
573# =============================================================================
574# TODO・アルバム・写真モデル
575# =============================================================================
578class Todo(BaseModel):
579 """TODOモデル
581 JSONPlaceholder /todos エンドポイントのレスポンス。
582 titleフィールドにXSS保護を適用。
584 Attributes:
585 id: TODO ID(1以上)
586 user_id: 所有者ユーザーID(1以上)
587 title: TODOタイトル(サニタイズ済み、最大200文字)
588 completed: 完了フラグ
590 """
592 id: int = Field(..., ge=1, description="TODO ID")
593 user_id: int = Field(..., ge=1, alias="userId", description="所有者ユーザーID")
594 title: str = Field(..., max_length=200, description="TODOタイトル")
595 completed: bool = Field(..., description="完了フラグ")
597 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid")
599 @field_validator("title")
600 @classmethod
601 def sanitize_todo_title(cls, v: str) -> str:
602 """TODOタイトルをサニタイズする。"""
603 return sanitize_user_content(v)
606class Album(BaseModel):
607 """アルバムモデル
609 JSONPlaceholder /albums エンドポイントのレスポンス。
610 titleフィールドにXSS保護を適用。
612 Attributes:
613 id: アルバムID(1以上)
614 user_id: 所有者ユーザーID(1以上)
615 title: アルバムタイトル(サニタイズ済み、最大200文字)
617 """
619 id: int = Field(..., ge=1, description="アルバムID")
620 user_id: int = Field(..., ge=1, alias="userId", description="所有者ユーザーID")
621 title: str = Field(..., max_length=200, description="アルバムタイトル")
623 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid")
625 @field_validator("title")
626 @classmethod
627 def sanitize_album_title(cls, v: str) -> str:
628 """アルバムタイトルをサニタイズする。"""
629 return sanitize_user_content(v)
632class Photo(BaseModel):
633 """写真モデル
635 JSONPlaceholder /photos エンドポイントのレスポンス。
636 titleフィールドにXSS保護を適用。url・thumbnail_urlはhttp/httpsスキーム必須、
637 不可視文字除去・RFC 3986 §6.2.2.1正規化(スキーム・ホスト小文字化)を適用。
639 Attributes:
640 id: 写真ID(1以上)
641 album_id: 親アルバムID(1以上)
642 title: 写真タイトル(サニタイズ済み、最大200文字)
643 url: 写真URL(http/https必須・不可視文字除去・userinfo禁止・RFC 3986正規化済み、
644 最大2048文字)
645 thumbnail_url: サムネイルURL(http/https必須・不可視文字除去・userinfo禁止・
646 RFC 3986正規化済み、最大2048文字)
648 """
650 id: int = Field(..., ge=1, description="写真ID")
651 album_id: int = Field(..., ge=1, alias="albumId", description="親アルバムID")
652 title: str = Field(..., max_length=200, description="写真タイトル")
653 url: str = Field(..., max_length=2048, description="写真URL")
654 thumbnail_url: str = Field(
655 ...,
656 max_length=2048,
657 alias="thumbnailUrl",
658 description="サムネイルURL",
659 )
661 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid")
663 @field_validator("title")
664 @classmethod
665 def sanitize_photo_title(cls, v: str) -> str:
666 """写真タイトルをサニタイズする。"""
667 return sanitize_user_content(v)
669 # mode 未指定(デフォルト after): Pydantic が str 型強制後にバリデーション実行
670 # validate_website_scheme は mode="before" だが、Photo URL は外部API由来のため
671 # str 型が保証されており mode="after" で十分
672 @field_validator("url", "thumbnail_url")
673 @classmethod
674 def validate_url_scheme(cls, v: str) -> str:
675 """URLスキームがhttp/httpsであることを検証
677 セキュリティのため、javascript:やdata:などの
678 潜在的に危険なスキームを拒否。
679 不可視文字(Cf/Cs/Cc/Zs/Zl/Zpカテゴリ)と Variation Selector を除去してから
680 スキーム検証を行う。Mn 文字のうち結合文字は保持し、NFD由来のホスト名を
681 サイレントに別文字列へ改変しない。
683 Args:
684 v: 検証対象のURL文字列
686 Returns:
687 検証済みURL文字列(制御文字除去・前後空白除去・RFC 3986正規化済み、
688 最大2048文字以内)
690 Raises:
691 ValueError: 以下のいずれかの場合:
692 - 制御文字除去後にURLが空
693 - パーセントエンコードされた制御文字(%00-%1f および %7f(DEL))を含む
694 - http/https以外のスキーム(またはスキームなし)
695 - 有効なホスト名なし
696 - ホスト名にHTMLメタ文字(<, >, ", ', &)を含む
697 - ポートが無効(整数値でない)
698 - userinfoを含む(例: https://user@host — RFC 3986 バイパス防止)
699 - 正規化後URL長が2048文字を超える
701 Note:
702 User.validate_website_scheme と異なり、スキームなしURLへの自動補完は行わない。
703 外部API由来URLのためスキームは必須。
704 HTMLコンテキストへ出力する場合は、呼び出し元で html.escape() による
705 エスケープが必須。
707 """
708 sanitized = _strip_invisible_chars(v).strip()
709 if not sanitized:
710 raise ValueError("URLが空になりました(制御文字除去後)")
711 # CRLF injection防止: パーセントエンコードされた制御文字を拒否(%00-%1f全範囲)
712 sanitized_lower = sanitized.lower()
713 if _PERCENT_CTRL_RE.search(sanitized_lower):
714 raise ValueError("URLにパーセントエンコードされた制御文字が含まれています")
715 # 不完全な%シーケンス(%、%G、%GGなど)はunquoteがリテラル扱いするため個別チェック
716 if _INCOMPLETE_PCT_RE.search(sanitized):
717 raise ValueError("URLに不完全なパーセントエンコードが含まれています")
718 if not sanitized_lower.startswith(("http://", "https://")):
719 raise ValueError("URLはhttp://またはhttps://で始まる必要があります")
720 # _validate_netloc / _normalize_url の ValueError はそのまま伝播
721 parsed = urlparse(sanitized)
722 _validate_netloc(parsed)
723 return _ensure_website_max_length(_normalize_url(parsed))
726# =============================================================================
727# 学習ポイント:
728#
729# 1. XSS保護の実務パターン:
730# - Defense in Depth: 型検証 + サニタイゼーション + 長さ制限
731# - html.escape(quote=True): シングル/ダブルクォートもエスケープ
732# - 明確なエラーメッセージで問題を早期発見
733#
734# 2. Pydantic field_validator活用:
735# - @classmethod デコレータで型安全なバリデーション
736# - 複数フィールドに同一バリデータを適用可能
737# - カスタムエラーメッセージで開発者体験向上
738#
739# 3. 日本語ドキュメント:
740# - docstring: 機能の説明、引数、戻り値を明確に
741# - Field description: フィールドの意味を簡潔に
742# - コメント: 実装の意図や注意点を記載
743#
744# 4. 実務推奨設計:
745# - ネストモデル(Company)で複雑なJSONに対応
746# - alias設定(userId → user_id)でPythonic命名
747# - 長さ制限で異常データからシステムを保護
748# =============================================================================