はじめに
PRレビューで「これ、内部エラーがクライアントに漏れてますよ」と指摘された。
直した。次のPRで「空文字でもトークンが生成されますよ」と指摘された。さらに次のPRで「エラーの型情報が消えて、正しいステータスコードが返せなくなってます」と指摘された。
3つとも全然違う問題に見えた。でも調べてみると、根っこは同じだった。
この記事では、PRレビューで繰り返し指摘されたセキュリティの問題を、「信頼境界」という考え方で整理する。
この記事でわかること:
- 信頼境界とは何か(一言で)
- 出口の問題:内部情報が外に漏れる(OWASP A05)
- 入口の問題:不正な値が内部に入る(OWASP A02)
- 内部境界の問題:エラー型が正しく変換されない
- 自分のコードをチェックする視点
筆者について:
GoでWeb APIを書き始めて半年ほど。DDDのレイヤードアーキテクチャで開発している。
コード例の前提:
- Go + Gin(HTTPフレームワーク) + GORM(ORM) + PostgreSQL
- レイヤー構成: presentation(ハンドラ) → application(ユースケース) → domain(モデル・インターフェース) → infra(DB実装)
- コード例は架空の予約システムをベースにしている
想定読者:
- Web APIを書いていて「セキュリティ」をあまり意識したことがない人
- PRレビューでセキュリティの指摘を受けたことがある人
- OWASP Top 10 を聞いたことはあるが、具体的にピンとこない人
信頼境界とは
「こちら側は信用できるが、あちら側は信用できない」という線引き。
日常の例え
空港のセキュリティゲートを想像してほしい。
チケットカウンター セキュリティゲート 搭乗ゲート
| | |
── 外側(誰でも入れる)──|── 内側(検査済みの人だけ)──
↑
信頼境界
- 入口(外→内): 持ち物検査。危険物を持ち込ませない
- 出口(内→外): 機密エリアの情報を外に持ち出させない
Web APIも同じ:
クライアント API サーバー データベース
(ブラウザ) (Go) (PostgreSQL)
| | |
── 外側 ──────────|── 内側 ─────────────────────
↑
信頼境界
- 入口: クライアントからのリクエスト。値は全て疑う
- 出口: クライアントへのレスポンス。内部情報を漏らさない
この「入口」と「出口」で守りを怠ると、セキュリティの穴になる。
出口の問題:内部情報が外に漏れる
OWASP A05: Security Misconfiguration
OWASP Top 10 の5番目「セキュリティの設定ミス」に該当する。
エラーメッセージ、スタックトレース、DB情報などをそのままクライアントに返してしまうパターン。
実際にレビューで指摘されたコード
Before(危険):
func (h *Handler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrBookingNotFound):
h.Error(c, http.StatusNotFound, "予約が見つかりません")
// ... 既知のエラーはメッセージを固定
default:
h.Error(c, http.StatusInternalServerError, err.Error()) // ← ここ!
}
}
default ケース。既知のエラーでない場合に err.Error() をそのまま返している。
何が起きるか
Goのエラーは fmt.Errorf("context: %w", err) でチェーンされる。最終的な err.Error() には全階層の情報が詰まっている:
クライアントに返るレスポンス:
{
"error": "failed to save booking: ERROR: duplicate key value
violates unique constraint \"bookings_pkey\"
(SQLSTATE 23505)"
}
攻撃者がこれを見ると:
-
bookings→ テーブル名がわかる -
bookings_pkey→ 主キーの制約名がわかる -
SQLSTATE 23505→ PostgreSQLを使っていることがわかる -
duplicate key→ 同じIDで挿入を試みればエラーが出ることがわかる
DBの種類、テーブル設計、制約名 — 攻撃に必要な情報が全て揃ってしまう。
最悪のケース
接続情報がエラーに含まれることもある:
"failed to connect: dial tcp postgres://admin:P@ssw0rd@db.internal:5432/myapp"
パスワード付きの接続文字列がクライアントに返る。こうなると、もはや攻撃の余地どころではない。
After(安全):
func (h *Handler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrBookingNotFound):
h.Error(c, http.StatusNotFound, "予約が見つかりません")
// ... 既知のエラーはメッセージを固定
default:
// ログには詳細を残す(運用チームがデバッグできる)
slog.ErrorContext(c.Request.Context(), "internal error", "error", err)
// クライアントには固定メッセージだけ返す
h.Error(c, http.StatusInternalServerError, "内部エラーが発生しました")
}
}
原則:外には汎用メッセージ、内にはログで詳細。
運用チームはサーバーログで原因を調査できる。攻撃者にはシステムの情報を一切渡さない。
入口の問題:不正な値が内部に入る
OWASP A02: Cryptographic Failures
OWASP Top 10 の2番目「暗号化の失敗」に該当する。
暗号やトークンの生成・検証が不十分で、本来守るべき情報やアクセス制御が機能しなくなるパターン。
実際にレビューで指摘されたコード
Before(危険):
func (c *videoClient) GenerateToken(roomName, identity string) (string, error) {
// roomName や identity が空でもそのままトークン生成に進む
at := auth.NewAccessToken(c.apiKey, c.apiSecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: roomName, // ← 空文字の可能性
}
at.AddGrant(grant).SetIdentity(identity) // ← 空文字の可能性
return at.ToJWT()
}
呼び出し側が正しい値を渡すことを前提にしている。
何が起きるか
リクエスト: POST /api/v1/rooms/xxx/token
→ roomName = ""(バグやリクエスト改ざんで空になった場合)
→ GenerateToken("", "user-123")
→ JWTトークンが生成される
→ トークンの room フィールドが空
空の room を持つトークンは、ビデオ通話サービスの実装によってはどの部屋にもアクセスできる可能性がある。つまり、他人の通話に参加できてしまうかもしれない。
After(安全):
func (c *videoClient) GenerateToken(roomName, identity string) (string, error) {
if roomName == "" || identity == "" {
return "", fmt.Errorf("generate video token: roomName and identity are required")
}
at := auth.NewAccessToken(c.apiKey, c.apiSecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: roomName,
}
at.AddGrant(grant).SetIdentity(identity)
return at.ToJWT()
}
原則:信頼境界の入口で、値を必ず検証する。
「呼び出し側が正しい値を渡すだろう」は信用しない。自分の関数の入口で、自分が守る。
空港の例えに戻ると
出口の問題:機密書類をそのまま持ち出している
入口の問題:持ち物検査なしで通している
どちらも境界でのチェックが抜けているのが原因。
内部境界の問題:エラー型が正しく変換されない
信頼境界はアプリの外側だけではない。レイヤー間にも境界がある。
infra/ → application/ → presentation/
(DBエラー) (業務エラー) (HTTPレスポンス)
↑ ↑
内部境界 内部境界
実際にレビューで指摘されたコード
Before(問題あり):
// infra層: PostgreSQLリポジトリ
func (r *bookingRepository) Save(ctx context.Context, booking *models.Booking) error {
result := r.db.WithContext(ctx).Save(entity)
if result.Error != nil {
return fmt.Errorf("save booking: %w", result.Error)
}
return nil
}
fmt.Errorf でラップしている。一見良さそうだが、問題がある。
何が起きるか
UNIQUE制約違反(同じ予約を重複作成)のケースで追うと:
GORMエラー: "duplicate key value violates unique constraint..."
↓ fmt.Errorf でラップ
ただの error: "save booking: duplicate key..." ← 型情報が消えた
↓ application 層
「UNIQUE制約違反だから409を返す」と判定したいが、
errors.Is(err, domain.ErrorTypeDuplicate) → false
→ 判定できない → 500 Internal Server Error を返す
クライアント目線:
期待: 409 Conflict(「もう存在してるよ」と教えてくれる)
実際: 500 Internal Server Error(「サーバーが壊れた」にしか見えない)
クライアントは 409 なら「既存の予約を表示する」というリカバリ処理を書ける。500 だとリトライするか諦めるしかない。
After(正しく変換):
// infra層: ErrorMapper でドメインエラーに変換
func (r *bookingRepository) Save(ctx context.Context, booking *models.Booking) error {
result := r.db.WithContext(ctx).Save(entity)
if result.Error != nil {
return r.errorMapper.TranslateToDomainError(result.Error, "Booking", "save")
}
return nil
}
ErrorMapper は GORMエラーを解析して、適切なドメインエラー型に変換する:
GORMエラー: "duplicate key..."
↓ ErrorMapper.TranslateToDomainError()
DomainError{Type: ErrorTypeDuplicate} ← 型情報が保存されている
↓ application層
errors.Is(err, domain.ErrorTypeDuplicate) → true
→ ApplicationError{Type: ErrTypeConflict}
↓ presentation層
HTTP 409 Conflict ✅
原則:各レイヤーの境界で、エラーを次の層が理解できる形に変換する。
生のDBエラーをそのまま上位に流すと、上位層は正しい判断ができない。
エラー変換の全体像
層 エラーの変換 目的
──────────────────────────────────────────────────────────
infra/ GORMエラー → DomainError DBの詳細を隠す
application/ DomainError → ApplicationError 業務的な意味を付与
presentation/ ApplicationError → HTTPステータス クライアントに正しく伝える
空港に例えると、各ゲートで荷物のタグを付け替えているようなもの。国際線の荷物タグが国内線のベルトに流れてきたら、誰もどこに届けていいかわからない。
自分のコードでチェックする視点
出口(レスポンス)
-
err.Error()をHTTPレスポンスに含めていないか? - 500系エラーで内部情報が漏れていないか?
- ログに認証情報(パスワード、トークン)を書いていないか?
入口(リクエスト)
- セキュリティに関わるパラメータ(トークン生成、認証、認可)に空文字チェックはあるか?
- 「呼び出し側が正しい値を渡すはず」と思い込んでいないか?
内部境界(レイヤー間)
- リポジトリ層でDBエラーをドメインエラーに変換しているか?
-
fmt.Errorfでラップしただけで、エラー型が消えていないか? - クライアントに返すHTTPステータスコードは正しいか?(本当に500か?)
まとめ
- 信頼境界 = 「信用できる側」と「信用できない側」の線引き
- 出口: 内部情報をクライアントに漏らさない(OWASP A05)
- 入口: 外部からの値は全て疑って検証する(OWASP A02)
- 内部境界: レイヤー間でエラー型を正しく変換し、上位層が正しい判断をできるようにする
- 3つの問題は別々に見えるが、**全て「境界でのチェック不足」**という同じ原因
PRレビューで指摘されるまで気づかなかったが、「境界で何を守るか」を意識するだけで、セキュリティの問題は大幅に減る。