diff --git a/fluxer_api/pkgs/http_client/package.json b/fluxer_api/pkgs/http_client/package.json index f0a5be54a..ededf43c9 100644 --- a/fluxer_api/pkgs/http_client/package.json +++ b/fluxer_api/pkgs/http_client/package.json @@ -12,7 +12,8 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@fluxer/constants": "workspace:*" + "@fluxer/constants": "workspace:*", + "undici": "^7.29.0" }, "devDependencies": { "@types/node": "catalog:", diff --git a/fluxer_api/pkgs/http_client/src/HttpClient.ts b/fluxer_api/pkgs/http_client/src/HttpClient.ts index ff3c52670..9ae503b1a 100644 --- a/fluxer_api/pkgs/http_client/src/HttpClient.ts +++ b/fluxer_api/pkgs/http_client/src/HttpClient.ts @@ -83,6 +83,7 @@ function createFetchInit( headers: Record, body: string | undefined, signal: AbortSignal, + dispatcher: NonNullable | undefined, ): RequestInit { return { method, @@ -90,9 +91,16 @@ function createFetchInit( body, signal, redirect: 'manual', + ...(dispatcher ? {dispatcher} : {}), }; } +function resolveRequestUrlPolicyDispatcher( + requestUrlPolicy: RequestUrlPolicy | undefined, +): NonNullable | undefined { + return (requestUrlPolicy as {dispatcher?: NonNullable} | 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; diff --git a/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts b/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts index 9502951a4..e659a92c9 100644 --- a/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts +++ b/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts @@ -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): Array { return deduplicated; } +function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable { + 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; +} + +interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy { + dispatcher: NonNullable; +} + 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}; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e7aedcc3..dd62d5c5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -813,6 +813,9 @@ importers: '@fluxer/constants': specifier: workspace:* version: link:../../../packages/constants + undici: + specifier: ^7.29.0 + version: 7.29.0 devDependencies: '@types/node': specifier: 'catalog:'