Files
homarr/packages/custom-widgets/src/test/server-policy.spec.ts
T

315 lines
12 KiB
TypeScript

import { createServer } from "node:http";
import { errors } from "undici";
import { describe, expect, test } from "vitest";
import {
assertJsonBudget,
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "../server";
import { isCustomWidgetRequestTimeoutError } from "../server/request-executor";
describe("custom widget network policy", () => {
test.each([
["8.8.8.8", "public"],
["10.0.0.1", "private"],
["127.0.0.1", "loopback"],
["169.254.169.254", "blocked"],
["224.0.0.1", "blocked"],
["::ffff:127.0.0.1", "blocked"],
["fe80::1", "blocked"],
] as const)("classifies %s as %s", (address, expected) => expect(classifyAddress(address)).toBe(expected));
test("enforces configured address scopes", async () => {
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("executes a DNS-pinned request within the approved scope", async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end('{"status":"ok"}');
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({ ok: true, status: 200, data: { status: "ok" } });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test.each([
["connect", new errors.ConnectTimeoutError()],
["headers", new errors.HeadersTimeoutError()],
["body", new errors.BodyTimeoutError()],
["generic timeout name", Object.assign(new Error("timed out"), { name: "ProxyTimeoutError" })],
])("recognizes %s timeout errors from the complete request lifecycle", (_label, error) => {
expect(isCustomWidgetRequestTimeoutError(error)).toBe(true);
});
test("recognizes an aborted request deadline as a timeout", () => {
const controller = new AbortController();
controller.abort();
expect(isCustomWidgetRequestTimeoutError(new errors.RequestAbortedError(), controller.signal)).toBe(true);
expect(isCustomWidgetRequestTimeoutError(new Error("socket failed"))).toBe(false);
});
test("normalizes response-body failures after headers arrive", async () => {
const events: Array<{ errorName: string; reason?: "timeout" }> = [];
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.flushHeaders();
response.write('{"status":');
setImmediate(() => response.destroy());
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
networkScope: "loopback",
kind: "query",
logError: (event) => events.push(event),
}),
).rejects.toMatchObject({ code: "BAD_GATEWAY" });
expect(events).toHaveLength(1);
expect(events[0]?.errorName).toBe("TransportError");
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("normalizes failures while draining a redirect body", async () => {
const server = createServer((_request, response) => {
response.writeHead(302, { location: "/target" });
response.flushHeaders();
response.write("partial redirect body");
setImmediate(() => response.destroy());
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
networkScope: "loopback",
kind: "query",
}),
).rejects.toMatchObject({ code: "BAD_GATEWAY" });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("maps Undici's response-size failure to the domain size limit", async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(`"${"x".repeat(1024 * 1024)}"`);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
networkScope: "loopback",
kind: "query",
}),
).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("preserves domain errors raised while parsing the response body", async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end("{invalid");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
networkScope: "loopback",
kind: "query",
}),
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Upstream returned invalid JSON",
});
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("preserves structured bodies for GET queries", async () => {
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ body }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
body: '{"query":"status"}',
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({ ok: true, data: { body: '{"query":"status"}' } });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test.each([
[301, "GET", ""],
[302, "GET", ""],
[303, "GET", ""],
[307, "POST", '{"query":"status"}'],
[308, "POST", '{"query":"status"}'],
] as const)("follows %i redirects with fetch-compatible method semantics", async (status, method, body) => {
const server = createServer((request, response) => {
if (request.url === "/start") {
request.resume();
response.writeHead(status, { location: "/target" });
response.end();
return;
}
let receivedBody = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
receivedBody += chunk;
});
request.on("end", () => {
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
method: request.method,
body: receivedBody,
contentType: request.headers["content-type"] ?? null,
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/start`,
method: "POST",
body: '{"query":"status"}',
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({
ok: true,
data: {
method,
body,
contentType: body ? "application/json" : null,
},
});
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("does not follow redirects for actions", async () => {
const server = createServer((_request, response) => {
response.writeHead(303, { location: "/target" });
response.end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/start`,
method: "POST",
networkScope: "loopback",
kind: "action",
}),
).rejects.toThrow("redirect limit");
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("allows only credential-free HTTP(S) URLs without fragments", () => {
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("HTTP and HTTPS");
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
});
test("rejects cross-origin targets and reserved headers", () => {
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
expect(() => resolveSameOriginTarget("https://example.com", "https://attacker.example")).toThrow("origin");
for (const header of [
"Authorization",
"Cookie",
"Host",
"Proxy-Authorization",
"Sec-Fetch-Site",
"X-Forwarded-For",
]) {
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
}
});
test("rejects response JSON beyond the depth budget", () => {
let nested: Record<string, unknown> = {};
const root = nested;
for (let index = 0; index < 40; index += 1) {
nested.child = {};
nested = nested.child as Record<string, unknown>;
}
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
});
});