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

1"""JSONPlaceholder APIレスポンスモデル 

2 

3XSS攻撃防止のため、ユーザー生成コンテンツフィールドに 

4html.escape()サニタイゼーションを適用したPydanticモデル。 

5(email・websiteはhtml.escape対象外: emailはEmailStr RFC準拠バリデーション、 

6websiteはURL形式のためhtmlコンテキスト出力時は呼び出し元でエスケープ) 

7モデル値はAPIレスポンスの意味論を保つ。HTML等への出力時のエスケープ責務は呼び出し元が持つ。 

8 

9実務推奨パターン: 

101. Defense in Depth: 型検証 + サニタイゼーション + 長さ制限 

112. Fail-Safe: バリデーションエラーは明確なエラーメッセージで 

123. 最小権限: 必要最小限のフィールドのみ公開 

13 

14学習目標: 

15- Pydantic field_validatorによるカスタムバリデーション 

16- XSS保護のベストプラクティス 

17- 型安全なAPIレスポンス処理 

18""" 

19 

20import html 

21import re 

22import unicodedata 

23from typing import Annotated 

24from urllib.parse import ParseResult, quote, unquote, urlparse, urlunparse 

25 

26from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator 

27 

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"}) 

51 

52 

53# URL正規化中のUnicodeカテゴリ参照を十分に吸収する余裕を持たせる。 

54# 通常のURLで登場する文字種は数百程度だが、テスト・攻撃入力では多様な 

55# 制御文字/不可視文字を含むため、余裕を持った上限にして再計算を避ける。 

56def _is_strippable_char(c: str, categories: frozenset[str]) -> bool: 

57 """不可視文字として除去すべき文字か判定する。 

58 

59 Variation Selectors は Mn に分類されるが、結合文字(例: U+0301)は保持し、 

60 NFD由来のホスト名をサイレントに別文字列へ改変しない。 

61 """ 

62 return c != " " and (unicodedata.category(c) in categories or c in _VARIATION_SELECTORS) 

63 

64 

65def _strip_invisible_chars(v: str) -> str: 

66 """不可視文字・制御文字・Unicode空白をURL文字列から除去(NFKC正規化含む2パス処理) 

67 

68 URLスキームバイパス防止のため、_STRIP_CATEGORIES(= _INVISIBLE_CATEGORIES | {"Cs"}) 

69 に属するUnicodeカテゴリを2パスの内包表記で除去する 

70 (パス1: NFKC正規化前・Cs含む全カテゴリ、パス2: NFKC正規化後・Cs除外): 

71 

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) 

85 

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)) 

98 

99 

100# ============================================================================= 

101# ユーティリティ関数 

102# ============================================================================= 

103 

104 

105def sanitize_user_content(value: str) -> str: 

106 """ユーザー生成コンテンツをHTMLエスケープでサニタイズ 

107 

108 XSS攻撃を防ぐため、HTMLエスケープを適用。 

109 特殊文字(<, >, &, ", ')をHTMLエンティティに変換。 

110 

111 Args: 

112 value: サニタイズ対象の文字列 

113 

114 Returns: 

115 サニタイズ済み文字列 

116 

117 Raises: 

118 ValueError: value が str 型でない場合 

119 

120 Note: 

121 主にPydantic field_validator経由で使用されます。 

122 以前は str | None を受理していたが、現在は str のみ受理する。 

123 None を渡した場合は ValueError が発生する。 

124 

125 Examples: 

126 >>> sanitize_user_content("<script>alert('XSS')</script>") 

127 '&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;' 

128 

129 """ 

130 if not isinstance(value, str): 

131 raise ValueError(f"文字列が必要です(受け取った型: {type(value).__name__}") 

132 # quote=True: シングルクォート、ダブルクォートもエスケープ 

133 return html.escape(value, quote=True) 

134 

135 

136def _validate_netloc(parsed: ParseResult) -> None: 

137 """netloc のバリデーション. 

138 

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("有効なホスト名が含まれていません") 

177 

178 

179def _normalize_url(parsed: ParseResult) -> str: 

180 """RFC 3986 §6.2.2.1(Case Normalization)に従いスキームとホスト部を小文字正規化する。 

181 

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 ) 

220 

221 

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 

229 

230 

231def _validate_scheme_less_url(sanitized: str) -> None: 

232 """スキームなしURLのバリデーション: パーセントエンコード・パス・フラグメント・クエリを検証する。 

233 

234 Args: 

235 sanitized: 前処理済み(不可視文字除去・strip済み)のURL文字列 

236 

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にクエリは指定できません") 

255 

256 

257# ============================================================================= 

258# 投稿関連モデル 

259# ============================================================================= 

260 

261 

262class Post(BaseModel): 

263 """ブログ投稿モデル 

264 

265 JSONPlaceholder /posts エンドポイントのレスポンス。 

266 title, bodyフィールドにXSS保護を適用。 

267 

268 Attributes: 

269 id: 投稿ID(1以上) 

270 user_id: 投稿者ユーザーID(1以上) 

271 title: 投稿タイトル(サニタイズ済み、最大200文字) 

272 body: 投稿本文(サニタイズ済み、最大5000文字) 

273 

274 """ 

275 

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="投稿本文") 

