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

397 lines
13 KiB
JavaScript

#!/usr/bin/env node
// Opt-in transport diagnostic, NOT a signed Slack event or an agent smoke test.
import { Resolver } from "node:dns/promises";
import http from "node:http";
import https from "node:https";
import { BlockList, isIP } from "node:net";
import { performance } from "node:perf_hooks";
import { pathToFileURL } from "node:url";
export const TIMEOUT_MS = 8_000;
export const INERT_BODY = "{}";
// A real v0 signature has 64 hexadecimal hash characters. This cannot match it.
export const INVALID_SIGNATURE = "v0=ingress-canary-invalid";
const USAGE = `Usage: node scripts/smoke/chat-webhook-ingress.mjs --url HTTPS_SLACK_WEBHOOK
[--compare-url HTTP_LOOPBACK_SAME_PATH] [--relay-ip PUBLIC_IP]
No arguments or --help sends nothing. The public URL must use a DNS hostname
and /api/chat-webhooks/<public-id>/slack; no credentials, query or fragment.
The optional comparator must use literal 127.0.0.1 or [::1] with the same path.
The relay override pins one public IP while retaining the public hostname/SNI.
Without an override, DNS answers must all be public. HTTP requests are not retried.
One inert, invalid-signature POST per target; 8 seconds maximum per target.
No redirects, response bodies, cookies, credentials or environment proxy use.
Clear NODE_DEBUG/NODE_DEBUG_NATIVE and TLS tracing before network diagnostics.
Only 401 is an expected rejection; other statuses are unexpected (exit 1).
A 401 is compatible with reaching the signature verifier, not proof of signed
event admission or chat quality. Correlate with local verifier logs if needed.
Timings are cumulative milliseconds from each target's start; null means unseen.
Output excludes URLs, public IDs, addresses, headers and raw error messages.
`;
class ConfigurationError extends Error {
constructor(code) {
super(code);
this.code = code;
}
}
const privateV4 = new BlockList();
for (const [address, prefix] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 3],
])
privateV4.addSubnet(address, prefix, "ipv4");
const globalV6 = new BlockList();
globalV6.addSubnet("2000::", 3, "ipv6");
const specialV6 = new BlockList();
for (const [address, prefix] of [
["2001::", 23],
["2001:db8::", 32],
["2002::", 16],
["3fff::", 20],
])
specialV6.addSubnet(address, prefix, "ipv6");
export function isPublicAddress(value) {
if (typeof value !== "string" || /[%\s]/u.test(value)) return false;
const family = isIP(value);
if (family === 4) return !privateV4.check(value, "ipv4");
return (
family === 6 &&
globalV6.check(value, "ipv6") &&
!specialV6.check(value, "ipv6")
);
}
function parseUrl(raw, comparison = false) {
// Reject parser normalization, encoded path/header tricks and even empty ?/#.
if (
typeof raw !== "string" ||
/[\s\u0000-\u001f\u007f-\u009f%\\@?#]/u.test(raw)
) {
throw new ConfigurationError("invalid_url");
}
const match = /^(https?):\/\/([^/]+)(\/.*)$/u.exec(raw);
let url;
try {
url = new URL(raw);
} catch {
throw new ConfigurationError("invalid_url");
}
if (
!match ||
match[3] !== url.pathname ||
url.port === "0" ||
!/^\/api\/chat-webhooks\/[A-Za-z0-9_-]{16,128}\/slack$/u.test(url.pathname)
) {
throw new ConfigurationError("invalid_webhook_path");
}
if (comparison) {
// Exact literals only: no host-file, DNS, alternate integer IPv4 or rebinding.
if (!/^http:\/\/(127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?\//u.test(raw)) {
throw new ConfigurationError("comparison_must_be_loopback_http");
}
} else {
if (
url.protocol !== "https:" ||
isIP(url.hostname) ||
!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(
url.hostname,
) ||
/\.(?:localhost|local|internal|invalid|test)$/u.test(url.hostname)
) {
throw new ConfigurationError("public_url_must_be_https_hostname");
}
}
return url;
}
export function parseArgs(argv) {
if (!Array.isArray(argv) || argv.some((item) => typeof item !== "string")) {
throw new ConfigurationError("invalid_arguments");
}
if (argv.length === 0 || (argv.length === 1 && argv[0] === "--help"))
return { help: true };
const values = new Map();
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
if (
!["--url", "--compare-url", "--relay-ip"].includes(key) ||
values.has(key) ||
!argv[index + 1] ||
argv[index + 1].startsWith("--")
) {
throw new ConfigurationError("invalid_arguments");
}
values.set(key, argv[index + 1]);
}
if (!values.has("--url"))
throw new ConfigurationError("explicit_public_url_required");
const publicUrl = parseUrl(values.get("--url"));
const relayIp = values.get("--relay-ip") ?? null;
if (relayIp !== null && !isPublicAddress(relayIp))
throw new ConfigurationError("unsafe_relay_ip");
const targets = [
{ label: "public", url: publicUrl, relayIp, host: publicUrl.host },
];
if (values.has("--compare-url")) {
const url = parseUrl(values.get("--compare-url"), true);
if (url.pathname !== publicUrl.pathname)
throw new ConfigurationError("comparison_path_mismatch");
targets.push({
label: "comparison",
url,
relayIp: null,
host: publicUrl.host,
});
}
return { help: false, targets };
}
const NETWORK_CODES = new Set([
"ECONNREFUSED",
"ECONNRESET",
"ENETUNREACH",
"EHOSTUNREACH",
"ETIMEDOUT",
"ENOTFOUND",
"ENODATA",
"EAI_AGAIN",
"ESERVFAIL",
"ECANCELLED",
"EPIPE",
"ERR_TLS_CERT_ALTNAME_INVALID",
"CERT_HAS_EXPIRED",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"HPE_HEADER_OVERFLOW",
"HPE_INVALID_HEADER_TOKEN",
]);
function safeNetworkCode(error) {
return NETWORK_CODES.has(error?.code) ? error.code : "NETWORK_ERROR";
}
// Injectable I/O is for local fixture tests only; the CLI always uses these native
// implementations. There is no CLI switch for TLS bypass or a private relay.
export async function probeTarget(
target,
{
createResolver = () => new Resolver({ timeout: TIMEOUT_MS, tries: 1 }),
request = target.label === "public" ? https.request : http.request,
timeoutMs = TIMEOUT_MS,
} = {},
) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > TIMEOUT_MS) {
throw new ConfigurationError("invalid_timeout");
}
const start = performance.now();
const result = {
target: target.label,
startedAt: new Date().toISOString(),
outcome: null,
status: null,
errorCode: null,
timingsMs: { dns: null, connect: null, tls: null, ttfb: null, total: null },
};
const elapsed = () => Math.round((performance.now() - start) * 1_000) / 1_000;
let resolver;
let agent;
let req;
let response;
let done = false;
return new Promise((resolve) => {
function finish(outcome, code = null) {
if (done) return;
done = true;
clearTimeout(deadline);
resolver?.cancel();
response?.destroy(); // Never consume, retain or display response content.
req?.destroy();
agent?.destroy();
result.outcome = outcome;
result.errorCode = code;
result.timingsMs.total = elapsed();
resolve(result);
}
// Covers DNS as well as TCP/TLS/header wait; destroy cancels actual I/O.
const deadline = setTimeout(
() => finish("timeout", "DEADLINE_EXCEEDED"),
timeoutMs,
);
async function send() {
let address = target.relayIp;
if (target.label === "public" && !address) {
resolver = createResolver();
const answers = await Promise.allSettled([
resolver.resolve4(target.url.hostname),
resolver.resolve6(target.url.hostname),
]);
if (done) return;
result.timingsMs.dns = elapsed();
const addresses = answers.flatMap((answer) =>
answer.status === "fulfilled" ? answer.value : [],
);
if (addresses.length === 0) {
finish(
"network_error",
safeNetworkCode(
answers.find((answer) => answer.status === "rejected")?.reason,
),
);
return;
}
if (!addresses.every(isPublicAddress)) {
finish("unsafe_resolution", "NON_PUBLIC_DNS_ANSWER");
return;
}
address = addresses[0]; // One pinned address, no fallback/retry.
}
if (done) return;
if (target.label === "comparison")
address = target.url.hostname.replace(/^\[|\]$/gu, "");
// A fresh explicit agent ignores global/env proxy agents and session caches.
const Agent = target.label === "public" ? https.Agent : http.Agent;
agent = new Agent({
keepAlive: false,
maxSockets: 1,
proxyEnv: {},
maxCachedSessions: 0,
});
function onResponse(res) {
response = res;
res.on("error", () => {}); // Raw response errors may contain private data.
if (done) {
res.destroy();
return;
}
result.status = res.statusCode ?? null;
result.timingsMs.ttfb = elapsed();
finish(
result.status === 401 ? "expected_rejection" : "unexpected_status",
);
}
req = request(
{
protocol: target.url.protocol,
hostname: target.url.hostname.replace(/^\[|\]$/gu, ""),
port: target.url.port || (target.label === "public" ? 443 : 80),
path: target.url.pathname,
method: "POST",
agent,
servername:
target.label === "public" ? target.url.hostname : undefined,
rejectUnauthorized: true,
autoSelectFamily: false,
family: isIP(address),
lookup: (_hostname, options, callback) => {
const selected = { address, family: isIP(address) };
callback(
null,
options?.all ? [selected] : address,
selected.family,
);
},
maxHeaderSize: 16_384,
headers: {
Host: target.host,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(INERT_BODY),
"X-Slack-Request-Timestamp": String(Math.floor(Date.now() / 1_000)),
"X-Slack-Signature": INVALID_SIGNATURE,
Connection: "close",
},
},
onResponse,
);
req.once("upgrade", (res, socket) => {
socket.destroy(); // A 101 is unexpected too; never enter another protocol.
onResponse(res);
});
req.on("socket", (socket) => {
socket.once("connect", () => {
if (!done) result.timingsMs.connect = elapsed();
});
socket.once("secureConnect", () => {
if (!done) result.timingsMs.tls = elapsed();
});
});
req.on("error", (error) =>
finish("network_error", safeNetworkCode(error)),
);
req.end(INERT_BODY);
}
void send().catch((error) =>
finish("network_error", safeNetworkCode(error)),
);
});
}
export async function main(
argv,
{ write = (line) => process.stdout.write(line), probe = probeTarget } = {},
) {
let config;
try {
config = parseArgs(argv);
} catch (error) {
write(
`${JSON.stringify({ outcome: "invalid_configuration", errorCode: error instanceof ConfigurationError ? error.code : "INVALID_CONFIGURATION" })}\n`,
);
return 2;
}
if (config.help) {
write(USAGE);
return 0;
}
// Core's debug logging bypasses our redaction and can include request options.
// Refuse networking rather than trying to intercept global stdout/stderr.
if (
process.env.NODE_DEBUG?.trim() ||
process.env.NODE_DEBUG_NATIVE?.trim() ||
process.execArgv.some((arg) => /^--trace[-_]tls(?:=|$)/u.test(arg)) ||
/(?:^|[\s"'])--trace[-_]tls(?:[=\s"']|$)/u.test(
process.env.NODE_OPTIONS ?? "",
)
) {
write(
`${JSON.stringify({ outcome: "invalid_configuration", errorCode: "UNSAFE_DEBUG_ENVIRONMENT" })}\n`,
);
return 2;
}
let exitCode = 0;
for (const target of config.targets) {
try {
const result = await probe(target);
write(`${JSON.stringify(result)}\n`);
if (result.outcome !== "expected_rejection") exitCode = 1;
} catch {
// Never surface subprocess/network messages, stacks, paths or caller input.
write(
`${JSON.stringify({ target: target.label, outcome: "diagnostic_error", errorCode: "DIAGNOSTIC_ERROR" })}\n`,
);
exitCode = 1;
}
}
return exitCode;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
process.exitCode = await main(process.argv.slice(2));
}