Initial Commit
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SubdomainTenantResolverError,
|
||||
TENANT_SUBDOMAIN_CODES,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createSubdomainTenantResolver } from "../resolver/resolver.js";
|
||||
import {
|
||||
cidrMatches,
|
||||
isTrustedProxy,
|
||||
parseCidr,
|
||||
parseIp,
|
||||
} from "./forwarded.js";
|
||||
|
||||
function reqWith(
|
||||
headers: Record<string, string>,
|
||||
extras: Record<string, unknown> = {}
|
||||
): Request {
|
||||
const req = new Request(`https://example.com/${Math.random()}`, {
|
||||
headers,
|
||||
});
|
||||
for (const [k, v] of Object.entries(extras)) {
|
||||
Object.defineProperty(req, k, { value: v, enumerable: true });
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("parseCidr", () => {
|
||||
it("parses IPv4 with prefix", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("10.0.0.0/8");
|
||||
expect(c).not.toBeNull();
|
||||
expect(c?.family).toBe(4);
|
||||
expect(c?.prefix).toBe(8);
|
||||
expect(Array.from(c?.bytes ?? [])).toEqual([10, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("defaults IPv4 prefix to 32 when omitted", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("10.4.7.2");
|
||||
expect(c?.prefix).toBe(32);
|
||||
});
|
||||
|
||||
it("parses IPv6 with prefix and :: shorthand", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("2001:db8::/32");
|
||||
expect(c?.family).toBe(6);
|
||||
expect(c?.prefix).toBe(32);
|
||||
});
|
||||
|
||||
it("defaults IPv6 prefix to 128 when omitted", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("2001:db8::1");
|
||||
expect(c?.prefix).toBe(128);
|
||||
});
|
||||
|
||||
it("returns null for malformed input", { timeout: 5000 }, () => {
|
||||
expect(parseCidr("")).toBeNull();
|
||||
expect(parseCidr(null as unknown as string)).toBeNull();
|
||||
expect(parseCidr("999.0.0.0/8")).toBeNull();
|
||||
expect(parseCidr("10.0.0.0/33")).toBeNull();
|
||||
expect(parseCidr("10.0.0.0/abc")).toBeNull();
|
||||
expect(parseCidr("2001:db8::/129")).toBeNull();
|
||||
expect(parseCidr("2001:db8::/zz")).toBeNull();
|
||||
expect(parseCidr("not-an-ip")).toBeNull();
|
||||
expect(parseCidr("1:2:3:4:5:6:7:8:9")).toBeNull();
|
||||
expect(parseCidr("g:db8::1")).toBeNull();
|
||||
expect(parseCidr("1.2.3")).toBeNull();
|
||||
expect(parseCidr("1.2.3.4.5")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects extra ipv6 groups without ::", { timeout: 5000 }, () => {
|
||||
expect(parseCidr("1:2:3:4:5:6:7")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses fully-expanded ipv6 (no ::)", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("1:2:3:4:5:6:7:8/128");
|
||||
expect(c?.family).toBe(6);
|
||||
expect(c?.prefix).toBe(128);
|
||||
});
|
||||
|
||||
it("rejects ipv6 hex group longer than 4 chars", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseCidr("12345::1")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects ipv6 empty hex group", { timeout: 5000 }, () => {
|
||||
// Triple colon makes the middle slot an empty group, not a ::.
|
||||
expect(parseCidr("1:::2")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses '::' as all-zero", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("::");
|
||||
expect(c?.family).toBe(6);
|
||||
expect(Array.from(c?.bytes ?? [])).toEqual(Array(16).fill(0));
|
||||
});
|
||||
|
||||
it("parses '::1'", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("::1");
|
||||
expect(c?.family).toBe(6);
|
||||
});
|
||||
|
||||
it("parses '1::'", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("1::");
|
||||
expect(c?.family).toBe(6);
|
||||
});
|
||||
|
||||
it("strips IPv6 zone-id before parsing", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("fe80::1%eth0/64");
|
||||
expect(c?.family).toBe(6);
|
||||
expect(c?.prefix).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIp", () => {
|
||||
it("parses IPv4 and IPv6", { timeout: 5000 }, () => {
|
||||
expect(parseIp("10.0.0.1")?.family).toBe(4);
|
||||
expect(parseIp("2001:db8::1")?.family).toBe(6);
|
||||
});
|
||||
|
||||
it("returns null for empty or malformed", { timeout: 5000 }, () => {
|
||||
expect(parseIp("")).toBeNull();
|
||||
expect(parseIp(null as unknown as string)).toBeNull();
|
||||
expect(parseIp("not-an-ip")).toBeNull();
|
||||
expect(parseIp("zzzz::1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cidrMatches", () => {
|
||||
it("matches v4 inside /8", { timeout: 5000 }, () => {
|
||||
const cidr = parseCidr("10.0.0.0/8");
|
||||
const ip = parseIp("10.4.7.2");
|
||||
expect(cidrMatches(ip!, cidr!)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects v4 outside /8", { timeout: 5000 }, () => {
|
||||
const cidr = parseCidr("10.0.0.0/8");
|
||||
const ip = parseIp("8.8.8.8");
|
||||
expect(cidrMatches(ip!, cidr!)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches /0 universally inside family", { timeout: 5000 }, () => {
|
||||
const cidr = parseCidr("0.0.0.0/0");
|
||||
expect(cidrMatches(parseIp("1.2.3.4")!, cidr!)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches v6 inside /32", { timeout: 5000 }, () => {
|
||||
const cidr = parseCidr("2001:db8::/32");
|
||||
expect(cidrMatches(parseIp("2001:db8:1::1")!, cidr!)).toBe(true);
|
||||
expect(cidrMatches(parseIp("2001:dead::1")!, cidr!)).toBe(false);
|
||||
});
|
||||
|
||||
it("family-disjoint match returns false", { timeout: 5000 }, () => {
|
||||
const cidr = parseCidr("10.0.0.0/8");
|
||||
const ip = parseIp("2001:db8::1");
|
||||
expect(cidrMatches(ip!, cidr!)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles partial-byte prefixes", { timeout: 5000 }, () => {
|
||||
const cidr = parseCidr("10.4.0.0/14");
|
||||
expect(cidrMatches(parseIp("10.4.0.1")!, cidr!)).toBe(true);
|
||||
expect(cidrMatches(parseIp("10.7.255.255")!, cidr!)).toBe(true);
|
||||
expect(cidrMatches(parseIp("10.8.0.0")!, cidr!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTrustedProxy", () => {
|
||||
it("returns false for nullish remoteAddress", { timeout: 5000 }, () => {
|
||||
expect(isTrustedProxy(null, [])).toBe(false);
|
||||
expect(isTrustedProxy(undefined, [])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for malformed remoteAddress", { timeout: 5000 }, () => {
|
||||
const c = parseCidr("10.0.0.0/8");
|
||||
expect(isTrustedProxy("not-an-ip", [c!])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when any CIDR matches", { timeout: 5000 }, () => {
|
||||
const cidrs = [parseCidr("10.0.0.0/8")!, parseCidr("172.16.0.0/12")!];
|
||||
expect(isTrustedProxy("172.16.1.1", cidrs)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no CIDR matches", { timeout: 5000 }, () => {
|
||||
const cidrs = [parseCidr("10.0.0.0/8")!];
|
||||
expect(isTrustedProxy("8.8.8.8", cidrs)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolver — X-Forwarded-Host trust", () => {
|
||||
it("trusted proxy honours forwarded host", { timeout: 5000 }, async () => {
|
||||
const r = createSubdomainTenantResolver({
|
||||
baseDomain: "api.example.com",
|
||||
trustForwardedHost: true,
|
||||
trustedProxyCidrs: ["10.0.0.0/8"],
|
||||
});
|
||||
const req = reqWith(
|
||||
{
|
||||
host: "internal.lb",
|
||||
"x-forwarded-host": "acme.api.example.com",
|
||||
},
|
||||
{ remoteAddress: "10.4.7.2" }
|
||||
);
|
||||
const ctx = await r.resolve(req);
|
||||
expect(ctx).toEqual({ id: "acme", slug: "acme" });
|
||||
});
|
||||
|
||||
it("untrusted proxy raises FORWARDED_HOST_UNTRUSTED in strict mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createSubdomainTenantResolver({
|
||||
baseDomain: "api.example.com",
|
||||
strict: true,
|
||||
trustForwardedHost: true,
|
||||
trustedProxyCidrs: ["10.0.0.0/8"],
|
||||
});
|
||||
const req = reqWith(
|
||||
{
|
||||
host: "acme.api.example.com",
|
||||
"x-forwarded-host": "victim.api.example.com",
|
||||
},
|
||||
{ remoteAddress: "8.8.8.8" }
|
||||
);
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await r.resolve(req);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
|
||||
expect((thrown as SubdomainTenantResolverError).code).toBe(
|
||||
TENANT_SUBDOMAIN_CODES.FORWARDED_HOST_UNTRUSTED
|
||||
);
|
||||
});
|
||||
|
||||
it("non-strict: untrusted proxy returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createSubdomainTenantResolver({
|
||||
baseDomain: "api.example.com",
|
||||
trustForwardedHost: true,
|
||||
trustedProxyCidrs: ["10.0.0.0/8"],
|
||||
});
|
||||
const req = reqWith(
|
||||
{
|
||||
host: "acme.api.example.com",
|
||||
"x-forwarded-host": "victim.api.example.com",
|
||||
},
|
||||
{ remoteAddress: "8.8.8.8" }
|
||||
);
|
||||
expect(await r.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("trustForwardedHost=false silently drops the header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createSubdomainTenantResolver({
|
||||
baseDomain: "api.example.com",
|
||||
strict: true,
|
||||
});
|
||||
const req = reqWith(
|
||||
{
|
||||
host: "acme.api.example.com",
|
||||
"x-forwarded-host": "victim.api.example.com",
|
||||
},
|
||||
{ remoteAddress: "10.4.7.2" }
|
||||
);
|
||||
const ctx = await r.resolve(req);
|
||||
expect(ctx).toEqual({ id: "acme", slug: "acme" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user