0
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?

30分しか寝れないショートスリーパーエンジニアでもエラーレスポンスから機密を漏らさない方法

0
Posted at

タイトルはふざけましたが中身は真面目です。

「エラー文言は BE と FE のどちらが持つべきか」を考えていて、
「BE が日本語のエラー文を返して FE がそのまま表示するのは、セキュリティ的によくない」
という意見を聞きました。

言いたいことは分かる。でも理由を突き詰めると、少しズレている気がしたので整理します。

結論から言うと、この問いは 「誰が持つか」ではなく「出る値をどこで固定するか」 の問題でした。
そして固定するには層を積む必要がありました。


まず、この 1 行が何を出すか

FastAPI でよく見る形です。

except Exception as e:
    raise HTTPException(status_code=400, detail=str(e))

「例外の内容を返してあげたほうが親切だよね」という気持ちで書かれるやつです。

この str(e) が実際に何を返すか、手元で確かめてみます。

import sqlalchemy.exc

err = sqlalchemy.exc.ProgrammingError(
    "SELECT teams.id, teams.name FROM teams "
    "WHERE teams.name ILIKE %(name_1)s LIMIT %(param_1)s OFFSET %(param_2)s",
    {"name_1": "%ユーザーが入力した検索語%", "param_1": 100, "param_2": -100},
    Exception("(psycopg2.errors.InvalidRowCountInResultOffsetClause) OFFSET must not be negative"),
)

print(str(err))

出力です。

