/**
* 接続元IPの取り出しと許可判定(純粋関数。DBもファイルも `process.env` も触らない)。
*
* ★段数・許可リスト・直接アクセス可否はすべて引数で受け取る★
* `lib/config/allowed-ips.ts` から呼び出し側が渡す。ここに値を書かないこと
* (CLAUDE.md §7 のハードコード禁止。かつ、引数化しないと期待値表をテストできない)。
*/
/**
* `X-Forwarded-For` から接続元IPを取り出す。
*
* ★末尾から `hops` 番目の要素を返す★(`hops = 1` なら末尾)。
* nginx は `$proxy_add_x_forwarded_for` 相当で「自分が見た接続元」を★末尾に追記★する
* (D-077 項5)ため、信頼できるのは末尾側だけ。
* ★先頭はクライアントが自由に詐称できるので絶対に使わないこと★ —
* 先頭を採ると、`X-Forwarded-For: <許可IP>` を自分で付けるだけでIP制限を素通りできる。
*
* 要素数が `hops` に満たない場合は `null`(取れない扱い)。
* ★足りないぶんを先頭で埋めないこと★(自称IPを掴んでしまう)。
*
* ★表記の正規化は行わない★(P1 の割り切り):
* IPv6 短縮形の展開・IPv4射影(`::ffff:`)の展開・ポートの除去はしない。
* `isAllowedIp()` が完全一致比較なので、`ALLOWED_IPS` には
* nginx が実際に付ける表記をそのまま書く必要がある。
* ここでやってよいのは `,` での分割と前後の空白除去まで。
*
* ★要素数で「直接アクセスかどうか」を判断しないこと★:
* クライアントが XFF を送らない普通のリクエストは、nginx が1個足して「要素1個」になる。
* つまり要素数だけでは nginx 経由の正常系と直接アクセスを区別できない。
*
* @param headerValue `Headers.get("x-forwarded-for")` の戻り値。
* ★同名ヘッダが複数行来ても `Headers.get()` が `", "` で連結した1文字列を返す★ので、
* 分割して末尾を取れば正しく動く(`getSetCookie` のような個別取得は不要)。
* @param hops 信頼済みプロキシの段数(`TRUSTED_PROXY_HOPS`)
*/
export function clientIp(
headerValue: string | null | undefined,
hops: number
): string | null {
if (!headerValue) return null;
if (hops < 1) return null;
const parts = headerValue.split(",");
if (parts.length < hops) return null;
const candidate = parts[parts.length - hops]?.trim();
if (!candidate) return null;
return candidate;
}
/**
* 接続元IPが許可されているかを判定する。
*
* ★判定の順序が重要★: 「ip が null かどうか」が先、「allowlist が空かどうか」は後。
* `ip === null` は `allowAllWhenEmpty` の影響を★一切受けない★。
* この2つを1つの設定値でまとめて扱うと、allowlist が空の初期状態のまま本番へ出た瞬間に
* nginx を経由しない直接アクセスが素通りする(「ヘッダが無ければ拒否」と矛盾する)。
*
* @param ip `clientIp()` の戻り値。`null` は「XFF から接続元が取れなかった」=直接アクセス
* @param allowedIps 許可リスト(★完全一致比較のみ。CIDR 非対応★)
* @param allowAllWhenEmpty 許可リストが空のとき制限をかけないか(`ip` が取れているときだけ効く)
* @param allowDirectAccess `ip === null` を通すか。
* ★呼び出し側が `ALLOW_DIRECT_ACCESS`(= `process.env.ALLOW_DIRECT_ACCESS === "true"`)を渡す。
* この関数自身は `process.env` を読まない★
*/
export function isAllowedIp(
ip: string | null,
allowedIps: readonly string[],
allowAllWhenEmpty: boolean,
allowDirectAccess: boolean
): boolean {
// 先に null を捌く。allowAllWhenEmpty はここに一切関与しない
if (ip === null) return allowDirectAccess;
if (allowedIps.length === 0) return allowAllWhenEmpty;
return allowedIps.includes(ip);
}