/**
* 指示書I 段階1 の期待値表をそのまま固定したテスト。
* ★表を先に確定させてから書いている(CLAUDE.md §6-2)。行を削らないこと★
*/
import { describe, expect, it } from "vitest";
import { clientIp, isAllowedIp } from "@/lib/auth/client-ip";
import {
ALLOW_ALL_WHEN_EMPTY,
ALLOW_DIRECT_ACCESS,
TRUSTED_PROXY_HOPS,
} from "@/lib/config/allowed-ips";
describe("clientIp() — X-Forwarded-For から接続元を取る", () => {
const cases: Array<{
name: string;
header: string | null;
hops: number;
expected: string | null;
}> = [
{
name: "正常系のメインケース: nginx が末尾に追記する(D-077 項5)。先頭の自称は信じない",
header: "1.2.3.4, 203.0.113.10",
hops: 1,
expected: "203.0.113.10",
},
{
name: "単一要素(クライアントが XFF を送らず nginx が1個足した正常系でもあり得る)",
header: "203.0.113.10",
hops: 1,
expected: "203.0.113.10",
},
{
name: "多段でも末尾",
header: "1.2.3.4, 5.6.7.8, 203.0.113.10",
hops: 1,
expected: "203.0.113.10",
},
{
name: "★将来2段構成になった場合★: 末尾から2番目が本当のクライアント(10.0.0.5 は LB の内部IP)",
header: "1.2.3.4, 203.0.113.10, 10.0.0.5",
hops: 2,
expected: "203.0.113.10",
},
{
name: "★要素数が hops に満たない → null。先頭で埋めないこと★",
header: "1.2.3.4, 203.0.113.10",
hops: 3,
expected: null,
},
{
name: "前後の空白を落とす",
header: " 203.0.113.10 ",
hops: 1,
expected: "203.0.113.10",
},
{
name: "末尾が空 → 取れない扱い",
header: "1.2.3.4,",
hops: 1,
expected: null,
},
{ name: "空文字", header: "", hops: 1, expected: null },
{
name: "ヘッダ無し = nginx を通っていない(直接アクセス)",
header: null,
hops: 1,
expected: null,
},
{
name: "★同名ヘッダが複数行来ても Headers.get() が ', ' で連結するので分割で足りる★",
header: "1.2.3.4, 5.6.7.8, 203.0.113.10",
hops: 1,
expected: "203.0.113.10",
},
{
name: "★IPv6。正規化しない★",
header: "1.2.3.4, ::1",
hops: 1,
expected: "::1",
},
{
name: "★IPv4射影アドレス。203.0.113.10 に展開しない★",
header: "1.2.3.4, ::ffff:203.0.113.10",
hops: 1,
expected: "::ffff:203.0.113.10",
},
{
name: "★ポート付き。ポートを剥がさない★",
header: "1.2.3.4, [2001:db8::1]:54321",
hops: 1,
expected: "[2001:db8::1]:54321",
},
];
for (const { name, header, hops, expected } of cases) {
it(`${name}(header=${JSON.stringify(header)} / hops=${hops})`, () => {
expect(clientIp(header, hops)).toBe(expected);
});
}
it("undefined(ヘッダ未取得)も null 扱い", () => {
expect(clientIp(undefined, 1)).toBeNull();
});
it("★先頭の自称IPを絶対に返さないこと★(IP制限の素通りを防ぐ最重要の性質)", () => {
// 攻撃者が許可IPを先頭に詐称したケース
expect(clientIp("203.0.113.10, 198.51.100.9", 1)).toBe("198.51.100.9");
expect(clientIp("203.0.113.10, 198.51.100.9", 1)).not.toBe("203.0.113.10");
});
});
describe("isAllowedIp() — 3軸(ip が null か / allowlist が空か / 直接アクセスを許すか)", () => {
const cases: Array<{
row: number;
ip: string | null;
allowedIps: readonly string[];
allowAllWhenEmpty: boolean;
allowDirectAccess: boolean;
expected: boolean;
note: string;
}> = [
{
row: 1,
ip: "203.0.113.10",
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: false,
expected: true,
note: "一致",
},
{
row: 2,
ip: "203.0.113.11",
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: false,
expected: false,
note: "不一致",
},
{
row: 3,
ip: "203.0.113.10",
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: true,
allowDirectAccess: false,
expected: true,
note: "一致(allowAllWhenEmpty は空でないので無関係)",
},
{
row: 4,
ip: "203.0.113.11",
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: true,
allowDirectAccess: false,
expected: false,
note: "★allowlist が空でなければ allowAllWhenEmpty は効かない★",
},
{
row: 5,
ip: "203.0.113.10",
allowedIps: [],
allowAllWhenEmpty: true,
allowDirectAccess: false,
expected: true,
note: "制限なし(開発時の既定)",
},
{
row: 6,
ip: "203.0.113.10",
allowedIps: [],
allowAllWhenEmpty: false,
allowDirectAccess: false,
expected: false,
note: "★全拒否(本番想定の設定ミス検知)★",
},
{
row: 7,
ip: null,
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: false,
expected: false,
note: "直接アクセスは拒否",
},
{
row: 8,
ip: null,
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: true,
allowDirectAccess: false,
expected: false,
note: "同上",
},
{
row: 9,
ip: null,
allowedIps: [],
allowAllWhenEmpty: true,
allowDirectAccess: false,
expected: false,
note: "★既定状態(allowlist 空)でも直接アクセスは通さない★",
},
{
row: 10,
ip: null,
allowedIps: [],
allowAllWhenEmpty: false,
allowDirectAccess: false,
expected: false,
note: "同上",
},
{
row: 11,
ip: null,
allowedIps: [],
allowAllWhenEmpty: true,
allowDirectAccess: true,
expected: true,
note: "★ローカル開発。ALLOW_DIRECT_ACCESS=true を明示したときだけ通る★",
},
{
row: 12,
ip: null,
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: true,
expected: true,
note: "★直接アクセスを許した以上、照合すべきIPが無いので通す★",
},
];
for (const c of cases) {
it(`行${c.row}: ip=${JSON.stringify(c.ip)} / allowedIps=${JSON.stringify(
c.allowedIps
)} / allowAllWhenEmpty=${c.allowAllWhenEmpty} / allowDirectAccess=${
c.allowDirectAccess
} → ${c.expected}(${c.note})`, () => {
expect(
isAllowedIp(
c.ip,
c.allowedIps,
c.allowAllWhenEmpty,
c.allowDirectAccess
)
).toBe(c.expected);
});
}
it("★ip が null のとき allowAllWhenEmpty は結果を一切変えない★(行7〜10 の性質を明示)", () => {
for (const allowAllWhenEmpty of [true, false]) {
for (const allowedIps of [[], ["203.0.113.10"]]) {
expect(isAllowedIp(null, allowedIps, allowAllWhenEmpty, false)).toBe(
false
);
expect(isAllowedIp(null, allowedIps, allowAllWhenEmpty, true)).toBe(
true
);
}
}
});
it("★CIDR は解釈しない(完全一致のみ)★ — P1 の割り切り", () => {
expect(isAllowedIp("192.0.2.5", ["192.0.2.0/24"], false, false)).toBe(
false
);
// 書いた文字列そのものとしてなら一致する(= CIDR として動いてはいない証拠)
expect(isAllowedIp("192.0.2.0/24", ["192.0.2.0/24"], false, false)).toBe(
true
);
});
it("★表記ゆれは正規化しないので拒否になる★", () => {
expect(
isAllowedIp("::ffff:203.0.113.10", ["203.0.113.10"], false, false)
).toBe(false);
expect(
isAllowedIp("[2001:db8::1]:54321", ["2001:db8::1"], false, false)
).toBe(false);
});
});
describe("lib/config/allowed-ips.ts の既定値", () => {
it("TRUSTED_PROXY_HOPS は 1(LB と nginx は同一=1段。D-079 項1)", () => {
expect(TRUSTED_PROXY_HOPS).toBe(1);
});
it("ALLOW_ALL_WHEN_EMPTY の既定は true(開発を止めないため。★本番では false にする★)", () => {
expect(ALLOW_ALL_WHEN_EMPTY).toBe(true);
});
it("★ALLOW_DIRECT_ACCESS は環境変数由来で、テスト実行時(未設定)は false になること★", () => {
// `=== "true"` なので未設定は false。`!== "false"` にすると ここが true になって落ちる
expect(ALLOW_DIRECT_ACCESS).toBe(process.env.ALLOW_DIRECT_ACCESS === "true");
if (process.env.ALLOW_DIRECT_ACCESS === undefined) {
expect(ALLOW_DIRECT_ACCESS).toBe(false);
}
});
});