280 

281 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid") 

282 

283 @field_validator("title", "body") 

284 @classmethod 

285 def sanitize_post_content(cls, v: str) -> str: 

286 """投稿のタイトルと本文をサニタイズする。""" 

287 return sanitize_user_content(v) 

288 

289 

290class Comment(BaseModel): 

291 """コメントモデル 

292 

293 JSONPlaceholder /comments エンドポイントのレスポンス。 

294 name, bodyフィールドにXSS保護(html.escape)を適用。 

295 emailはEmailStr型でRFC構文チェックのみ(html.escape非適用)。 

296 

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文字) 

305 

306 """ 

307 

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="コメント本文") 

319 

320 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid") 

321 

322 @field_validator("name", "body") 

323 @classmethod 

324 def sanitize_comment_content(cls, v: str) -> str: 

325 """コメントの名前、本文をサニタイズする。""" 

326 return sanitize_user_content(v) 

327 

328 

329# ============================================================================= 

330# ユーザー関連モデル 

331# ============================================================================= 

332 

333 

334class Geo(BaseModel): 

335 """地理座標モデル 

336 

337 Addressモデルのネストされたフィールド。 

338 JSONPlaceholderでは緯度経度が文字列で返される。 

339 

340 Attributes: 

341 lat: 緯度(文字列形式、サニタイズ済み、最大50文字) 

342 lng: 経度(文字列形式、サニタイズ済み、最大50文字) 

343 

344 Note: 

345 lat/lngは数値座標文字列(例: "-40.7128")のため、 

346 URLスキームバイパス防止を目的とする _strip_invisible_chars は非適用。 

347 XSSはhtml.escape(sanitize_user_content経由)で対処。 

348 

349 Raises: 

350 ValueError: lat/lng が str 型でない場合 

351 

352 """ 

353 

354 lat: str = Field(..., max_length=50, description="緯度") 

355 lng: str = Field(..., max_length=50, description="経度") 

356 

357 model_config = ConfigDict(extra="forbid") 

358 

359 @field_validator("lat", "lng") 

360 @classmethod 

361 def sanitize_geo_content(cls, v: str) -> str: 

362 """地理座標をサニタイズする。""" 

363 return sanitize_user_content(v) 

364 

365 

366class Address(BaseModel): 

367 """住所モデル 

368 

369 Userモデルのネストされたフィールド。 

370 street, suite, city, zipcodeフィールドにXSS保護を適用。 

371 

372 Attributes: 

373 street: 通り名(サニタイズ済み、最大200文字) 

374 suite: 部屋番号/建物名(サニタイズ済み、最大100文字) 

375 city: 市区町村(サニタイズ済み、最大100文字) 

376 zipcode: 郵便番号(サニタイズ済み、最大20文字) 

377 geo: 地理座標(ネストされたGeoモデル) 

378 

379 """ 

380 

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="地理座標") 

386 

387 model_config = ConfigDict(extra="forbid") 

388 

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) 

394 

395 

396class Company(BaseModel): 

397 """企業情報モデル 

398 

399 Userモデルのネストされたフィールド。 

400 name, catch_phrase, bsフィールドにXSS保護を適用。 

401 

402 Attributes: 

403 name: 企業名(サニタイズ済み、最大100文字) 

404 catch_phrase: キャッチフレーズ(サニタイズ済み、最大200文字) 

405 bs: ビジネススローガン(サニタイズ済み、最大200文字) 

406 

407 """ 

408 

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="ビジネススローガン") 

417 

418 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid") 

419 

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) 

425 

426 

427class User(BaseModel): 

428 """ユーザーモデル 

429 

430 JSONPlaceholder /users エンドポイントのレスポンス。 

431 name, username, phoneフィールドにXSS保護(html.escape)を適用。 

432 websiteフィールドはhtml.escape対象外。不可視文字除去(_strip_invisible_chars)・allowlist方式スキームバリデーション適用。 

433 emailはEmailStr型でRFC構文チェック(DNS検証なし)。 

434 

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モデル) 

446 

447 """ 

