paperclip/scripts/smoke/chat-webhook-ingress.test.mjs

772 lines
22 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { EventEmitter, once } from "node:events";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import test from "node:test";
import {
INERT_BODY,
INVALID_SIGNATURE,
TIMEOUT_MS,
isPublicAddress,
main,
parseArgs,
probeTarget,
} from "./chat-webhook-ingress.mjs";
const script = fileURLToPath(
new URL("./chat-webhook-ingress.mjs", import.meta.url),
);
const webhookPath = "/api/chat-webhooks/fixture-public-id-secret/slack";
const publicUrl = `https://canary.example.com:8443${webhookPath}`;
const secret = "PRIVATE_RESPONSE_CREDENTIAL_CANARY";
const runFile = promisify(execFile);
async function fixture(t, handler) {
const server = http.createServer(handler);
const sockets = new Set();
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
});
const url = `http://127.0.0.1:${server.address().port}${webhookPath}`;
const target = parseArgs(["--url", publicUrl, "--compare-url", url])
.targets[1];
return { server, target, url };
}
function assertRedacted(value) {
const output = typeof value === "string" ? value : JSON.stringify(value);
for (const denied of [
secret,
"fixture-public-id-secret",
"canary.example.com",
"/api/chat-webhooks",
"127.0.0.1",
"http:",
"https:",
]) {
assert.equal(
output.includes(denied),
false,
`unexpected private output: ${denied}`,
);
}
}
test("no URL, help, invalid options and missing values never invoke networking", async () => {
for (const argv of [
[],
["--help"],
["--url"],
["--compare-url", publicUrl],
["--relay-ip", "8.8.8.8"],
["--url", publicUrl, "--url", publicUrl],
[publicUrl],
["--help", "--url", publicUrl],
["--url", publicUrl, "--headers", secret],
["--url", publicUrl, "--retry", "1"],
["--url", publicUrl, "--timeout", "999"],
["--url", publicUrl, "--"],
["--url", 123],
]) {
let calls = 0;
let output = "";
const status = await main(argv, {
write: (line) => {
output += line;
},
probe: () => {
calls++;
},
});
assert.equal(calls, 0);
assert.equal(status, argv.length === 0 || argv.join() === "--help" ? 0 : 2);
assert.equal(output.includes(secret), false);
assert.equal(output.includes("fixture-public-id-secret"), false);
}
});
for (const raw of [
`http://canary.example.com${webhookPath}`,
`https://user:password@canary.example.com${webhookPath}`,
`${publicUrl}?token=${secret}`,
`${publicUrl}#${secret}`,
`${publicUrl}?`,
`${publicUrl}#`,
`${publicUrl}\r\nAuthorization: ${secret}`,
`${publicUrl}\t`,
` ${publicUrl}`,
`https://canary.example.com/%2e%2e${webhookPath}`,
`https://canary.example.com/wrong/..${webhookPath}`,
`https://canary.example.com\\evil${webhookPath}`,
`https://canary.example.com${webhookPath.replace("/slack", "/telegram")}`,
`https://canary.example.com${webhookPath}/`,
`https://127.0.0.1${webhookPath}`,
`https://[::1]${webhookPath}`,
`https://canary.local${webhookPath}`,
`https://canary.internal${webhookPath}`,
`https://canary.example.com:0${webhookPath}`,
`https://canary.example.com:999999${webhookPath}`,
]) {
test(`rejects unsafe public URL variant ${[...raw].reduce((sum, char) => sum + char.codePointAt(0), 0)}`, async () => {
let output = "";
assert.equal(
await main(["--url", raw], {
write: (line) => {
output += line;
},
probe: () => assert.fail("networking"),
}),
2,
);
assertRedacted(output);
});
}
test("comparator is exact literal loopback HTTP with exactly the same path", () => {
for (const authority of ["127.0.0.1:3104", "[::1]:3104"]) {
assert.equal(
parseArgs([
"--url",
publicUrl,
"--compare-url",
`http://${authority}${webhookPath}`,
]).targets.length,
2,
);
}
for (const value of [
`https://127.0.0.1${webhookPath}`,
`http://localhost${webhookPath}`,
`http://127.1${webhookPath}`,
`http://2130706433${webhookPath}`,
`http://127.0.0.2${webhookPath}`,
`http://10.0.0.1${webhookPath}`,
`http://127.0.0.1:3104${webhookPath.replace("fixture-public", "another-public")}`,
`http://127.0.0.1:3104${webhookPath}\r\nCookie: ${secret}`,
])
assert.throws(() =>
parseArgs(["--url", publicUrl, "--compare-url", value]),
);
});
test("only unambiguous public relay IPs are accepted, including no IPv6 tunneling", () => {
for (const address of [
"8.8.8.8",
"209.177.145.192",
"2607:f740:f::b31",
"2001:4860:4860::8888",
]) {
assert.equal(isPublicAddress(address), true);
assert.equal(
parseArgs(["--url", publicUrl, "--relay-ip", address]).targets[0].relayIp,
address,
);
}
for (const address of [
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"100.127.255.255",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"192.0.0.1",
"192.0.2.1",
"192.88.99.1",
"192.168.1.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"255.255.255.255",
"::",
"::1",
"fc00::1",
"fe80::1",
"ff02::1",
"::ffff:127.0.0.1",
"::ffff:8.8.8.8",
"64:ff9b::808:808",
"2001::1",
"2001:db8::1",
"2002:7f00:1::1",
"3fff::1",
"4000::1",
"fe80::1%en0",
"[2607:f740:f::b31]",
"8.8.8.8:443",
"008.008.008.008",
"0x08080808",
"134744072",
"example.com",
"8.8.8.8\r\nAuthorization: secret",
" 8.8.8.8",
"8.8.8.8 ",
"",
]) {
assert.equal(isPublicAddress(address), false);
assert.throws(() => parseArgs(["--url", publicUrl, "--relay-ip", address]));
}
});
test("401 is one inert POST, with no copied auth or response content", async (t) => {
let calls = 0;
let headers;
let method;
let path;
let body = "";
const { target } = await fixture(t, (req, res) => {
calls++;
headers = req.headers;
method = req.method;
path = req.url;
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () =>
res
.writeHead(401, { "Set-Cookie": secret, "X-Request-Id": secret })
.end(secret),
);
});
const result = await probeTarget(target);
assert.equal(calls, 1);
assert.equal(method, "POST");
assert.equal(path, webhookPath);
assert.equal(body, INERT_BODY);
assert.equal(headers.host, "canary.example.com:8443");
assert.equal(headers.authorization, undefined);
assert.equal(headers.cookie, undefined);
assert.equal(headers["proxy-authorization"], undefined);
assert.equal(headers["x-slack-signature"], INVALID_SIGNATURE);
assert.equal(/^v0=[a-f0-9]{64}$/u.test(headers["x-slack-signature"]), false);
assert.ok(
Math.abs(
Number(headers["x-slack-request-timestamp"]) * 1_000 - Date.now(),
) < 2_000,
);
assert.equal(result.outcome, "expected_rejection");
assert.equal(result.status, 401);
assert.equal(result.errorCode, null);
assert.equal(result.timingsMs.dns, null);
assert.equal(result.timingsMs.tls, null);
assert.ok(result.timingsMs.connect <= result.timingsMs.ttfb);
assert.ok(result.timingsMs.ttfb <= result.timingsMs.total);
assertRedacted(result);
});
for (const status of [
101, 200, 202, 204, 301, 302, 307, 308, 400, 403, 404, 429, 500, 502,
]) {
test(`HTTP ${status} is unexpected; no redirect, retry or body wait`, async (t) => {
let destinationCalls = 0;
let calls = 0;
const destination = await fixture(t, (_req, res) => {
destinationCalls++;
res.end();
});
const { target } = await fixture(t, (_req, res) => {
calls++;
res.writeHead(status, {
Location: `${destination.url}?token=${secret}`,
"Set-Cookie": secret,
});
res.flushHeaders(); // Deliberately never end a body: the probe must discard it.
});
const result = await probeTarget(target, { timeoutMs: 1_000 });
assert.equal(result.status, status);
assert.equal(result.outcome, "unexpected_status");
assert.equal(calls, 1);
assert.equal(destinationCalls, 0);
assertRedacted(result);
let output = "";
assert.equal(
await main(["--url", publicUrl], {
write: (line) => {
output += line;
},
probe: async () => result,
}),
1,
);
assertRedacted(output);
});
}
test("hard deadline destroys a real held HTTP connection instead of retrying", async (t) => {
let calls = 0;
let close;
const closed = new Promise((resolve) => {
close = resolve;
});
const { target } = await fixture(t, (req) => {
calls++;
req.socket.once("close", close);
});
const result = await probeTarget(target, { timeoutMs: 200 });
assert.equal(result.outcome, "timeout");
assert.equal(result.errorCode, "DEADLINE_EXCEEDED");
assert.equal(result.status, null);
assert.equal(result.timingsMs.ttfb, null);
await closed;
assert.equal(calls, 1);
assert.equal(TIMEOUT_MS, 8_000);
await assert.rejects(probeTarget(target, { timeoutMs: 8_001 }));
assertRedacted(result);
});
test("default eight-second deadline cancels original DNS and forbids late request", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let release4;
let release6;
let cancelled = 0;
let requests = 0;
const target = parseArgs(["--url", publicUrl]).targets[0];
const promise = probeTarget(target, {
createResolver: () => ({
resolve4: () =>
new Promise((resolve) => {
release4 = resolve;
}),
resolve6: () =>
new Promise((resolve) => {
release6 = resolve;
}),
cancel: () => {
cancelled++;
release4(["8.8.8.8"]);
release6([]);
},
}),
request: () => {
requests++;
assert.fail("late request");
},
});
t.mock.timers.tick(7_999);
assert.equal(cancelled, 0);
t.mock.timers.tick(1);
const result = await promise;
await Promise.resolve();
assert.equal(result.outcome, "timeout");
assert.equal(cancelled, 1);
assert.equal(requests, 0);
});
test("deadline also destroys an actual socket held during TLS handshake", async (t) => {
let close;
let connections = 0;
const closed = new Promise((resolve) => {
close = resolve;
});
const sockets = new Set();
const server = net.createServer((socket) => {
connections++;
sockets.add(socket);
socket.on("data", () => {}); // Consume the ClientHello, but never answer it.
socket.once("close", () => {
sockets.delete(socket);
close();
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
});
const target = parseArgs(["--url", publicUrl, "--relay-ip", "8.8.8.8"])
.targets[0];
const result = await probeTarget(target, {
timeoutMs: 200,
// Test-only transport routes the real TLS client to this owned TCP fixture.
// It does not disable TLS verification or expose a CLI private-relay option.
request: (options, callback) =>
https.request(
{
...options,
port: server.address().port,
lookup: (_hostname, _options, callback) =>
callback(null, "127.0.0.1", 4),
},
callback,
),
});
assert.equal(result.outcome, "timeout");
assert.equal(result.status, null);
assert.notEqual(result.timingsMs.connect, null);
assert.equal(result.timingsMs.tls, null);
await closed;
assert.equal(connections, 1);
assertRedacted(result);
});
test("DNS private or mixed answers fail closed before any connection", async () => {
for (const addresses of [
["100.64.0.1"],
["8.8.8.8", "127.0.0.1"],
["::ffff:127.0.0.1"],
]) {
const result = await probeTarget(
parseArgs(["--url", publicUrl]).targets[0],
{
createResolver: () => ({
resolve4: async () => addresses,
resolve6: async () => [],
cancel() {},
}),
request: () => assert.fail("private network connection"),
},
);
assert.equal(result.outcome, "unsafe_resolution");
assert.equal(result.errorCode, "NON_PUBLIC_DNS_ANSWER");
assertRedacted(result);
}
});
test("public resolution/override pins one address but preserves Host, TLS SNI and verification", async () => {
for (const override of [null, "2607:f740:f::b31"]) {
const argv = [
"--url",
publicUrl,
...(override ? ["--relay-ip", override] : []),
];
let requests = 0;
let dnsCalls = 0;
let destroyed = 0;
const result = await probeTarget(parseArgs(argv).targets[0], {
createResolver: () => ({
resolve4: async () => {
dnsCalls++;
return ["8.8.8.8", "8.8.4.4"];
},
resolve6: async () => {
dnsCalls++;
return [];
},
cancel() {},
}),
request: (options, callback) => {
requests++;
assert.equal(options.hostname, "canary.example.com");
assert.equal(options.servername, "canary.example.com");
assert.equal(options.headers.Host, "canary.example.com:8443");
assert.equal(options.path, webhookPath);
assert.equal(options.rejectUnauthorized, true);
assert.equal(options.auth, undefined);
assert.equal(options.autoSelectFamily, false);
assert.ok(options.agent instanceof https.Agent);
assert.deepEqual(options.agent.options.proxyEnv, {});
options.lookup(options.hostname, {}, (error, address) => {
assert.equal(error, null);
assert.equal(address, override ?? "8.8.8.8");
});
const req = new EventEmitter();
req.destroy = () => {
destroyed++;
};
req.end = (body) => {
assert.equal(body, "{}");
const socket = new EventEmitter();
req.emit("socket", socket);
socket.emit("connect");
socket.emit("secureConnect");
const response = new EventEmitter();
response.statusCode = 401;
response.on("newListener", (event) => assert.notEqual(event, "data"));
response.read = () => assert.fail("response body must never be read");
response.destroy = () => {};
callback(response);
};
return req;
},
});
assert.equal(requests, 1);
assert.equal(dnsCalls, override ? 0 : 2);
assert.equal(destroyed, 1);
assert.equal(result.outcome, "expected_rejection");
assert.ok(result.timingsMs.connect <= result.timingsMs.tls);
assert.ok(result.timingsMs.tls <= result.timingsMs.ttfb);
assertRedacted(result);
}
});
test("failed pinned family is not retried with another DNS answer; asynchronous errors are redacted", async () => {
let calls = 0;
let destroys = 0;
let cancelled = 0;
const result = await probeTarget(parseArgs(["--url", publicUrl]).targets[0], {
createResolver: () => ({
resolve4: async () => ["8.8.8.8", "8.8.4.4"],
resolve6: async () => ["2001:4860:4860::8888"],
cancel: () => {
cancelled++;
},
}),
request: (options) => {
calls++;
options.lookup(options.hostname, { all: true }, (error, addresses) => {
assert.equal(error, null);
assert.deepEqual(addresses, [{ address: "8.8.8.8", family: 4 }]);
});
const request = new EventEmitter();
request.destroy = () => {
destroys++;
};
request.end = () =>
queueMicrotask(() =>
request.emit(
"error",
Object.assign(new Error(`${publicUrl} ${secret}`), {
code: "ENETUNREACH",
}),
),
);
return request;
},
});
assert.equal(result.outcome, "network_error");
assert.equal(result.errorCode, "ENETUNREACH");
assert.equal(calls, 1);
assert.equal(destroys, 1);
assert.equal(cancelled, 1);
assertRedacted(result);
});
test("network/DNS exceptions expose only closed error codes, never messages or stdout/stderr", async () => {
for (const code of ["ECONNRESET", "ENOTFOUND", secret, undefined]) {
const error = Object.assign(new Error(`${publicUrl} ${secret}`), {
code,
stdout: secret,
stderr: secret,
});
const target = parseArgs(["--url", publicUrl, "--relay-ip", "8.8.8.8"])
.targets[0];
const result = await probeTarget(target, {
request: () => {
throw error;
},
});
assert.equal(result.outcome, "network_error");
assert.equal(
result.errorCode,
["ECONNRESET", "ENOTFOUND"].includes(code) ? code : "NETWORK_ERROR",
);
assertRedacted(result);
const dnsResult = await probeTarget(
parseArgs(["--url", publicUrl]).targets[0],
{
createResolver: () => ({
resolve4: async () => {
throw error;
},
resolve6: async () => {
throw error;
},
cancel() {},
}),
request: () => assert.fail("failed DNS must not connect"),
},
);
assert.equal(dnsResult.outcome, "network_error");
assertRedacted(dnsResult);
let output = "";
assert.equal(
await main(["--url", publicUrl], {
write: (line) => {
output += line;
},
probe: async () => {
throw error;
},
}),
1,
);
assertRedacted(output);
}
});
test("actual socket failure is redacted and not retried", async (t) => {
let calls = 0;
const { target } = await fixture(t, (req) => {
calls++;
req.socket.destroy();
});
const result = await probeTarget(target);
assert.equal(result.outcome, "network_error");
assert.equal(result.errorCode, "ECONNRESET");
assert.equal(calls, 1);
assertRedacted(result);
});
test("CLI child and real comparator ignore proxy/auth environments and redact bad argv", async (t) => {
let proxyCalls = 0;
const proxy = await fixture(t, (_req, res) => {
proxyCalls++;
res.end(secret);
});
const proxyUrl = proxy.url
.replace(webhookPath, "")
.replace("http://", `http://user:${secret}@`);
const env = {
...process.env,
NODE_USE_ENV_PROXY: "1",
HTTP_PROXY: proxyUrl,
HTTPS_PROXY: proxyUrl,
ALL_PROXY: proxyUrl,
http_proxy: proxyUrl,
https_proxy: proxyUrl,
NO_PROXY: "",
no_proxy: "",
PAPERCLIP_AUTH_HEADER: `Bearer ${secret}`,
PAPERCLIP_COOKIE: secret,
SLACK_BOT_TOKEN: secret,
};
for (const argv of [
[],
["--help"],
["--url", `${publicUrl}\r\nCookie: ${secret}`],
]) {
let child;
try {
child = await runFile(process.execPath, [script, ...argv], {
env,
timeout: 5_000,
});
} catch (error) {
assert.equal(error.code, 2);
child = error;
}
assert.equal(child.stderr, "");
assert.equal(child.stdout.includes(secret), false);
assert.equal(child.stdout.includes("fixture-public-id-secret"), false);
}
// Explicitly poison Node's GLOBAL agent as --use-env-proxy does. The canary's
// fresh agent must still connect straight to the one local fixture.
const originalAgent = http.globalAgent;
http.globalAgent = new http.Agent({ proxyEnv: env });
t.after(() => {
http.globalAgent.destroy();
http.globalAgent = originalAgent;
});
const direct = await fixture(t, (req, res) => {
assert.equal(req.headers.authorization, undefined);
assert.equal(req.headers.cookie, undefined);
res.writeHead(401).end();
});
assert.equal(
(await probeTarget(direct.target)).outcome,
"expected_rejection",
);
assert.equal(proxyCalls, 0);
});
test("CLI refuses native debug output before starting a request", async () => {
for (const debug of [
{ NODE_DEBUG: "http,net,tls" },
{ NODE_DEBUG_NATIVE: "TLS" },
{ NODE_OPTIONS: "--trace-tls" },
]) {
const env = {
...process.env,
NODE_DEBUG: "",
NODE_DEBUG_NATIVE: "",
NODE_OPTIONS: "",
...debug,
};
const child = await runFile(
process.execPath,
[script, "--url", publicUrl, "--relay-ip", "8.8.8.8"],
{ env, timeout: 5_000 },
).catch((error) => error);
assert.equal(child.code, 2);
assert.equal(
JSON.parse(child.stdout).errorCode,
"UNSAFE_DEBUG_ENVIRONMENT",
);
assertRedacted(child.stdout);
assertRedacted(child.stderr);
}
});
for (const flag of ["--trace_tls", "--trace-tls=true", "--trace_tls=true"]) {
for (const source of ["argv", "environment"]) {
test(`refuses ${source} TLS tracing alias ${flag} without networking`, async (t) => {
const previousArgv = process.execArgv;
const previousOptions = process.env.NODE_OPTIONS;
t.after(() => {
process.execArgv = previousArgv;
if (previousOptions === undefined) delete process.env.NODE_OPTIONS;
else process.env.NODE_OPTIONS = previousOptions;
});
if (source === "argv") process.execArgv = [...previousArgv, flag];
else process.env.NODE_OPTIONS = flag;
let calls = 0;
let output = "";
const status = await main(["--url", publicUrl], {
write: (line) => {
output += line;
},
probe: async () => {
calls++;
return { outcome: "expected_rejection" };
},
});
assert.equal(calls, 0);
assert.equal(status, 2);
assert.equal(JSON.parse(output).errorCode, "UNSAFE_DEBUG_ENVIRONMENT");
assertRedacted(output);
});
}
}
test("two explicit targets run once in order and any unexpected result fails the command", async () => {
for (const statuses of [
[401, 401],
[502, 401],
[401, 202],
]) {
const calls = [];
let output = "";
const exit = await main(
[
"--url",
publicUrl,
"--compare-url",
`http://127.0.0.1:3104${webhookPath}`,
],
{
write: (line) => {
output += line;
},
probe: async (target) => {
const status = statuses[calls.length];
calls.push(target.label);
return {
target: target.label,
status,
outcome:
status === 401 ? "expected_rejection" : "unexpected_status",
};
},
},
);
assert.deepEqual(calls, ["public", "comparison"]);
assert.equal(exit, statuses.every((status) => status === 401) ? 0 : 1);
assert.equal(output.trim().split("\n").length, 2);
assertRedacted(output);
}
});