245 lines
7.2 KiB
JavaScript
245 lines
7.2 KiB
JavaScript
import { createServer, request as httpRequest } from "node:http";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { createLocalRunnerBrowserMiddleware } from "./local-runner-browser-server.mjs";
|
|
|
|
const openServers = new Set();
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, reject, resolve };
|
|
}
|
|
|
|
function fakeRunner() {
|
|
const completions = [];
|
|
let startCalls = 0;
|
|
return {
|
|
completions,
|
|
get startCalls() {
|
|
return startCalls;
|
|
},
|
|
async load() {
|
|
return {
|
|
async startLocalRunnerScenario({ scenario }) {
|
|
startCalls += 1;
|
|
const completion = deferred();
|
|
completions.push(completion);
|
|
return {
|
|
metadata: { schema: "test", scenario },
|
|
completion: completion.promise,
|
|
async interrupt() {
|
|
return { status: "accepted" };
|
|
},
|
|
async resolveRequest() {
|
|
return { status: "accepted" };
|
|
},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
async function startServer(options = {}) {
|
|
const middleware = createLocalRunnerBrowserMiddleware(options);
|
|
const server = createServer((request, response) => {
|
|
void middleware(request, response, () => {
|
|
response.statusCode = 404;
|
|
response.end();
|
|
});
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
openServers.add(server);
|
|
const address = server.address();
|
|
if (typeof address !== "object" || address === null) {
|
|
throw new Error("test server did not expose a TCP address");
|
|
}
|
|
return { server, port: address.port };
|
|
}
|
|
|
|
function sendRequest(port, options = {}) {
|
|
const body = options.body ?? "";
|
|
const host = options.host ?? `127.0.0.1:${port}`;
|
|
const origin = options.origin === undefined ? `http://${host}` : options.origin;
|
|
const headers = {
|
|
host,
|
|
...(origin === null ? {} : { origin }),
|
|
...(body.length === 0 ? {} : { "content-type": "application/json" }),
|
|
...options.headers,
|
|
};
|
|
return new Promise((resolve, reject) => {
|
|
const request = httpRequest(
|
|
{
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
path: options.path ?? "/api/localRunner/runs",
|
|
method: options.method ?? "POST",
|
|
headers,
|
|
},
|
|
(response) => {
|
|
const chunks = [];
|
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
response.on("end", () => {
|
|
const source = Buffer.concat(chunks).toString("utf8");
|
|
resolve({
|
|
status: response.statusCode,
|
|
body: source.length === 0 ? null : JSON.parse(source),
|
|
});
|
|
});
|
|
},
|
|
);
|
|
request.on("error", reject);
|
|
if (options.chunks !== undefined) {
|
|
for (const chunk of options.chunks) {
|
|
request.write(chunk);
|
|
}
|
|
request.end();
|
|
} else {
|
|
request.end(body);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function flushPromises() {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
afterEach(async () => {
|
|
const servers = [...openServers];
|
|
openServers.clear();
|
|
await Promise.all(
|
|
servers.map(
|
|
(server) =>
|
|
new Promise((resolve) => {
|
|
server.close(resolve);
|
|
server.closeAllConnections?.();
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
describe("Local runner browser transport hardening", () => {
|
|
it("denies cross-origin starts and applies the guard before route lookup", async () => {
|
|
const runner = fakeRunner();
|
|
const { port } = await startServer({ loadRunner: () => runner.load() });
|
|
|
|
const start = await sendRequest(port, {
|
|
origin: "https://attacker.example",
|
|
body: JSON.stringify({ scenario: "happy-path" }),
|
|
});
|
|
const unknown = await sendRequest(port, {
|
|
method: "GET",
|
|
path: "/api/localRunner/runs/missing/events",
|
|
origin: "https://attacker.example",
|
|
});
|
|
|
|
expect(start.status).toBe(403);
|
|
expect(unknown.status).toBe(403);
|
|
expect(runner.startCalls).toBe(0);
|
|
});
|
|
|
|
it("rejects non-loopback Host values even when Origin matches", async () => {
|
|
const runner = fakeRunner();
|
|
const { port } = await startServer({ loadRunner: () => runner.load() });
|
|
|
|
const response = await sendRequest(port, {
|
|
host: "attacker.example",
|
|
origin: "http://attacker.example",
|
|
body: JSON.stringify({ scenario: "happy-path" }),
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(runner.startCalls).toBe(0);
|
|
});
|
|
|
|
it("rejects a loopback Host that does not name the listening port", async () => {
|
|
const runner = fakeRunner();
|
|
const { port } = await startServer({ loadRunner: () => runner.load() });
|
|
|
|
const response = await sendRequest(port, {
|
|
host: "127.0.0.1:9",
|
|
origin: "http://127.0.0.1:9",
|
|
body: JSON.stringify({ scenario: "happy-path" }),
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(runner.startCalls).toBe(0);
|
|
});
|
|
|
|
it("caps active runs and evicts the oldest completed run at the retention bound", async () => {
|
|
const runner = fakeRunner();
|
|
const { port } = await startServer({
|
|
loadRunner: () => runner.load(),
|
|
limits: { maxActiveRuns: 1, maxRetainedRuns: 2 },
|
|
});
|
|
const body = JSON.stringify({ scenario: "happy-path" });
|
|
|
|
const first = await sendRequest(port, { body });
|
|
const rejected = await sendRequest(port, { body });
|
|
expect(first.status).toBe(201);
|
|
expect(rejected.status).toBe(429);
|
|
|
|
runner.completions[0].resolve({ run: 1 });
|
|
await flushPromises();
|
|
const second = await sendRequest(port, { body });
|
|
expect(second.status).toBe(201);
|
|
runner.completions[1].resolve({ run: 2 });
|
|
await flushPromises();
|
|
|
|
const third = await sendRequest(port, { body });
|
|
expect(third.status).toBe(201);
|
|
const evicted = await sendRequest(port, {
|
|
method: "GET",
|
|
path: `/api/localRunner/runs/${first.body.id}/events`,
|
|
});
|
|
expect(evicted.status).toBe(404);
|
|
|
|
runner.completions[2].resolve({ run: 3 });
|
|
await flushPromises();
|
|
});
|
|
|
|
it("rejects declared and streamed oversized JSON before starting a runner", async () => {
|
|
const runner = fakeRunner();
|
|
const { port } = await startServer({
|
|
loadRunner: () => runner.load(),
|
|
limits: { maxRequestBodyBytes: 32 },
|
|
});
|
|
const oversized = JSON.stringify({ scenario: "x".repeat(64) });
|
|
|
|
const declared = await sendRequest(port, { body: oversized });
|
|
const streamed = await sendRequest(port, {
|
|
chunks: ["{\"scenario\":\"", "y".repeat(64), "\"}"],
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"transfer-encoding": "chunked",
|
|
},
|
|
});
|
|
|
|
expect(declared.status).toBe(413);
|
|
expect(streamed.status).toBe(413);
|
|
expect(runner.startCalls).toBe(0);
|
|
});
|
|
|
|
it("requires JSON content so a simple text/plain browser POST cannot start work", async () => {
|
|
const runner = fakeRunner();
|
|
const { port } = await startServer({ loadRunner: () => runner.load() });
|
|
|
|
const response = await sendRequest(port, {
|
|
body: JSON.stringify({ scenario: "happy-path" }),
|
|
headers: { "content-type": "text/plain" },
|
|
});
|
|
|
|
expect(response.status).toBe(415);
|
|
expect(runner.startCalls).toBe(0);
|
|
});
|
|
});
|