1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

信頼境界って何? — PRレビューで3回指摘されたセキュリティの話

1
Last updated at Posted at 2026-04-03

はじめに

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レビューで指摘されるまで気づかなかったが、「境界で何を守るか」を意識するだけで、セキュリティの問題は大幅に減る。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?