2-5. 入力検証とサニタイズ - 「ユーザーを信じない」設計
所要時間: 40-60分(がっつりなら2セッション分) ゴール: allow-list 思考で入力を検証し、SQL/HTML/ファイル/Unicode の各文脈で安全な処理が書ける コミット内容:
~/learn/security/day-validation/に validator を使った検証コードとファイルアップロード安全実装
前章とのつながり
このレッスンの位置づけ
実害シーン: 入力検証ミスの被害
- Equifax (2017): HTTP ヘッダの入力を Struts2 が素で評価 → RCE → 1.47億件流出、CEO 辞任
- ImageTragick (2016): 画像アップロード機能経由で 任意コマンド実行。多くの画像処理サービスが即座に被害
- 某ECサイト: ファイル名検証なし →
../../../etc/passwdで Path Traversal、サーバー設定丸見え入力検証は 「すべてのセキュリティ事故の上流」 にある。ここを固めれば下流の事故が激減する。
大前提: 「入力を信用しない」がセキュリティの基本姿勢
ほとんどのセキュリティ事故は、突き詰めると 「ユーザー入力を素のまま使った」 ことに起因します:
- SQL Injection: 入力をクエリに連結
- XSS: 入力を HTML にそのまま埋める
- Path Traversal: 入力をファイルパスに使う
- Command Injection: 入力をシェルコマンドに渡す
- SSRF: 入力 URL を素で fetch
- LDAP / NoSQL Injection: 入力をクエリ構文に展開
- XXE / Billion Laughs: 入力 XML を素でパース
「バリデーション + サニタイズ + コンテキスト別エスケープ」の3層で防ぐ。このレッスンではその3層を網羅する。
実例:
- ImageTragick (CVE-2016-3714): ImageMagick が画像の MIME を偽装した「PDF」ファイル経由でシェルコマンド実行を許す脆弱性。多くの画像アップロード機能が即座にやられた
- 2017年 Equifax (CVE-2017-5638, Apache Struts): HTTP ヘッダの入力を素で OGNL 式評価 → リモートコード実行 → 1億4700万件の流出
- 2023年 MOVEit (CVE-2023-34362): 入力検証不備の SQL Injection、数百企業が被害
セッション①: 検証の基本戦略(30分)
1. allow-list vs deny-list(最重要思想)
| 方式 | 考え方 | 例 |
|---|---|---|
| allow-list (whitelist) | 「これだけ許可」 | 「英数字とハイフンだけ」 |
| deny-list (blacklist) | 「これを禁止」 | 「<script> を除去」 |
allow-list が圧倒的に優位な理由
deny-list は 「攻撃者が知っている全部の攻撃手段」を列挙する という負け戦。新しい攻撃手段が出てくるたびにルール追加が必要。
allow-list は 「自分が許可するものを明示」 だけ。それ以外は全部弾く。新攻撃手段が出ても、許可リストに無い限り通らない。
「拒否すべきものをリストアップする」より「許可すべきものをリストアップする」方が、リストが短く、抜け漏れが少ない。
例: パスワードリセットの URL に使うトークン
- deny-list 発想: 「
<、>、'、"が含まれていたら拒否」 → 抜け漏れ多数、SQL/HTML/JSON 文脈で別の特殊文字を見落とす- allow-list 発想: 「
[A-Za-z0-9_-]{32,}のみ許可」 → これ以外は問答無用で拒否
2. 多層防御(フロント / バック / DB)
[ブラウザ] ─→ [API] ─→ [DB]
UX用検証 セキュリティ検証 制約
各層の役割分担
- フロント(HTML / JS): UX のための検証。「メールアドレス形式じゃないと送信ボタンを押せない」など。セキュリティ目的にはならない(攻撃者は curl で API を直接叩ける)
- バック(API): セキュリティの本丸。すべての入力に対して allow-list で検証
- DB: スキーマ制約(NOT NULL、UNIQUE、CHECK、FK)で最後の砦。アプリの bug があっても DB が止める
3層あれば、1層に穴があっても他で止まる。
フロントだけで検証
<input pattern="[A-Za-z0-9]+" required>なぜNGか:
- 攻撃者は
<form>を経由しない。curl -X POST -d 'name=<script>...' https://api...で直叩き- フロントの JS は無効化・改変が自由
- フロント検証は UX のため、セキュリティのためではない
バックエンド側で同じバリデーションを必ず実装する。
3. バリデーションの種類
バリデーション 4 分類
- 形式 (format): メール / URL / UUID / ISO 日付 / 電話番号など
- 範囲 (range): 最小・最大長、数値の上限下限
- 一意性 (uniqueness): メールアドレスの重複なし、ユーザー名重複なし
- 業務ルール (business rule): 「未成年は購入不可」「終了日は開始日より後」など
形式・範囲は struct tag で宣言的に。一意性は DB の UNIQUE 制約 + アプリの事前チェック。業務ルールはサービス層で書く。
4. Go: go-playground/validator(実務標準)
mkdir -p ~/learn/security/day-validation
cd ~/learn/security/day-validation
go mod init validation-demo
go get github.com/go-playground/validator/v10package main
import (
"fmt"
"github.com/go-playground/validator/v10"
)
type SignupRequest struct {
Email string `json:"email" validate:"required,email,max=254"`
Username string `json:"username" validate:"required,alphanum,min=3,max=30"`
Password string `json:"password" validate:"required,min=12,max=128"`
Age int `json:"age" validate:"gte=13,lte=120"`
Role string `json:"role" validate:"required,oneof=user admin"`
Website string `json:"website" validate:"omitempty,url"`
}
var v = validator.New()
func handleSignup(req SignupRequest) error {
if err := v.Struct(req); err != nil {
// バリデーションエラーを使いやすく整形
for _, e := range err.(validator.ValidationErrors) {
fmt.Printf("field=%s tag=%s value=%v\n", e.Field(), e.Tag(), e.Value())
}
return err
}
return nil
}このコードのポイント
required: 空でないことurl: 形式チェックmin/max/gte/lte: 範囲チェック。長さ上限は必ず付ける(DoS 対策)alphanum: allow-list 思考の文字種制限oneof=user admin: 取りうる値の列挙(最強の allow-list)omitempty: 任意フィールド
validate:"required,oneof=..."は最強のパターン。「想定外の値を一切受け付けない」を1行で表現。
長さ上限を忘れない
// NG: Bio に上限なし type Profile struct { Bio string `validate:"required"` }なぜNGか: 攻撃者が 1GB の文字列を送ってくると、メモリと DB を圧迫してサービス停止(DoS)。全文字列フィールドに
maxを付けるのが鉄則。
5. SQL: パラメータ化クエリ(プレースホルダ)
SQL Injection レッスンの復習だが、入力検証の文脈でも重要。
// 正解: プレースホルダ
db.QueryRow("SELECT * FROM users WHERE email = $1 AND status = $2", email, "active")
// NG: 文字列連結
db.QueryRow("SELECT * FROM users WHERE email = '" + email + "'")プレースホルダの何が違うか
プレースホルダ方式は「SQL の構文部分」と「データ部分」をDBドライバ/DB側で分離して扱う。データに
' OR 1=1 --が入っていても、それは データとしてのみ 解釈され、SQL 構文として実行されない。「エスケープを頑張る」ではなく「分離する」 が本質。
6. HTML: コンテキスト別エスケープ
ユーザー入力を HTML に埋め込むときは、埋め込む場所によってエスケープ規則が違う:
| 文脈 | エスケープ対象 |
|---|---|
| HTML テキスト | < > & |
| HTML 属性値 | 上記 + " ' |
| JavaScript 文字列 | バックスラッシュエスケープ |
| URL パラメータ | URL エンコード |
| CSS | CSS エスケープ |
Go の html/template が「自動コンテキスト判定」してくれる
import "html/template" t := template.Must(template.New("p").Parse(` <a href="{{.URL}}">{{.Name}}</a> `)) t.Execute(w, struct{ URL, Name string }{userInput1, userInput2})
html/templateは変数を埋める場所(属性内 / テキスト / JS / URL)をコードから推測してエスケープを切り替える。Go 標準ライブラリの傑作。一方
text/templateはエスケープしない。HTML を出す箇所では絶対html/template。
7. JSON エスケープ
// 標準ライブラリの encoding/json は HTML エスケープを自動で行う
enc := json.NewEncoder(w)
enc.Encode(data) // <, >, & が <, >, & にエスケープされるなぜ JSON でも HTML エスケープが必要か
JSON を HTML に埋め込む場合(
<script>var data = {{.Data}};</script>)、<がそのままだと</script>の閉じタグを途中で生んで XSS の温床になる。Go の
encoding/jsonは API レスポンスとして使う場合でも<>&をエスケープする。これを無効化したい場合はenc.SetEscapeHTML(false)(基本やらない)。
セッション②: ファイル・Unicode・特殊文脈(30分)
8. ファイルアップロード
ファイルアップロード機能は脆弱性の宝庫。以下を全部やる:
import (
"io"
"net/http"
"path/filepath"
"strings"
)
const maxUploadSize = 5 * 1024 * 1024 // 5MB
var allowedExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// 1. サイズ制限(DoS 対策)
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "file too large", 413)
return
}
file, header, err := r.FormFile("upload")
if err != nil {
http.Error(w, "bad request", 400)
return
}
defer file.Close()
// 2. 拡張子チェック(allow-list)
ext := strings.ToLower(filepath.Ext(header.Filename))
if !allowedExts[ext] {
http.Error(w, "extension not allowed", 400)
return
}
// 3. MIME タイプを「実際の中身から」判定(http.DetectContentType)
buf := make([]byte, 512)
_, _ = file.Read(buf)
contentType := http.DetectContentType(buf)
if !strings.HasPrefix(contentType, "image/") {
http.Error(w, "not an image", 400)
return
}
_, _ = file.Seek(0, io.SeekStart) // 巻き戻す
// 4. 保存先パスを安全に生成(ユーザー入力ファイル名は使わない)
safeName := generateUUID() + ext
dst := filepath.Join("/var/uploads", safeName)
// 5. /var/uploads から脱出していないか念のため確認
absDst, err := filepath.Abs(dst)
if err != nil || !strings.HasPrefix(absDst, "/var/uploads/") {
http.Error(w, "invalid path", 400)
return
}
// 6. 保存
// ... (省略)
}このコードで防いでいる攻撃
- DoS (巨大ファイル):
MaxBytesReader- 拡張子偽装: allow-list で
.jpg等のみ- MIME 偽装 (Polyglot):
Content-Typeヘッダではなく実際の中身で判定- Path Traversal (
../../../etc/passwd等): ユーザー入力ファイル名を使わず UUID- シンボリックリンク経由の脱出: 保存パスを絶対パスにして基底ディレクトリ内か確認
Path Traversal 攻撃例
// NG: ユーザー入力をパスに直結 fname := r.FormValue("name") os.Open(filepath.Join("/var/uploads", fname))攻撃:
name = "../../../etc/passwd"→/etc/passwdを読まれる対策:
- UUID 等のサーバー生成名を使う(最強)
- やむなくユーザー名を使う場合は
filepath.Baseで末尾のみ取り、..を含む値を allow-list で弾く- 保存・読み込み後に
filepath.Absで「基底ディレクトリの中か」確認
Content-Type ヘッダだけで判定
// NG if r.Header.Get("Content-Type") != "image/jpeg" { ... }攻撃者は
curl -H "Content-Type: image/jpeg"で任意ファイルを偽装できる。ヘッダは入力 = 信用しない。
9. ImageMagick (ImageTragick) の教訓
CVE-2016-3714(通称 ImageTragick)
ImageMagick は「ファイルの中身を見て自動的に適切なフォーマットで開く」機能を持っていた。
攻撃シナリオ:
- 攻撃者は拡張子
.jpgだが中身は MVG (Magick Vector Graphics) のファイルを用意- アップロードサーバーは拡張子チェックは通る、MIME も
image/jpeg偽装可能- ImageMagick が中身を見て「これは MVG だ」と判断
- MVG には外部URL fetch やシェル実行を含む命令が書ける
- アップロードしただけでサーバー上でコマンド実行
教訓:
- 拡張子・MIME・中身、3つすべてが一致しない場合は拒否
- 画像処理ライブラリは独自フォーマットを無効化(policy.xml で MVG / EPHEMERAL / URL 等を disable)
- 信頼境界を意識:外から来たデータを処理するライブラリは脆弱性の温床
10. Unicode 正規化と null byte
Unicode 正規化で認証バイパス
ユーザー名
adminを unique 制約で守っていたつもりが、攻撃者がadmin(a が異字体)で登録できてしまうケース。見た目同じ別人。対策:
import "golang.org/x/text/unicode/norm" normalized := norm.NFKC.String(input)NFKC(互換正規化)で「見た目が同じ文字を統一」してから比較・保存。
トルコ語の “I” 問題: トルコ語では
iの大文字はİ、Iの小文字はı。strings.ToLower("ADMIN")がロケールによって異なる結果に。Go のstrings.ToLowerは ASCII 範囲のみだが、他言語では落とし穴。
null byte 注入
攻撃: filename = "image.jpg\x00.php"古い C 系ライブラリは null で文字列終端と解釈 →
image.jpgとして処理しつつ、ファイルシステムには.phpとして保存 → サーバーで実行対策: 入力に
\x00(NUL) が含まれていたら拒否(Go の標準ライブラリでは多くが安全だが、外部ライブラリ呼び出しでは注意)。
11. XML: XXE と Billion Laughs
XXE (XML External Entity) 攻撃
<?xml version="1.0"?> <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> <data>&xxe;</data>外部エンティティ展開を許す XML パーサーは、
file://やhttp://を介してファイル読み出し・SSRF を許す。対策: XML パーサーで
DOCTYPE/ 外部エンティティを 無効化。Go のencoding/xmlはデフォルトで外部エンティティを展開しないので比較的安全だが、他言語(Java / Python)は要明示無効化。
Billion Laughs(XML 爆弾)
<!ENTITY lol "lol"> <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"> <!ENTITY lol3 "&lol2;&lol2;&lol2;..."> ...エンティティの再帰展開で、わずか数 KB の XML が GB 単位のメモリ消費を引き起こす DoS。
対策: パーサーのエンティティ展開回数上限、入力サイズ上限。
12. アンチパターン総まとめ
フロント検証だけ
既に書いた通り、API を curl で直叩きで終わり。バックでも必ず検証。
deny-list で SQL Injection を防ごうとする
// NG: シングルクォートを除去すれば安全と思っている sanitized := strings.Replace(input, "'", "''", -1) sql := "SELECT * FROM users WHERE name = '" + sanitized + "'"なぜNGか:
- 文字エンコーディングの罠でエスケープが破られる事例多数
- 多バイト文字での SQL Injection の事例
- そもそもエスケープを自前で書く時点で負け。プレースホルダ一択
「ホワイトリスト」と言いながら抜け穴
// NG: 「英数字とハイフン」と言いつつ書き方が緩い matched, _ := regexp.MatchString("[A-Za-z0-9-]+", input) // ↑ ^ と $ が無い! 文字列内に1つでもマッチすればOKという意味になる正しくは
^[A-Za-z0-9-]+$で「全体マッチ」。実務で頻発するミス。
生入力を
os/execに渡す// NG: コマンドインジェクション exec.Command("sh", "-c", "ls "+userInput)
userInput = "; rm -rf /"で任意コマンド実行。
exec.Commandは引数を配列で渡せばシェル経由しない:exec.Command("ls", userInput) // userInput はそのまま単一引数として渡るただし第1引数(コマンド名)は絶対にユーザー入力にしない。
13. 設計指針: 「信頼境界」を意識する
信頼境界 (Trust Boundary) の図
[ユーザー] ↓ 入力 [フロント検証] ← UX 用 ↓ ┃━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ←← 信頼境界 ┃ [API バリデーション] ← セキュリティの主防衛線 ┃ ↓ ┃ [ビジネスロジック] ┃ ↓ ┃ [プレースホルダ・テンプレート・エスケープ] ← コンテキスト別防衛 ┃ ↓ ┃ [DB / 外部API]信頼境界を超えた瞬間に検証する。一度検証したものは内部で安全に扱える前提にする。コードを書く時に「ここは境界の内側か外側か」を意識する癖を。
練習課題
mkdir -p ~/learn/security/day-validation
cd ~/learn/security/day-validationSignupRequest構造体に validator タグを付け、不正な入力(短いパスワード、ロールsuperadmin、巨大文字列)が全部弾かれることをテスト- ファイルアップロード API を実装し、以下を全部弾けることを確認:
- 6MB のファイル(サイズ超過)
- 拡張子
.exe - 拡張子
.jpgだが中身が実行ファイル - ファイル名に
../../../etc/passwd - ファイル名に null byte
html/templateでユーザー入力を含む HTML を生成し、<script>alert(1)</script>がエスケープされることを確認- Unicode 正規化を入れて、
admin(フルワイド)がadminと同一視されることを確認 - 文字列連結 SQL と placeholder SQL の両方を書き、
' OR 1=1 --でログイン突破を試す
締め: git で証跡を残す
cd ~/learn/security/day-validation
git add .
git commit -m "feat(security): allow-list 検証とファイルアップロード安全実装"チェックリスト
- allow-list と deny-list の違い、前者が優位な理由を語れる
- フロント検証だけでは不十分な理由を curl で実証できる
- go-playground/validator の主要タグ(required, max, oneof など)を即書ける
- プレースホルダの「データと構文の分離」の本質を説明できる
-
html/templateのコンテキスト自動判定を理解 - ファイルアップロードで必要な6つのチェック(サイズ/拡張子/MIME/中身/パス/絶対パス確認)が言える
- Path Traversal の攻撃と対策を実装できる
- Unicode 正規化と null byte 注入の概要を語れる
- XXE / Billion Laughs の対策を一文で言える
詰まった時のチートシート
| やりたいこと | コード |
|---|---|
| 必須・形式・範囲 | validate:"required,email,max=254" |
| 取りうる値の列挙 | validate:"oneof=user admin" |
| 正規表現の全体マッチ | ^[A-Za-z0-9-]+$(^ と $ を忘れない) |
| ファイルサイズ制限 | http.MaxBytesReader(w, r.Body, 5<<20) |
| 実 MIME 検出 | http.DetectContentType(buf) |
| Path Traversal 防止 | UUID 名で保存 + filepath.Abs で基底確認 |
| Unicode 正規化 | norm.NFKC.String(s) |
| HTML エスケープ | html/template 使用(text/template 不可) |
| シェル経由を避ける | exec.Command(name, args...) で配列渡し |
「実務OK」基準
- 新しい API を書くとき、最初に struct タグでバリデーションを書く癖がある
- 「ユーザー入力を直接ファイルパス / SQL / シェル / HTML に使っている箇所」を即座に指摘できる
- ファイルアップロード機能のコードレビューで6項目チェックリストが頭にある
- deny-list 発想のコードを見たら allow-list 化を提案できる
html/templateとtext/templateの使い分けで迷わない
さらに深掘りするなら
- OWASP Input Validation Cheat Sheet
- OWASP File Upload Cheat Sheet
- 「Web Application Hacker’s Handbook」 - 入力検証の章は古いが本質的
- ImageTragick (CVE-2016-3714) の解説記事
- Apache Struts CVE-2017-5638 と Equifax 事件の詳細
- Go 標準ライブラリ
html/templateのソース - コンテキスト自動判定の実装
メタ認知: 自分の理解度を診断する
30秒セルフテスト
- allow-list と deny-list、どちらを優先すべきか・理由
html/templateとtext/templateの違い(再確認)- ファイルアップロード時、拡張子チェック だけで不十分な理由
- Unicode 正規化を どのタイミングで やるべきか
- JSON Schema /
validatorタグの利点詰まったら → セッション①の戦略表と、ファイルアップロード6項目チェックリストを再読。
次のレッスン
2-6 認可設計 で、認証された後の「何ができるか」(認可)の設計を扱う。
間隔反復ポイント
入力検証の allow-list 思考 はあらゆる API 設計で発動すべき習慣。コードレビュー時に「これは allow-list か deny-list か?」を問う癖をつけると劣化しない。