mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
439 lines
18 KiB
TypeScript
439 lines
18 KiB
TypeScript
/**
|
|
* Mutation-killing coverage for the SSRF module. These tests target the
|
|
* survivors Stryker leaves after the behavioural specs (ssrf.test.ts,
|
|
* ssrf-https.test.ts, ssrf-response.test.ts): the IPv4/IPv6 range-boundary
|
|
* comparisons in the private-IP classifier, the scheme/protocol branches, the
|
|
* IP-pinning agent construction, and the HTTP/HTTPS request plumbing.
|
|
*
|
|
* The technique throughout: for every reserved CIDR boundary, assert the IP one
|
|
* step inside is BLOCKED and the IP one step outside is ALLOWED, so a flipped
|
|
* `<=`/`>=`/`===`/`&&` comparator changes an observable boolean. Where a value
|
|
* is threaded through the request path, capture it and assert the exact value a
|
|
* mutation would perturb.
|
|
*/
|
|
import { EventEmitter } from "node:events";
|
|
import type { IncomingMessage } from "node:http";
|
|
import https from "node:https";
|
|
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";
|
|
import { isPrivateIp, safeFetch } from "../../../apps/api/src/lib/ssrf.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isPrivateIp: IPv4 range boundaries (kills the octet-comparison mutants on
|
|
// lines 11-20). Each pair pins the exact edge: `inside` must be private,
|
|
// `outside` (one address beyond the boundary) must be public. A boundary
|
|
// operator flipped from `>=` to `>` (or `<=` to `<`, or a constant nudged by
|
|
// one) reclassifies exactly one of the two, so at least one assertion fails.
|
|
// ---------------------------------------------------------------------------
|
|
describe("isPrivateIp IPv4 boundaries", () => {
|
|
const blocked: Array<[string, string]> = [
|
|
// 10.0.0.0/8 (line 11): a === 10
|
|
["10.0.0.0", "lowest of 10/8"],
|
|
["10.255.255.255", "highest of 10/8"],
|
|
// 172.16.0.0/12 (line 12): a === 172 && b in [16,31]
|
|
["172.16.0.0", "low edge of 172.16/12"],
|
|
["172.31.255.255", "high edge of 172.16/12"],
|
|
["172.16.5.9", "mid 172.16/12"],
|
|
["172.31.0.1", "b === 31 upper bound"],
|
|
// 192.168.0.0/16 (line 13): a === 192 && b === 168
|
|
["192.168.0.0", "low of 192.168/16"],
|
|
["192.168.255.255", "high of 192.168/16"],
|
|
// 127.0.0.0/8 (line 14): a === 127
|
|
["127.0.0.0", "low of 127/8"],
|
|
["127.255.255.255", "high of 127/8"],
|
|
// 169.254.0.0/16 (line 15): a === 169 && b === 254
|
|
["169.254.0.0", "low of 169.254/16"],
|
|
["169.254.255.255", "high of 169.254/16"],
|
|
// 0.0.0.0/8 (line 16): a === 0
|
|
["0.0.0.0", "this-network 0/8"],
|
|
["0.255.255.255", "high of 0/8"],
|
|
// 100.64.0.0/10 (line 17): a === 100 && b in [64,127]
|
|
["100.64.0.0", "low edge of CG-NAT"],
|
|
["100.127.255.255", "high edge of CG-NAT"],
|
|
["100.64.0.1", "b === 64 lower bound"],
|
|
["100.127.0.0", "b === 127 upper bound"],
|
|
// 192.0.0.0/24 (line 18): a === 192 && b === 0 && parts[2] === 0
|
|
["192.0.0.0", "low of 192.0.0/24"],
|
|
["192.0.0.255", "high of 192.0.0/24"],
|
|
// 198.18.0.0/15 (line 19): a === 198 && (b === 18 || b === 19)
|
|
["198.18.0.0", "low of benchmarking /15"],
|
|
["198.18.255.255", "b === 18 span"],
|
|
["198.19.0.0", "b === 19 low"],
|
|
["198.19.255.255", "high of benchmarking /15"],
|
|
// 240.0.0.0/4 (line 20): a >= 240
|
|
["240.0.0.0", "low of class E"],
|
|
["255.255.255.255", "broadcast / top of class E"],
|
|
];
|
|
|
|
it.each(blocked)("blocks %s (%s)", (ip) => {
|
|
expect(isPrivateIp(ip)).toBe(true);
|
|
});
|
|
|
|
const allowed: Array<[string, string]> = [
|
|
// Just below / above each boundary -> must be PUBLIC. These are the
|
|
// "outside" halves that a shifted comparator would wrongly capture.
|
|
["9.255.255.255", "one below 10/8"],
|
|
["11.0.0.0", "one above 10/8"],
|
|
["172.15.255.255", "one below 172.16/12 (kills b >= 16 -> b >= 15)"],
|
|
["172.32.0.0", "one above 172.16/12 (kills b <= 31 -> b <= 32)"],
|
|
["192.167.255.255", "one below 192.168/16"],
|
|
["192.169.0.0", "one above 192.168/16"],
|
|
["126.255.255.255", "one below 127/8"],
|
|
["128.0.0.0", "one above 127/8"],
|
|
["169.253.255.255", "one below 169.254/16"],
|
|
["169.255.0.0", "one above 169.254/16 (kills b === 254 -> b === 255)"],
|
|
["1.0.0.0", "one above 0/8"],
|
|
["100.63.255.255", "one below CG-NAT (kills b >= 64 -> b >= 63)"],
|
|
["100.128.0.0", "one above CG-NAT (kills b <= 127 -> b <= 128)"],
|
|
["192.0.1.0", "one above 192.0.0/24 (kills parts[2] === 0 -> !== 0)"],
|
|
["192.1.0.0", "adjacent /8 to 192.0.0/24 (kills b === 0 relaxation)"],
|
|
["198.17.255.255", "one below benchmarking /15"],
|
|
["198.20.0.0", "one above benchmarking /15 (kills b === 19 boundary)"],
|
|
["239.255.255.255", "one below class E (kills a >= 240 -> a >= 239)"],
|
|
// Well-known public resolvers as positive controls.
|
|
["8.8.8.8", "public DNS"],
|
|
["93.184.216.34", "example.com"],
|
|
["1.1.1.1", "public DNS"],
|
|
];
|
|
|
|
it.each(allowed)("allows %s (%s)", (ip) => {
|
|
expect(isPrivateIp(ip)).toBe(false);
|
|
});
|
|
|
|
// The 198.18/15 rule (line 19) uses an OR of two exact octets. Prove each arm
|
|
// is load-bearing: 198.17 and 198.20 are the immediate non-matches, and
|
|
// nothing between 18 and 19 is skipped.
|
|
it("treats 198.18/15 as exactly b===18 or b===19", () => {
|
|
expect(isPrivateIp("198.17.0.0")).toBe(false);
|
|
expect(isPrivateIp("198.18.0.0")).toBe(true);
|
|
expect(isPrivateIp("198.19.0.0")).toBe(true);
|
|
expect(isPrivateIp("198.20.0.0")).toBe(false);
|
|
});
|
|
|
|
// 192.0.0/24 (line 18) needs all three octet conditions. Break each one:
|
|
// wrong first octet, wrong second, wrong third -> public.
|
|
it("requires all three octets for 192.0.0/24", () => {
|
|
expect(isPrivateIp("192.0.0.7")).toBe(true); // inside
|
|
expect(isPrivateIp("191.0.0.7")).toBe(false); // a !== 192
|
|
expect(isPrivateIp("192.1.0.7")).toBe(false); // b !== 0
|
|
expect(isPrivateIp("192.0.1.7")).toBe(false); // parts[2] !== 0
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isPrivateIp: IPv6 classifier. Covers the IPv4-mapped / IPv4-compatible
|
|
// embedding (lines 62-67, esp. the 0xff byte checks on line 63) and each
|
|
// blocked CIDR (lines 29-41 / 69). A mapped private v4 must stay blocked; a
|
|
// mapped public v4 must be allowed (proves the embedded classifier actually
|
|
// runs rather than a blanket block).
|
|
// ---------------------------------------------------------------------------
|
|
describe("isPrivateIp IPv6 classifier", () => {
|
|
it("blocks IPv4-mapped IPv6 wrapping a private v4 (line 63 isMapped)", () => {
|
|
expect(isPrivateIp("::ffff:127.0.0.1")).toBe(true); // loopback
|
|
expect(isPrivateIp("::ffff:10.0.0.1")).toBe(true); // RFC1918
|
|
expect(isPrivateIp("::ffff:169.254.169.254")).toBe(true); // metadata
|
|
expect(isPrivateIp("::ffff:a9fe:a9fe")).toBe(true); // same, hex form
|
|
});
|
|
|
|
it("allows IPv4-mapped IPv6 wrapping a public v4 (proves embedded check runs)", () => {
|
|
expect(isPrivateIp("::ffff:8.8.8.8")).toBe(false);
|
|
expect(isPrivateIp("::ffff:9.255.255.255")).toBe(false); // just below 10/8
|
|
expect(isPrivateIp("::ffff:11.0.0.0")).toBe(false); // just above 10/8
|
|
});
|
|
|
|
it("blocks IPv4-compatible IPv6 wrapping a private v4 (line 64 isCompatible)", () => {
|
|
expect(isPrivateIp("::127.0.0.1")).toBe(true);
|
|
expect(isPrivateIp("::169.254.169.254")).toBe(true);
|
|
// ::0.0.0.1 embeds 0.0.0.1, which is inside 0.0.0.0/8.
|
|
expect(isPrivateIp("::0.0.0.1")).toBe(true);
|
|
});
|
|
|
|
const blockedCidrs: Array<[string, string]> = [
|
|
["::1", "loopback ::1/128"],
|
|
["::", "unspecified ::/128"],
|
|
["fe80::1", "link-local fe80::/10 low"],
|
|
["febf::1", "link-local fe80::/10 high"],
|
|
["fc00::1", "unique-local fc00::/7 low"],
|
|
["fdff::1", "unique-local fc00::/7 high"],
|
|
["fec0::1", "site-local fec0::/10"],
|
|
["2001:db8::1", "documentation 2001:db8::/32"],
|
|
["2001::1", "Teredo 2001::/32"],
|
|
["2002::1", "6to4 2002::/16"],
|
|
["64:ff9b::1", "NAT64 64:ff9b::/96"],
|
|
["100::1", "discard 100::/64"],
|
|
["100::ffff", "discard 100::/64 (distinct from CG-NAT 100.64/10)"],
|
|
["ff02::1", "multicast ff00::/8"],
|
|
];
|
|
it.each(blockedCidrs)("blocks %s (%s)", (ip) => {
|
|
expect(isPrivateIp(ip)).toBe(true);
|
|
});
|
|
|
|
const publicV6: Array<[string, string]> = [
|
|
["2606:4700::1", "Cloudflare public"],
|
|
["2001:4860:4860::8888", "Google public DNS"],
|
|
["2620:fe::fe", "Quad9 public DNS"],
|
|
// fe00::1 sits one prefix below link-local fe80::/10 and outside fc00::/7,
|
|
// so it is genuinely public: proves the CIDR match doesn't over-block.
|
|
["fe00::1", "just below link-local, outside unique-local"],
|
|
];
|
|
it.each(publicV6)("allows %s (%s)", (ip) => {
|
|
expect(isPrivateIp(ip)).toBe(false);
|
|
});
|
|
|
|
// Unparseable input fails closed (line 52 -> return true). A mutation that
|
|
// flips that default to `return false` would let garbage through.
|
|
it("fails closed on unparseable addresses", () => {
|
|
expect(isPrivateIp("not-an-ip")).toBe(true);
|
|
expect(isPrivateIp("")).toBe(true);
|
|
expect(isPrivateIp("gg::zz")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// safeFetch HTTP path: exercises the `parsed.protocol === "http:"` branch
|
|
// (line 237) and the IPv6 bracket rewrite (line 240) through the global fetch
|
|
// stub. DNS is mocked so a bare hostname resolves to a chosen public IP and we
|
|
// can assert the request URL carries the pinned IP, not the hostname.
|
|
// ---------------------------------------------------------------------------
|
|
const dnsOriginal = vi.hoisted(() => ({ fn: null as null | ((...args: unknown[]) => unknown) }));
|
|
vi.mock("node:dns/promises", async (importOriginal) => {
|
|
const orig = (await importOriginal()) as Record<string, unknown>;
|
|
dnsOriginal.fn = orig.lookup as (...args: unknown[]) => unknown;
|
|
return { ...orig, lookup: vi.fn((...args: unknown[]) => dnsOriginal.fn?.(...args)) };
|
|
});
|
|
|
|
describe("safeFetch HTTP IP pinning (lines 237, 240)", () => {
|
|
let mockFetch: Mock;
|
|
|
|
beforeEach(() => {
|
|
mockFetch = vi.fn();
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function okResponse(): Response {
|
|
return {
|
|
status: 200,
|
|
headers: new Headers(),
|
|
body: null,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
it("replaces the hostname with the resolved IPv4 for HTTP requests", async () => {
|
|
const dns = await import("node:dns/promises");
|
|
vi.mocked(dns.lookup).mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }] as never);
|
|
mockFetch.mockResolvedValueOnce(okResponse());
|
|
|
|
await safeFetch("http://public-host.example/photo.jpg");
|
|
|
|
// Line 237 true-branch: the fetched URL swaps in the IP; the hostname is
|
|
// gone. If the branch were skipped, this URL would still say the hostname.
|
|
const calledUrl = String(mockFetch.mock.calls[0]?.[0]);
|
|
expect(calledUrl).toBe("http://93.184.216.34/photo.jpg");
|
|
expect(calledUrl).not.toContain("public-host.example");
|
|
});
|
|
|
|
it("wraps a resolved IPv6 address in brackets for HTTP requests (line 240)", async () => {
|
|
const dns = await import("node:dns/promises");
|
|
vi.mocked(dns.lookup).mockResolvedValueOnce([{ address: "2606:4700::1", family: 6 }] as never);
|
|
mockFetch.mockResolvedValueOnce(okResponse());
|
|
|
|
await safeFetch("http://v6-host.example/img.png");
|
|
|
|
// The ternary must bracket IPv6 (kills the `[${ip}]` -> `${ip}` mutant: an
|
|
// unbracketed IPv6 assignment to URL.hostname is rejected and the hostname
|
|
// would stay "v6-host.example").
|
|
const calledUrl = String(mockFetch.mock.calls[0]?.[0]);
|
|
expect(calledUrl).toBe("http://[2606:4700::1]/img.png");
|
|
expect(calledUrl).not.toContain("v6-host.example");
|
|
});
|
|
|
|
it("sends the SSRF User-Agent and original Host header on the HTTP path", async () => {
|
|
const dns = await import("node:dns/promises");
|
|
vi.mocked(dns.lookup).mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }] as never);
|
|
mockFetch.mockResolvedValueOnce(okResponse());
|
|
|
|
await safeFetch("http://public-host.example/x");
|
|
|
|
const init = mockFetch.mock.calls[0]?.[1] as RequestInit;
|
|
const headers = init.headers as Record<string, string>;
|
|
expect(headers["User-Agent"]).toBe("SnapOtter/2.0 (file-fetch)");
|
|
// Host header keeps the original hostname even though the URL uses the IP.
|
|
expect(headers.Host).toBe("public-host.example");
|
|
expect(init.redirect).toBe("manual");
|
|
expect(init.method).toBe("GET");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// safeFetch HTTPS path + createPinnedAgent. node:https.request is mocked so no
|
|
// socket opens; we keep the real https.Agent so createPinnedAgent builds a
|
|
// genuine agent. Targets: the `protocol === "https:"` agent branch (line 163),
|
|
// the AbortSignal normalization branch (line 171), the statusMessage `?? ""`
|
|
// fallback (line 306), and the toFetchResponse-throws catch (line 310).
|
|
// ---------------------------------------------------------------------------
|
|
interface CapturedHttps {
|
|
url: unknown;
|
|
options: {
|
|
agent?: unknown;
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
signal?: AbortSignal;
|
|
};
|
|
}
|
|
const capturedHttps: CapturedHttps[] = [];
|
|
type HttpsScript = (msg: EventEmitter & Partial<IncomingMessage>) => void;
|
|
let httpsScripts: HttpsScript[] = [];
|
|
|
|
const mockHttpsRequest: Mock = vi.hoisted(() =>
|
|
vi.fn((url: unknown, options: CapturedHttps["options"], callback: (msg: unknown) => void) => {
|
|
const req = new EventEmitter() as EventEmitter & {
|
|
write: Mock;
|
|
end: Mock;
|
|
destroy: Mock;
|
|
};
|
|
req.write = vi.fn(() => true);
|
|
req.end = vi.fn(() => req);
|
|
req.destroy = vi.fn((err?: Error) => {
|
|
if (err) setImmediate(() => req.emit("error", err));
|
|
return req;
|
|
});
|
|
capturedHttps.push({ url, options });
|
|
const script = httpsScripts.shift();
|
|
setImmediate(() => {
|
|
const msg = new EventEmitter() as EventEmitter & Partial<IncomingMessage>;
|
|
msg.statusCode = 200;
|
|
msg.statusMessage = "OK";
|
|
msg.headers = {};
|
|
callback(msg);
|
|
script?.(msg);
|
|
});
|
|
return req;
|
|
}),
|
|
);
|
|
|
|
vi.mock("node:https", async (importOriginal) => {
|
|
const actual: Record<string, unknown> = await importOriginal();
|
|
const actualDefault = (actual.default ?? {}) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
default: { ...actualDefault, request: mockHttpsRequest },
|
|
request: mockHttpsRequest,
|
|
};
|
|
});
|
|
|
|
const PUBLIC_HTTPS = "https://93.184.216.34/image.jpg";
|
|
|
|
describe("safeFetch HTTPS agent + plumbing (lines 163, 171, 306, 310)", () => {
|
|
beforeEach(() => {
|
|
capturedHttps.length = 0;
|
|
httpsScripts = [];
|
|
mockHttpsRequest.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("builds an https.Agent (not a plain http.Agent) for HTTPS (line 163)", async () => {
|
|
httpsScripts = [
|
|
(msg) => {
|
|
msg.emit("end");
|
|
},
|
|
];
|
|
|
|
await safeFetch(PUBLIC_HTTPS);
|
|
|
|
// The https: branch must construct an https.Agent. A mutation returning the
|
|
// http.Agent fallback fails this instanceof (https.Agent extends http.Agent,
|
|
// so the check is specific to the subclass).
|
|
expect(capturedHttps[0].options.agent).toBeInstanceOf(https.Agent);
|
|
});
|
|
|
|
it("threads a positional AbortSignal through to the request (line 171)", async () => {
|
|
httpsScripts = [
|
|
(msg) => {
|
|
msg.emit("end");
|
|
},
|
|
];
|
|
const controller = new AbortController();
|
|
|
|
await safeFetch(PUBLIC_HTTPS, controller.signal);
|
|
|
|
// normalizeSafeFetchOptions must detect the AbortSignal and wrap it as
|
|
// { signal }. If that branch is skipped, options.signal is undefined here.
|
|
expect(capturedHttps[0].options.signal).toBe(controller.signal);
|
|
});
|
|
|
|
it("does not treat a plain options object as a signal", async () => {
|
|
httpsScripts = [
|
|
(msg) => {
|
|
msg.emit("end");
|
|
},
|
|
];
|
|
|
|
// A SafeFetchOptions object lacks `aborted`/`addEventListener`, so line 171
|
|
// must fall through to `return options` and NOT set signal from the object.
|
|
await safeFetch(PUBLIC_HTTPS, { method: "POST", body: "x" });
|
|
|
|
expect(capturedHttps[0].options.method).toBe("POST");
|
|
expect(capturedHttps[0].options.signal).toBeUndefined();
|
|
});
|
|
|
|
it("falls back to an empty statusText when statusMessage is absent (line 306)", async () => {
|
|
httpsScripts = [
|
|
(msg) => {
|
|
// Server sends a 200 with no reason phrase at all.
|
|
msg.statusCode = 200;
|
|
msg.statusMessage = undefined;
|
|
msg.headers = {};
|
|
msg.emit("end");
|
|
},
|
|
];
|
|
|
|
const res = await safeFetch(PUBLIC_HTTPS);
|
|
|
|
// `statusMessage ?? ""` must yield "". A mutation of the "" literal to any
|
|
// non-empty string would surface here as a non-empty statusText.
|
|
expect(res.status).toBe(200);
|
|
expect(res.statusText).toBe("");
|
|
});
|
|
|
|
it("rejects when toFetchResponse throws while building the Response (line 310)", async () => {
|
|
httpsScripts = [
|
|
(msg) => {
|
|
// A reason phrase containing a newline is an invalid HTTP statusText;
|
|
// the Response constructor inside toFetchResponse throws TypeError,
|
|
// which the try/catch must translate into a promise rejection.
|
|
msg.statusCode = 200;
|
|
msg.statusMessage = "bad\nreason";
|
|
msg.headers = {};
|
|
msg.emit("end");
|
|
},
|
|
];
|
|
|
|
// The catch on line 310 (reject(err)) is the only way this surfaces; without
|
|
// it the rejection never happens and the await would hang / resolve wrongly.
|
|
await expect(safeFetch(PUBLIC_HTTPS)).rejects.toThrow(/statusText/i);
|
|
});
|
|
|
|
it("preserves a normal statusText on the HTTPS path", async () => {
|
|
httpsScripts = [
|
|
(msg) => {
|
|
msg.statusCode = 201;
|
|
msg.statusMessage = "Created";
|
|
msg.headers = {};
|
|
msg.emit("end");
|
|
},
|
|
];
|
|
|
|
const res = await safeFetch(PUBLIC_HTTPS);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.statusText).toBe("Created");
|
|
});
|
|
});
|