294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
import http from "node:http";
|
|
import https from "node:https";
|
|
import { analyzeHeaders, type HeaderMap } from "./headers";
|
|
import { createDeadline, remainingMs, timeoutError, withDeadline } from "./deadline";
|
|
import { InspectionInputError, InspectionPolicyError, InspectionResolutionError, normalizeHost, resolvePublicHostDetailed, validateResolvedAddresses } from "./policy";
|
|
import { CERTSCOPE_VERSION } from "../version";
|
|
|
|
const HEADER_NAMES = [
|
|
"strict-transport-security",
|
|
"content-security-policy",
|
|
"x-content-type-options",
|
|
"referrer-policy",
|
|
"x-frame-options",
|
|
"server",
|
|
"x-powered-by",
|
|
];
|
|
export const HTTP_TIMEOUT_MS = 5_000;
|
|
export const HTTP_TOTAL_TIMEOUT_MS = 15_000;
|
|
export const DEFAULT_MAX_REDIRECTS = 3;
|
|
export const MAX_ADDRESS_ATTEMPTS = 6;
|
|
export const HTTPS_DOWNGRADE_ERROR = "HTTPS redirect downgrade is not allowed.";
|
|
export const REDIRECT_LOOP_ERROR = "Redirect loop detected.";
|
|
|
|
export type HttpHop = Record<string, unknown>;
|
|
type Requester = (url: URL, deadline: number) => Promise<HttpHop>;
|
|
export type CandidateRequester = (url: URL, address: string, deadline: number) => Promise<HttpHop>;
|
|
export interface HttpRequestOptions {
|
|
resolve?: typeof resolvePublicHostDetailed;
|
|
request?: CandidateRequester;
|
|
}
|
|
|
|
function safeUrl(value: string | URL) {
|
|
const text = typeof value === "string" ? value : value.toString();
|
|
try {
|
|
const parsed = new URL(text);
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
return parsed.toString();
|
|
} catch {
|
|
return text.replace(/(\/\/)[^/\s@]+@/g, "$1[redacted]@");
|
|
}
|
|
}
|
|
|
|
function sanitizedHop(hop: HttpHop): HttpHop {
|
|
return typeof hop.location === "string" ? { ...hop, location: safeUrl(hop.location) } : hop;
|
|
}
|
|
|
|
export function validateProbeUrl(url: URL) {
|
|
if (!["http:", "https:"].includes(url.protocol)) throw new InspectionPolicyError("Redirect used an unsupported protocol.");
|
|
if (url.username || url.password) throw new InspectionPolicyError("Redirect URLs with credentials are not allowed.");
|
|
if (url.port && url.port !== (url.protocol === "https:" ? "443" : "80")) {
|
|
throw new InspectionPolicyError("Redirect to a non-default port is not allowed.");
|
|
}
|
|
normalizeHost(url.hostname);
|
|
}
|
|
|
|
function errorCode(error: unknown) {
|
|
return typeof error === "object" && error !== null && "code" in error
|
|
? String((error as { code?: unknown }).code ?? "")
|
|
: "";
|
|
}
|
|
|
|
function transportError(error: unknown) {
|
|
if (error instanceof InspectionInputError || error instanceof InspectionPolicyError || error instanceof InspectionResolutionError) return error.message;
|
|
if (errorCode(error) === "ETIMEOUT") return "HTTP request timed out.";
|
|
const code = errorCode(error);
|
|
return code ? "HTTP request failed (" + code + ")." : "HTTP request failed.";
|
|
}
|
|
|
|
function requestCandidate(url: URL, address: string, deadline: number): Promise<HttpHop> {
|
|
const budget = Math.min(HTTP_TIMEOUT_MS, remainingMs(deadline));
|
|
if (!budget) return Promise.reject(timeoutError("HTTP request timed out."));
|
|
const host = normalizeHost(url.hostname);
|
|
const client = url.protocol === "https:" ? https : http;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
// The request constructor can throw before a timer exists; the callback
|
|
// therefore needs an intentionally late-bound timer reference.
|
|
// eslint-disable-next-line prefer-const
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const finish = (callback: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) clearTimeout(timer);
|
|
callback();
|
|
};
|
|
|
|
let request: ReturnType<typeof client.request>;
|
|
try {
|
|
request = client.request({
|
|
hostname: address,
|
|
port: url.protocol === "https:" ? 443 : 80,
|
|
path: url.pathname + url.search,
|
|
method: "GET",
|
|
servername: url.protocol === "https:" ? host : undefined,
|
|
rejectUnauthorized: url.protocol === "https:" ? true : undefined,
|
|
headers: { host, "user-agent": "CertScope/" + CERTSCOPE_VERSION },
|
|
timeout: budget,
|
|
}, (response) => {
|
|
if (settled) {
|
|
response.destroy();
|
|
return;
|
|
}
|
|
const headers = Object.fromEntries(
|
|
HEADER_NAMES.map((name) => [name, response.headers[name] ?? null]),
|
|
) as HeaderMap;
|
|
finish(() => {
|
|
response.destroy();
|
|
resolve({
|
|
url: url.toString(),
|
|
address,
|
|
status: response.statusCode ?? null,
|
|
location: response.headers.location ?? null,
|
|
headers,
|
|
headerPosture: analyzeHeaders(headers),
|
|
});
|
|
});
|
|
});
|
|
} catch (error) {
|
|
finish(() => reject(error));
|
|
return;
|
|
}
|
|
|
|
const abortOnTimeout = () => finish(() => {
|
|
request.destroy();
|
|
reject(timeoutError("HTTP request timed out."));
|
|
});
|
|
timer = setTimeout(abortOnTimeout, budget);
|
|
request.once("timeout", abortOnTimeout);
|
|
request.once("error", (error) => finish(() => reject(error)));
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
export async function requestOnce(url: URL, deadline = createDeadline(HTTP_TIMEOUT_MS), options: HttpRequestOptions = {}): Promise<HttpHop> {
|
|
validateProbeUrl(url);
|
|
const resolution = await (options.resolve ?? resolvePublicHostDetailed)(url.hostname, deadline);
|
|
const addresses = validateResolvedAddresses(resolution.addresses);
|
|
const request = options.request ?? requestCandidate;
|
|
const attempts: Array<Record<string, unknown>> = [];
|
|
for (const address of addresses.slice(0, MAX_ADDRESS_ATTEMPTS)) {
|
|
if (!remainingMs(deadline)) break;
|
|
try {
|
|
const result = await request(url, address, deadline);
|
|
return { ...result, address: result.address ?? address, addresses, resolution: resolution._meta, attempts };
|
|
} catch (error) {
|
|
attempts.push({ address, error: transportError(error) });
|
|
}
|
|
}
|
|
const message = remainingMs(deadline) ? "HTTP request failed for all resolved addresses." : "HTTP probe deadline exhausted.";
|
|
return { url: safeUrl(url), addresses, attempts, error: message, resolution: resolution._meta };
|
|
}
|
|
|
|
async function safeRequest(url: URL, requester: Requester, deadline: number, markPolicyBlock = false): Promise<HttpHop> {
|
|
try {
|
|
validateProbeUrl(url);
|
|
if (!remainingMs(deadline)) return { url: safeUrl(url), error: "HTTP probe deadline exhausted." };
|
|
return await withDeadline(requester(url, deadline), deadline, () => undefined, "HTTP request timed out.");
|
|
} catch (error) {
|
|
return {
|
|
url: safeUrl(url),
|
|
error: transportError(error),
|
|
...(markPolicyBlock && (error instanceof InspectionInputError || error instanceof InspectionPolicyError)
|
|
? { code: "REDIRECT_TARGET_BLOCKED" }
|
|
: {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
function normalizedUrlKey(url: URL) {
|
|
const host = normalizeHost(url.hostname);
|
|
const port = url.port && url.port !== (url.protocol === "https:" ? "443" : "80") ? ":" + url.port : "";
|
|
return url.protocol.toLowerCase() + "//" + host + port + (url.pathname || "/") + url.search;
|
|
}
|
|
|
|
export async function followRedirectChain(
|
|
start: URL,
|
|
requester: Requester = requestOnce,
|
|
maxRedirects = DEFAULT_MAX_REDIRECTS,
|
|
deadline = createDeadline(HTTP_TOTAL_TIMEOUT_MS),
|
|
): Promise<HttpHop[]> {
|
|
const chain: HttpHop[] = [];
|
|
const redirectLimit = Math.max(0, Math.floor(maxRedirects));
|
|
let current = start;
|
|
const visited = new Set<string>();
|
|
|
|
for (let index = 0; index <= redirectLimit; index++) {
|
|
let key: string;
|
|
try {
|
|
key = normalizedUrlKey(current);
|
|
} catch (error) {
|
|
chain.push({ url: safeUrl(current), error: error instanceof Error ? error.message : "Invalid redirect target." });
|
|
break;
|
|
}
|
|
if (visited.has(key)) {
|
|
chain.push({ url: current.toString(), error: REDIRECT_LOOP_ERROR, code: "REDIRECT_LOOP" });
|
|
break;
|
|
}
|
|
visited.add(key);
|
|
|
|
const hop = await safeRequest(current, requester, deadline, index > 0);
|
|
chain.push(sanitizedHop(hop));
|
|
if ("error" in hop) break;
|
|
|
|
const status = Number(hop.status ?? 0);
|
|
const location = typeof hop.location === "string" ? hop.location : null;
|
|
if (status < 300 || status >= 400 || !location) break;
|
|
if (index === redirectLimit) {
|
|
chain.push({ url: current.toString(), error: "Redirect limit reached", code: "REDIRECT_LIMIT" });
|
|
break;
|
|
}
|
|
|
|
try {
|
|
const next = new URL(location, current);
|
|
validateProbeUrl(next);
|
|
if (current.protocol === "https:" && next.protocol === "http:") {
|
|
chain.push({ url: next.toString(), error: HTTPS_DOWNGRADE_ERROR, code: "HTTPS_DOWNGRADE", from: current.toString() });
|
|
break;
|
|
}
|
|
if (visited.has(normalizedUrlKey(next))) {
|
|
chain.push({ url: next.toString(), error: REDIRECT_LOOP_ERROR, code: "REDIRECT_LOOP", from: current.toString() });
|
|
break;
|
|
}
|
|
current = next;
|
|
} catch (error) {
|
|
chain.push({
|
|
url: safeUrl(location),
|
|
error: error instanceof Error ? error.message : "Invalid redirect target.",
|
|
code: error instanceof InspectionInputError || error instanceof InspectionPolicyError ? "REDIRECT_TARGET_BLOCKED" : "INVALID_REDIRECT",
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
return chain;
|
|
}
|
|
|
|
function errorEvidence(hop: HttpHop, probe: string) {
|
|
return "error" in hop ? { probe, message: String(hop.error) } : null;
|
|
}
|
|
|
|
export async function collectHttp(host: string): Promise<Record<string, unknown>> {
|
|
const normalizedHost = normalizeHost(host);
|
|
const deadline = createDeadline(HTTP_TOTAL_TIMEOUT_MS);
|
|
const rawHttpResult = await safeRequest(new URL("http://" + normalizedHost + "/"), requestOnce, deadline);
|
|
const httpResult = sanitizedHop(rawHttpResult);
|
|
let httpRedirectsToHttps = false;
|
|
let httpRedirectTarget: HttpHop | null = null;
|
|
|
|
if (!("error" in rawHttpResult)) {
|
|
const status = Number(rawHttpResult.status ?? 0);
|
|
const location = typeof rawHttpResult.location === "string" ? rawHttpResult.location : null;
|
|
if (status >= 300 && status < 400 && location) {
|
|
try {
|
|
const target = new URL(location, new URL("http://" + normalizedHost + "/"));
|
|
validateProbeUrl(target);
|
|
const resolution = await resolvePublicHostDetailed(target.hostname, deadline);
|
|
httpRedirectsToHttps = target.protocol === "https:";
|
|
httpRedirectTarget = { url: target.toString(), addresses: resolution.addresses, resolution: resolution._meta, validated: true };
|
|
} catch (error) {
|
|
httpRedirectTarget = {
|
|
url: safeUrl(location),
|
|
error: error instanceof Error ? error.message : "Invalid redirect target.",
|
|
code: error instanceof InspectionInputError || error instanceof InspectionPolicyError ? "REDIRECT_TARGET_BLOCKED" : "INVALID_REDIRECT",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
const httpsChain = await followRedirectChain(new URL("https://" + normalizedHost + "/"), requestOnce, DEFAULT_MAX_REDIRECTS, deadline);
|
|
const finalHop = httpsChain.at(-1) && !("error" in httpsChain.at(-1)!) ? httpsChain.at(-1)! : null;
|
|
const downgrade = httpsChain.some((hop) => hop.code === "HTTPS_DOWNGRADE");
|
|
const evidenceErrors = [
|
|
errorEvidence(httpResult, "HTTP"),
|
|
...(httpRedirectTarget && "error" in httpRedirectTarget
|
|
? [{ probe: "HTTP redirect", message: String(httpRedirectTarget.error) }]
|
|
: []),
|
|
...httpsChain.map((hop) => errorEvidence(hop, "HTTPS")),
|
|
].filter((item): item is { probe: string; message: string } => Boolean(item));
|
|
const hasSuccessfulObservation = ![httpResult, ...httpsChain].every((hop) => "error" in hop);
|
|
const status = evidenceErrors.length ? (hasSuccessfulObservation ? "partial" : "failed") : "ok";
|
|
|
|
return {
|
|
http: httpResult,
|
|
httpRedirectsToHttps,
|
|
httpRedirectTarget,
|
|
httpsChain,
|
|
httpsDowngrade: downgrade,
|
|
finalUrl: finalHop && typeof finalHop.url === "string" ? finalHop.url : null,
|
|
_meta: { status, errors: evidenceErrors },
|
|
};
|
|
}
|