mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(http-client): validate public addresses at connect time (#2341)
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*"
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"undici": "^7.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user