(builtins.Exception) (psycopg2.errors.InvalidRowCountInResultOffsetClause) OFFSET must not be negative
[SQL: SELECT teams.id, teams.name FROM teams WHERE teams.name ILIKE %(name_1)s LIMIT %(param_1)s OFFSET %(param_2)s]
[parameters: {'name_1': '%ユーザーが入力した検索語%', 'param_1': 100, 'param_2': -100}]
(Background on this error at: https://sqlalche.me/e/20/f405)

これがそのまま HTTP レスポンスに載ります。

  • SQL 全文(テーブル名・カラム名つき)
  • bind パラメータ
  • 利用者が入力した文字列

最後のが特にまずくて、検索 API なら 他人の検索語が返る形になりえます。

StatementError.__str__ は SQL 文と bind 値を連結する実装で、
hide_parameters を指定しなければ既定は False です。つまり 意図せずこうなります。

ここで大事なこと

この漏れ方、英語でも起きます。 エラーコード方式にしても
{"code": "UNKNOWN", "message": str(e)} と書けば同じです。

つまり論点は

  • ❌ 日本語か英語か
  • ❌ 文言かエラーコードか
  • ✅ その文字列がどう作られたか

「BE が文言を持つと危ない」ではなく 「BE がハンドリングを漏らすと危ない」 でした。


混ざっている 4 つを分ける

「エラーメッセージ」と一言で呼んでいるものには、性質の違う 4 つが入っています。

例 読み手で変わる?
意味 「最後の管理者は削除できない」 変わらない
文言 最後の管理者は削除できません 変わらない(後述)
助言 先に別の管理者を追加してください 変わる
実行時の値 上限は5人 の「5」 変わらない(ドメインの値)

分けると、置き場所がほぼ自動的に決まります。

意味は 1 つに定まる

DELETE /teams/{id}/members/{userId} が「最後の管理者だから外せない」と判断したとき、
その意味は 1 つです。誰が見ても同じ。 ドメインロジックが決めているので当然です。

これは BE のものです。 ここに異論は無いと思います。

文言も 1 つに定まる ── ただし条件がある

「読み手によって正しい文言が変わるのでは?」と思うかもしれません。
実はそう見えるだけで、変わっているのは別の部分です。

「最後の管理者は削除できません。先に別の管理者を追加してください。」
 └─ 意味 ─────────────────┘ └─ 助言 ──────────────┘

たとえばこの API を「管理者」と「サブ管理者」の 2 ロールが叩けるとして、
サブ管理者に管理者を追加する権限が無かったら、後半は
「できないことをやれ」と言っていることになります。

でも前半は誰に対しても正しい。

なぜ助言だけが変わるのか。 助言は「あなたは次にこれができる」という主張で、
相手の権限・画面・状況に依存するからです。意味は事実の記述なので依存しません。

エラーは「何が起きたか」だけを言う。「どうすればいいか」は画面が言う。

これを守れば、文言は 1 つに定まります。 つまり BE が持てる。


リテラルにすれば安全か? いいえ

「じゃあ全部ベタ書きの固定文言にすれば安全だね」と思いますが、まだ穴があります。

{
  "error_code": "RESOURCE_NOT_FOUND",
  "message": "User with id 123 not found",
  "details": { "resource_type": "User", "resource_id": "123" }
}

str(e) は使っていません。全部こちらで組み立てた文字列です。
それでも、ID を総当たりすれば「どの ID のユーザーが存在するか」が全部分かります。

いわゆる存在オラクルです。ログイン画面で
「そのメールアドレスは登録されていません」と出すのが避けられるのと同じ理屈ですね。

なので、招待 API ならこうします。

# ★ どれが無いかを区別しない ★
# 「チームが無い」「ユーザーが無い」を分けると、
# その ID が存在するかを外から確かめられる(存在オラクル)。
(InviteTargetNotFoundError, 404, "対象が見つかりません"),

この曖昧さは仕様です。 親切に書き分けてはいけない。
そして これは BE にしか判断できません。 FE に「もっと分かりやすく」と任せると壊れます。


でも「上限は5人です」って出したい

固定文言だけだと、こう書けません。

「チームの人数が上限に達しています」   ← これは書ける
「チームの人数上限は5人です」          ← 5 が実行時の値

ここで一度、**「5 は誰のものか」**を考えます。

上限 5 はドメインのルールです。BE が持っています。
なら BE が文まで作ればいいはずです。FE に 5 を渡して組み立てさせる必然性がない。

よくある案:code + params

{ "code": "MEMBER_LIMIT_EXCEEDED", "params": { "limit": 5 } }

FE が code から文言を選び、params を埋める。責務が綺麗に見えます。

でも、よく見てください。

BE が組み立て  →  {"detail": "チームの人数上限は5人です"}
params で渡す  →  {"params": {"limit": 5}}

どちらも 5 が応答に載っています。 params にしても、実行時の値が出ることは変わりません。
場所が変わるだけです。

つまり params は安全装置ではありません。


本当に守りたいものは何か

考え直すと、守りたいのはこれです。

応答に出る値の「種類」を、事前に列挙して固定したい

f"…{user.email}…" が危ないのは、そこに何でも書けるからです。
{limit} も {email} も同じ構文で書けて、書いた瞬間に応答に出ます。

なら、テンプレートと、埋めてよい値を分けて宣言すればいい。

# error_mapper.py
from typing import NewType
from fastapi import HTTPException, status

Limit = NewType("Limit", int)


class DomainError(Exception): ...


class MemberLimitExceededError(DomainError):
    def __init__(self, limit: Limit) -> None:   # ← 型で縛る(後述)
        self.limit = limit


class InviteTargetNotFoundError(DomainError): ...


# 例外クラス -> (status, 文言テンプレート, 埋めてよい属性名)
#
# ★ テンプレートはリテラル ★ AST で検査できる
# ★ 埋める値は「属性名」で宣言する ★ 何が応答に出るか一覧で分かる
_RULES: tuple[tuple[type[Exception], int, str, tuple[str, ...]], ...] = (
    (MemberLimitExceededError, status.HTTP_409_CONFLICT,
     "チームの人数上限は{limit}人です", ("limit",)),

    (InviteTargetNotFoundError, status.HTTP_404_NOT_FOUND,
     "対象が見つかりません", ()),
)


def to_http_exception(error: Exception) -> HTTPException:
    for error_type, status_code, template, allowed in _RULES:
        if isinstance(error, error_type):
            values = {name: getattr(error, name) for name in allowed}
            return HTTPException(status_code=status_code,
                                 detail=template.format(**values))
    return HTTPException(status_code=500, detail="Internal server error")

動かすとこうなります。

MemberLimitExceededError         409  チームの人数上限は5人です
InviteTargetNotFoundError        404  対象が見つかりません
RuntimeError                     500  Internal server error

f-string との決定的な差は、「何を出すか」が宣言になっていることです。

f-string テンプレート + 宣言
何を埋められる? 何でも ("limit",) に書いたものだけ
一覧できる? できない できる
機械で検査できる? しにくい できる(次章)
BE が文言を作る? ✅ ✅

検証するコード

テンプレートがリテラルで、埋める値が宣言どおりかを AST で確かめます。

# check_tpl.py
import ast
import re
import sys

TARGET = "_RULES"


def _is_target(node: ast.stmt) -> bool:
    """`_RULES = (...)` と `_RULES: T = (...)` の両方を拾う。"""
    if isinstance(node, ast.Assign):
        return any(getattr(t, "id", "") == TARGET for t in node.targets)
    if isinstance(node, ast.AnnAssign):
        return getattr(node.target, "id", "") == TARGET
    return False


def check(path: str) -> list[str]:
    tree = ast.parse(open(path, encoding="utf-8").read())
    problems: list[str] = []
    checked = 0

    for node in ast.walk(tree):
        if not _is_target(node) or node.value is None:
            continue

        for entry in node.value.elts:
            checked += 1
            tpl, allowed = entry.elts[2], entry.elts[3]
            ln = tpl.lineno

            # ① テンプレートはリテラルか
            if not (isinstance(tpl, ast.Constant) and isinstance(tpl.value, str)):
                problems.append(f"{path}:{ln} テンプレートがリテラルでない")
                continue

            # ② 宣言はリテラルのタプルか
            if not (isinstance(allowed, ast.Tuple)
                    and all(isinstance(e, ast.Constant) for e in allowed.elts)):
                problems.append(f"{path}:{ln} 埋める値の宣言がリテラルでない")
                continue

            # ③ テンプレートの穴と宣言が一致するか
            holes = set(re.findall(r"\{(\w+)\}", tpl.value))
            declared = {e.value for e in allowed.elts}
            if holes != declared:
                problems.append(
                    f"{path}:{ln} 穴と宣言が不一致 穴={sorted(holes)} 宣言={sorted(declared)}")

    # ★ 0 件を検査したのに緑、を防ぐ ★
    if checked == 0:
        raise SystemExit(f"❌ {path} に {TARGET} が見つかりません。検査が空振りしています")

    print(f"({checked} 件を検査しました)")
    return problems


if __name__ == "__main__":
    found = check(sys.argv[1])
    if found:
        print("❌ 問題あり")
        for p in found:
            print(f"   {p}")
        sys.exit(1)
    print("✅ テンプレートも宣言もリテラル。穴と宣言も一致")

検査を書いたら、必ず壊して確かめる

検査は「書いた」だけでは効きません。わざと壊して落ちるかを見る必要があります。

=== ① 正常 ===
(2 件を検査しました)
✅ テンプレートも宣言もリテラル。穴と宣言も一致
   終了 0

=== ② テンプレートを f-string にした ===
❌ テンプレートがリテラルでない
   終了 1

=== ③ 宣言していない穴 {email} を足した ===
❌ 穴と宣言が不一致 穴=['email', 'limit'] 宣言=['limit']
   終了 1

=== ④ 宣言を変数にした ===
❌ 埋める値の宣言がリテラルでない
   終了 1

③ が効くのが肝です。 うっかりメールアドレスを出そうとした瞬間に CI が落ちます。

AST の検査には落とし穴があります

上のコードで _is_target() をわざわざ関数に切り出しているのには理由があります。

_RULES: tuple[...] = (...)

これは ast.Assign ではなく ast.AnnAssign(型注釈つき代入) です。
ast.Assign だけを見ていると、型注釈を付けた瞬間に検査が何も見なくなります。
しかも エラーは出ません。何も検査せず緑になります。

checked == 0 で落としているのも同じ理由です。
「検査が空振りしていないこと」を検査する、ここまでやって初めて安心できます。


それでも残る穴を、型で塞ぐ

「テンプレート + 宣言」でも、まだ抜けます。

MemberLimitExceededError("user@example.com")
detail = チームの人数上限はuser@example.com人です

AST 検査は通ります。CI も緑。それでもメールアドレスが出ました。

("limit",) は「limit という名前の属性を埋める」としか言っておらず、
そこに何が入るかは縛っていません。

そこで型注釈です。

def __init__(self, limit: int) -> None: ...

mypy を通すとこうなります。

error: Argument 1 to "MemberLimitExceededError" has incompatible type "str"; expected "int"

漏れて困るものの多くは文字列(メールアドレス・氏名・トークン・str(e))なので、
これだけでかなり塞がります。

型が合う機密は残る

MemberLimitExceededError(user.id)   # int なので通る

ここは NewType で潰せます。

Limit = NewType("Limit", int)
UserId = NewType("UserId", int)

def __init__(self, limit: Limit) -> None: ...
error: Argument 1 ... has incompatible type "int";   expected "Limit"   ← 素の int も拒否
error: Argument 1 ... has incompatible type "UserId"; expected "Limit"   ← 別の意味の int も拒否

実行時のコストはありません。NewType は静的にしか存在せず、
Limit(5) は int の 5 そのものです(200 万回で 0.06 秒の差=1 回 33 ナノ秒)。


安全性は「層」でしか守れない

ここまでを一般化すると、こうなります。

層 何を縛るか 手段
① 構造 何を出す「枠」があるか テンプレートをリテラルに固定
② 名前 その枠に入れてよい値はどれか 埋める値を宣言する
③ 型 その値がどんな種類か 型注釈 + 静的検査
④ 意味 その型が何を表すか NewType

どれか 1 つでは守れません。

① だけ   →  枠は固定。でも何でも埋められる
①②      →  埋める名前は決まった。でも中身は自由
①②③    →  型が合えば通る(int の機密が残る)
①②③④  →  意味の合う値しか渡せない

そして、層より先に塞ぐべきものがある

層をいくら積んでも、その仕組みを通らない経路があれば無意味です。

raise HTTPException(404, detail=f"team {team_id} not found")   # 表を通らない

抽象化するとこうなります。

仕組みを作ったら、「その仕組みを通らずに同じことができる経路」を必ず塞ぐ

仕組み 迂回路
エラー変換表 ハンドラで直接 raise
ORM のエスケープ 生 SQL
認可ミドルウェア 別の入口
lint ルール # noqa

迂回路を塞ぐほうが、層を 1 つ増やすより効くことが多いです。
層は「正しい道を安全にする」だけで、道は他にもあるからです。


AI 時代に、この話はどう変わるか

「NewType まで敷くのはコストが高い」というのが従来の評価でした。
でも、そのコストの内訳を見ると、AI が書くようになった今、大半が消えます。

コスト いま
型を書く 消える(生成させればいい)
呼び出し側に通す 消える(機械的な作業)
型を変えたときの追従 ほぼ消える
境界での変換を足す 消える
人が読むときの認知負荷 残る
チームが規律を守る 残る

つまり ④ の層が「贅沢品」から「既定にしてよいもの」へ動いたわけです。

むしろ型の「価値」が上がっている

従来、型はこう見られていました。

型 = 人間のためのドキュメント + コンパイラへのヒント

コードを機械が書くようになると、こうなります。

型 = コード生成器に対する、機械検証可能な仕様

生成器は自信を持って間違えます。 書く速度が上がったぶん、間違いも速く量産されます。
だから 「機械が止められる形」で仕様を書く価値が上がる。

型注釈も、NewType も、AST 検査も、全部その「機械が止められる形」です。

ただし、新しい抜け道が増える

型を書くのが速くなったということは、型を黙らせるのも速いということです。

raise MemberLimitExceededError(uid)  # type: ignore[arg-type]

「怒られたので # type: ignore を付けました」は、生成器がやりがちな解決です。
人間なら「なぜ怒っているのか」を一度考えますが、
生成器はエラーを消すこと自体を目的にしがちです。

なので層がもう 1 段要ります。

⑤ 抜け道を数える
   type: ignore / cast / noqa の総数をラチェット(増えたら落とす)

抜け道をゼロにはできないので、増えないことを機械で守る。


結局どこに書くか

ここまでの決まりごとを、どこに置くかという話です。
AI コーディングの文脈だと、選択肢が 3 つあります。

置き場所 効き方 限界
Skills / Rules(.claude/rules/ など) 意図が伝わる 読まれる保証がない。強制力ゼロ
Hooks(編集時に検査) 書く瞬間に止まる AI の編集経路だけ。 人が直接書いたコードは素通り
CI / テスト すべての経路を止める 唯一、迂回できない

この 3 つは代替ではなく、積み重ねです。

  • Skills だけ → 守られないことがある
  • Hooks だけ → エディタで直接書かれたら素通り
  • CI だけ → 気づくのが遅い(書いた瞬間に教えてほしい)

そして、3 つとも壊れます

ここが最後の要点です。

  • ルールは読まれないことがある
  • hook は設定ミスで静かに死ぬことがある(設定してあるのに一致していない、など)
  • 検査は「何も検査せず緑」になることがある(この記事の ast.AnnAssign がそれ)

だから、もう 1 段。

★ 「効いていること」自体を検査する ★

  検査を壊してみて、落ちるか(陰性対照)
  検査対象が 0 件になっていないか
  hook が実際に発火するか

仕組みは「作った」だけでは効きません。「効いていること」を別に確かめる必要があります。


まとめ

「エラーメッセージ」を 4 つに分けると、置き場所が決まります。

読み手で変わる? 誰が持つ
意味 変わらない BE
文言 変わらない(助言を混ぜなければ) BE
助言 変わる FE(BE の文言に混ぜない)
実行時の値 変わらない(ドメインの値) BE(テンプレートに埋める)

そのうえで

  • 「BE が日本語を返すと危ない」は、正確には 「BE が例外の中身を返すと危ない」
  • エラーコードにしても隠れません。 レスポンスは見えるので
  • params も安全装置ではありません。 実行時の値が応答に載るのは同じ
  • 効くのは 「応答に出る値の種類を、事前に列挙して固定する」
  • リテラルにしても存在オラクルは残る。「どれが無いか区別しない」は BE の判断
  • 固定は ① 構造 → ② 名前 → ③ 型 → ④ 意味 の順に積む
  • 層より先に、迂回路を塞ぐ
  • AI 時代は ④ まで安い。 ただし ⑤ 抜け道を数えるが新しく要る
  • 置き場所は Skills → Hooks → CI の積み重ね。3 つとも壊れる前提で
  • code + params を FE へ渡すのは i18n のときだけでいい

30 分しか寝ていなくても大丈夫なのは、「CI が起きているから」ではなく
「CI が起きていることを確かめてあるから」
です。

0
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
0
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?