mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* fix(homepage): check allowlisted private service reachability * test(css): exercise the configured PostCSS plugin chain
319 lines
8.9 KiB
TypeScript
319 lines
8.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { createServer } from "http";
|
|
import type { AddressInfo } from "net";
|
|
import type { LookupAddress, LookupOptions } from "dns";
|
|
import {
|
|
createDnsLookupHook,
|
|
isBlockedAddress,
|
|
readResponseTextLimited,
|
|
safeOutboundFetch,
|
|
} from "../../utils/safe-outbound-fetch.js";
|
|
|
|
describe("isBlockedAddress", () => {
|
|
it("allows public IPv4 addresses", () => {
|
|
expect(isBlockedAddress("8.8.8.8")).toBe(false);
|
|
expect(isBlockedAddress("104.21.52.150")).toBe(false);
|
|
});
|
|
|
|
it("blocks private/reserved IPv4 ranges", () => {
|
|
expect(isBlockedAddress("10.0.0.1")).toBe(true);
|
|
expect(isBlockedAddress("172.16.0.1")).toBe(true);
|
|
expect(isBlockedAddress("192.168.1.1")).toBe(true);
|
|
expect(isBlockedAddress("127.0.0.1")).toBe(true);
|
|
expect(isBlockedAddress("169.254.1.1")).toBe(true);
|
|
expect(isBlockedAddress("100.64.0.1")).toBe(true);
|
|
});
|
|
|
|
it("allows public IPv6 addresses", () => {
|
|
expect(isBlockedAddress("2606:4700:3034::ac43:c88d")).toBe(false);
|
|
expect(isBlockedAddress("2001:4860:4860::8888")).toBe(false);
|
|
});
|
|
|
|
it("blocks private/reserved IPv6 ranges", () => {
|
|
expect(isBlockedAddress("::1")).toBe(true);
|
|
expect(isBlockedAddress("fc00::1")).toBe(true);
|
|
expect(isBlockedAddress("fe80::1")).toBe(true);
|
|
});
|
|
|
|
it("blocks IPv4-mapped-IPv6 spoofing of private addresses", () => {
|
|
expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true);
|
|
expect(isBlockedAddress("::ffff:192.168.1.1")).toBe(true);
|
|
expect(isBlockedAddress("::ffff:10.0.0.1")).toBe(true);
|
|
});
|
|
|
|
it("does not block IPv4-mapped-IPv6 form of public addresses", () => {
|
|
expect(isBlockedAddress("::ffff:104.21.52.150")).toBe(false);
|
|
expect(isBlockedAddress("::ffff:8.8.8.8")).toBe(false);
|
|
});
|
|
|
|
it("blocks unparseable input", () => {
|
|
expect(isBlockedAddress("not-an-ip")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("readResponseTextLimited", () => {
|
|
it("reads a response within the configured limit", async () => {
|
|
const response = new Response("hello");
|
|
await expect(readResponseTextLimited(response, 5)).resolves.toBe("hello");
|
|
});
|
|
|
|
it("rejects a declared oversized response before buffering it", async () => {
|
|
const response = new Response("small", {
|
|
headers: { "content-length": "100" },
|
|
});
|
|
await expect(readResponseTextLimited(response, 10)).rejects.toThrow(
|
|
"Response exceeds 10 bytes",
|
|
);
|
|
});
|
|
|
|
it("stops a chunked response once its actual body exceeds the limit", async () => {
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("12345"));
|
|
controller.enqueue(new TextEncoder().encode("6"));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
await expect(readResponseTextLimited(response, 5)).rejects.toThrow(
|
|
"Response exceeds 5 bytes",
|
|
);
|
|
});
|
|
});
|
|
|
|
function runHook(
|
|
addresses: LookupAddress[] | string | undefined,
|
|
error: NodeJS.ErrnoException | null = null,
|
|
lookupOptions: LookupOptions = { all: true },
|
|
allowPrivate = false,
|
|
) {
|
|
const fakeLookup = vi.fn(
|
|
(
|
|
_host: string,
|
|
_opts: LookupOptions,
|
|
cb: (
|
|
err: NodeJS.ErrnoException | null,
|
|
addrs: LookupAddress[] | string | undefined,
|
|
family?: number,
|
|
) => void,
|
|
) => {
|
|
cb(error, addresses, typeof addresses === "string" ? 4 : undefined);
|
|
},
|
|
);
|
|
|
|
const hook = createDnsLookupHook(fakeLookup, allowPrivate);
|
|
const callback = vi.fn();
|
|
|
|
hook("example.invalid", lookupOptions, callback);
|
|
|
|
return { callback, fakeLookup };
|
|
}
|
|
|
|
const lookupOptionsCases: Array<[string, LookupOptions, unknown[]]> = [
|
|
["all:true", { all: true }, ["", 0]],
|
|
["all:false", { all: false }, ["", 0]],
|
|
["all omitted", {}, ["", 0]],
|
|
];
|
|
|
|
const publicAddresses: LookupAddress[] = [
|
|
{
|
|
address: "104.21.52.150",
|
|
family: 4,
|
|
},
|
|
{
|
|
address: "2606:4700:3034::ac43:c88d",
|
|
family: 6,
|
|
},
|
|
];
|
|
|
|
describe("createDnsLookupHook", () => {
|
|
it("permits private results only for an explicitly authorized host", () => {
|
|
const { callback } = runHook(
|
|
[{ address: "192.168.1.20", family: 4 }],
|
|
null,
|
|
{ all: true },
|
|
true,
|
|
);
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
null,
|
|
[{ address: "192.168.1.20", family: 4 }],
|
|
0,
|
|
);
|
|
});
|
|
|
|
it("allows a public IPv4 address through", () => {
|
|
const { callback } = runHook([
|
|
{
|
|
address: "104.21.52.150",
|
|
family: 4,
|
|
},
|
|
]);
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
null,
|
|
[{ address: "104.21.52.150", family: 4 }],
|
|
0,
|
|
);
|
|
});
|
|
|
|
it.each(lookupOptionsCases)(
|
|
"rejects if any address is private, including an IPv4-mapped IPv6 spoof not in first position (%s)",
|
|
(_label, lookupOptions, tailArgs) => {
|
|
const { callback } = runHook(
|
|
[
|
|
{
|
|
address: "104.21.52.150",
|
|
family: 4,
|
|
},
|
|
{
|
|
address: "::ffff:192.168.1.1",
|
|
family: 6,
|
|
},
|
|
{
|
|
address: "2606:4700:3034::ac43:c88d",
|
|
family: 6,
|
|
},
|
|
],
|
|
null,
|
|
lookupOptions,
|
|
);
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Private destinations are not allowed",
|
|
}),
|
|
...tailArgs,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("returns a single lookup result when all is false", () => {
|
|
const { callback } = runHook("104.21.52.150", null, { all: false });
|
|
|
|
expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4);
|
|
});
|
|
|
|
it("returns a single lookup result when all is omitted", () => {
|
|
const { callback } = runHook("104.21.52.150", null, {});
|
|
|
|
expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4);
|
|
});
|
|
|
|
it("returns all lookup results when all is true", () => {
|
|
const { callback } = runHook(publicAddresses, null, { all: true });
|
|
|
|
expect(callback).toHaveBeenCalledWith(null, publicAddresses, 0);
|
|
});
|
|
|
|
it("rejects invalid single lookup results with a DNS lookup error", () => {
|
|
const { callback } = runHook(undefined, null, { all: false });
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "DNS lookup returned invalid address",
|
|
}),
|
|
"",
|
|
0,
|
|
);
|
|
});
|
|
|
|
it("rejects with a distinct error when DNS returns no addresses", () => {
|
|
const { callback } = runHook([]);
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "DNS resolution returned no addresses",
|
|
}),
|
|
"",
|
|
0,
|
|
);
|
|
});
|
|
|
|
it("propagates a real DNS lookup error untouched", () => {
|
|
const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
|
|
code: "ENOTFOUND",
|
|
});
|
|
|
|
const { callback } = runHook([], dnsError);
|
|
|
|
expect(callback).toHaveBeenCalledWith(dnsError, "", 0);
|
|
});
|
|
|
|
it("always asks the underlying resolver for all:true regardless of the caller's option", () => {
|
|
const { fakeLookup } = runHook(
|
|
[{ address: "104.21.52.150", family: 4 }],
|
|
null,
|
|
{ all: false },
|
|
);
|
|
|
|
console.log(fakeLookup.mock.calls);
|
|
|
|
expect(fakeLookup).toHaveBeenCalledWith(
|
|
"example.invalid",
|
|
expect.objectContaining({
|
|
all: true,
|
|
verbatim: true,
|
|
}),
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("safeOutboundFetch", () => {
|
|
it("returns a large body the caller reads after the call", async () => {
|
|
const body = "x".repeat(1_000_000);
|
|
const server = createServer((_req, res) => res.end(body));
|
|
await new Promise<void>((resolve) =>
|
|
server.listen(0, "127.0.0.1", resolve),
|
|
);
|
|
const { port } = server.address() as AddressInfo;
|
|
try {
|
|
const response = await safeOutboundFetch(
|
|
`http://127.0.0.1:${port}/`,
|
|
{},
|
|
["127.0.0.1"],
|
|
);
|
|
expect((await response.text()).length).toBe(body.length);
|
|
} finally {
|
|
server.close();
|
|
}
|
|
}, 10_000);
|
|
});
|
|
|
|
describe("redirect handling", () => {
|
|
it.each([undefined, "error", "follow", "manual"] as const)(
|
|
"never follows redirects with mode %s",
|
|
async (redirect) => {
|
|
const paths: string[] = [];
|
|
const server = createServer((req, res) => {
|
|
paths.push(req.url!);
|
|
res.writeHead(302, { Location: "/private-target" });
|
|
res.end();
|
|
});
|
|
await new Promise<void>((resolve) =>
|
|
server.listen(0, "127.0.0.1", resolve),
|
|
);
|
|
const { port } = server.address() as AddressInfo;
|
|
try {
|
|
const response = safeOutboundFetch(
|
|
`http://127.0.0.1:${port}/`,
|
|
{ redirect },
|
|
["127.0.0.1"],
|
|
);
|
|
if (redirect === "manual") {
|
|
const result = await response;
|
|
expect(result.status).toBe(302);
|
|
await result.body?.cancel();
|
|
} else {
|
|
await expect(response).rejects.toThrow();
|
|
}
|
|
expect(paths).toEqual(["/"]);
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
},
|
|
);
|
|
});
|