fix(http-client): validate public addresses at connect time (#2341)

This commit is contained in:
Hampus
2026-09-02 17:22:37 +02:00
committed by GitHub
parent 32dcd5ed1c
commit bcd95b2af9
4 changed files with 75 additions and 5 deletions
+2 -1
View File
@@ -12,7 +12,8 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*"
"@fluxer/constants": "workspace:*",
"undici": "^7.29.0"
},
"devDependencies": {
"@types/node": "catalog:",
+17 -2
View File
@@ -83,6 +83,7 @@ function createFetchInit(
headers: Record<string, string>,
body: string | undefined,
signal: AbortSignal,
dispatcher: NonNullable<RequestInit['dispatcher']> | undefined,
): RequestInit {
return {
method,
@@ -90,9 +91,16 @@ function createFetchInit(
body,
signal,
redirect: 'manual',
...(dispatcher ? {dispatcher} : {}),
};
}
function resolveRequestUrlPolicyDispatcher(
requestUrlPolicy: RequestUrlPolicy | undefined,
): NonNullable<RequestInit['dispatcher']> | undefined {
return (requestUrlPolicy as {dispatcher?: NonNullable<RequestInit['dispatcher']>} | undefined)?.dispatcher;
}
function isRedirectStatus(status: number): boolean {
return REDIRECT_STATUS_CODES.includes(status as (typeof REDIRECT_STATUS_CODES)[number]);
}
@@ -142,11 +150,15 @@ async function fetchWithRedirects(
let currentMethod: HttpMethod = method;
let currentBody = body;
let currentHeaders = {...headers};
const dispatcher = resolveRequestUrlPolicyDispatcher(requestUrlPolicy);
await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, {
phase: 'initial',
redirectCount: 0,
});
let response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
let response = await fetch(
currentUrl.href,
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
);
let redirectCount = 0;
while (isRedirectStatus(response.status)) {
if (redirectCount >= maxRedirects) {
@@ -174,7 +186,10 @@ async function fetchWithRedirects(
previousUrl: previousUrl.href,
});
currentUrl = nextUrl;
response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
response = await fetch(
currentUrl.href,
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
);
redirectCount = nextRedirectCount;
}
return response;
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import dns from 'node:dns';
import type {LookupFunction} from 'node:net';
import {BlockList, isIP} from 'node:net';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
import {HttpError} from '@pkgs/http_client/src/HttpError';
import {Agent} from 'undici';
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
@@ -185,6 +187,20 @@ function isBlockedIpAddress(address: string): boolean {
return true;
}
export function isPubliclyRoutableUrlShape(url: URL): boolean {
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
return false;
}
const normalizedHostname = normalizeHostname(url.hostname);
if (!normalizedHostname) {
return false;
}
if (isIP(normalizedHostname)) {
return !isBlockedIpAddress(normalizedHostname);
}
return isFqdnHostname(normalizedHostname);
}
function getPolicyErrorContext(context: RequestUrlValidationContext): string {
if (context.phase === 'redirect') {
const previous = context.previousUrl ?? 'unknown';
@@ -216,9 +232,43 @@ function deduplicateAddresses(addresses: Array<string>): Array<string> {
return deduplicated;
}
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
const lookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, {...options, all: true, verbatim: true}, (error, addresses) => {
if (error) {
callback(error, []);
return;
}
if (!allowPrivateAddresses && addresses.some((entry) => isBlockedIpAddress(entry.address))) {
callback(new Error(`Hostname ${hostname} resolved to a disallowed address`), []);
return;
}
if (options.all) {
callback(null, addresses);
return;
}
const [primary] = addresses;
if (!primary) {
callback(new Error(`Hostname ${hostname} resolved to no IP addresses`), []);
return;
}
callback(null, primary.address, primary.family);
});
};
return new Agent({
connect: {
lookup,
},
}) as unknown as NonNullable<RequestInit['dispatcher']>;
}
interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy {
dispatcher: NonNullable<RequestInit['dispatcher']>;
}
export function createPublicInternetRequestUrlPolicy(
options?: PublicInternetRequestUrlPolicyOptions,
): RequestUrlPolicy {
): PublicInternetRequestUrlPolicy {
const dnsCacheTtlMs =
typeof options?.dnsCacheTtlMs === 'number' && options.dnsCacheTtlMs > 0
? options.dnsCacheTtlMs
@@ -269,5 +319,6 @@ export function createPublicInternetRequestUrlPolicy(
}
}
}
return {validate};
const dispatcher = createBlocklistDispatcher(allowPrivateAddresses);
return {validate, dispatcher};
}