448 

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="企業情報") 

465 

466 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid") 

467 

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) 

473 

474 @field_validator("website", mode="before") 

475 @classmethod 

476 def validate_website_scheme(cls, v: object) -> str: 

477 """websiteフィールドのURLスキーム検証(allowlist方式) 

478 

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に従い小文字に正規化。 

485 

486 Args: 

487 v: バリデーション対象の値(mode="before"のため任意型。 

488 str以外の場合はValueErrorを送出) 

489 

490 Returns: 

491 バリデーション済みURL文字列(スキームなしの場合はhttps://を補完、 

492 制御文字除去・前後空白除去・RFC 3986 §6.2.2.1正規化済み、 

493 最大2048文字以内) 

494 

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進シーケンス) 

515 

516 Note: 

517 websiteフィールドの値をHTMLコンテキストへ出力する際は、 

518 呼び出し元で html.escape() を適用すること(URLはhtml.escape対象外のため)。 

519 

520 """ 

521 if not isinstance(v, str): 

522 raise ValueError(f"文字列が必要です(受け取った型: {type(v).__name__}") 

523 

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)) 

571 

572 

573# ============================================================================= 

574# TODO・アルバム・写真モデル 

575# ============================================================================= 

576 

577 

578class Todo(BaseModel): 

579 """TODOモデル 

580 

581 JSONPlaceholder /todos エンドポイントのレスポンス。 

582 titleフィールドにXSS保護を適用。 

583 

584 Attributes: 

585 id: TODO ID(1以上) 

586 user_id: 所有者ユーザーID(1以上) 

587 title: TODOタイトル(サニタイズ済み、最大200文字) 

588 completed: 完了フラグ 

589 

590 """ 

591 

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="完了フラグ") 

596 

597 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid") 

598 

599 @field_validator("title") 

600 @classmethod 

601 def sanitize_todo_title(cls, v: str) -> str: 

602 """TODOタイトルをサニタイズする。""" 

603 return sanitize_user_content(v) 

604 

605 

606class Album(BaseModel): 

607 """アルバムモデル 

608 

609 JSONPlaceholder /albums エンドポイントのレスポンス。 

610 titleフィールドにXSS保護を適用。 

611 

612 Attributes: 

613 id: アルバムID(1以上) 

614 user_id: 所有者ユーザーID(1以上) 

615 title: アルバムタイトル(サニタイズ済み、最大200文字) 

616 

617 """ 

618 

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="アルバムタイトル") 

622 

623 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid") 

624 

625 @field_validator("title") 

626 @classmethod 

627 def sanitize_album_title(cls, v: str) -> str: 

628 """アルバムタイトルをサニタイズする。""" 

629 return sanitize_user_content(v) 

630 

631 

632class Photo(BaseModel): 

633 """写真モデル 

634 

635 JSONPlaceholder /photos エンドポイントのレスポンス。 

636 titleフィールドにXSS保護を適用。url・thumbnail_urlはhttp/httpsスキーム必須、 

637 不可視文字除去・RFC 3986 §6.2.2.1正規化(スキーム・ホスト小文字化)を適用。 

638 

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文字) 

647 

648 """ 

649 

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 ) 

660 

661 model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="forbid") 

662 

663 @field_validator("title") 

664 @classmethod 

665 def sanitize_photo_title(cls, v: str) -> str: 

666 """写真タイトルをサニタイズする。""" 

667 return sanitize_user_content(v) 

668 

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であることを検証 

676 

677 セキュリティのため、javascript:やdata:などの 

678 潜在的に危険なスキームを拒否。 

679 不可視文字(Cf/Cs/Cc/Zs/Zl/Zpカテゴリ)と Variation Selector を除去してから 

680 スキーム検証を行う。Mn 文字のうち結合文字は保持し、NFD由来のホスト名を 

681 サイレントに別文字列へ改変しない。 

682 

683 Args: 

684 v: 検証対象のURL文字列 

685 

686 Returns: 

687 検証済みURL文字列(制御文字除去・前後空白除去・RFC 3986正規化済み、 

688 最大2048文字以内) 

689 

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文字を超える 

700 

701 Note: 

702 User.validate_website_scheme と異なり、スキームなしURLへの自動補完は行わない。 

703 外部API由来URLのためスキームは必須。 

704 HTMLコンテキストへ出力する場合は、呼び出し元で html.escape() による 

705 エスケープが必須。 

706 

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)) 

724 

725 

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# =============================================================================