src / http / HTTPClient.ts
src / http / HTTPClient.ts
import * as dns from "dns";
import * as net from "net";
import { Agent } from "undici";
import { UrlValidator } from "../engine/normalization/UrlValidator";
import { HTTPClientError } from "../models/errors";
import { Logger } from "../utils/Logger";
export const DEFAULT_TIMEOUT_SECONDS = 30.0;
export const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
export const SENSITIVE_REDIRECT_HEADERS = new Set([
"authorization",
"cookie",
"proxy-authorization",
"x-api-key",
"x-subscription-token",
"api-key",
]);
export interface HTTPClientOptions {
/** Default request timeout in seconds (default: 30.0s). */
timeoutSeconds?: number;
/** Custom User-Agent header string. */
userAgent?: string;
/** Default HTTP headers applied to all requests. */
defaultHeaders?: Record<string, string>;
}
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | { [key: string]: JsonValue } | JsonValue[];
export type JsonObject = { [key: string]: JsonValue };
export type JsonArray = JsonValue[];
export interface RequestOptions {
/** Optional URL query parameters dictionary. */
params?: Record<string, string | number | boolean | undefined>;
/** Optional additional request headers. */
headers?: Record<string, string>;
/** Optional request timeout override in seconds. */
timeoutSeconds?: number;
/** Optional structured JSON payload. */
json?: JsonValue;
/** Optional raw body string for POST/PUT requests. */
body?: string;
}
export interface HTTPResponse<T = unknown> {
statusCode: number;
headers: Record<string, string>;
text: string;
data: T;
buffer: Buffer;
}
/**
* Modern HTTP Client with Python `httpx.Client` API parity.
* Serves as the single security boundary for Browse, validating URL syntax,
* hostnames, DNS IPs, and 3xx redirect chains.
*/
export class HTTPClient {
public readonly timeoutSeconds: number;
public readonly userAgent: string;
public readonly defaultHeaders: Record<string, string>;
constructor(options: HTTPClientOptions = {}) {
this.timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
this.defaultHeaders = {
"User-Agent": this.userAgent,
"Accept-Encoding": "gzip, deflate, br",
...(options.defaultHeaders ?? {}),
};
}
/**
* Single Security Boundary: Validates URL syntax, hostname patterns, and DNS-resolved IPs.
* Emits sanitized error messages containing hostnames without sensitive query strings.
*/
private async validateNetworkTarget(
urlStr: string,
dnsCache: Map<string, dns.LookupAddress[]>
): Promise<void> {
const validUrl = UrlValidator.validateUrl(urlStr);
if (!validUrl) {
throw new HTTPClientError(`Invalid URL syntax or unsupported protocol.`);
}
const parsed = new URL(validUrl);
const rawHostname = parsed.hostname;
const cleanHost = rawHostname.replace(/^\[|\]$/g, "").split("%")[0];
// 1. Fast Path Synchronous Hostname Check
const isPrivateHost = UrlValidator.isPrivateHostname(cleanHost);
if (isPrivateHost) {
throw new HTTPClientError(`Target host '${rawHostname}' is restricted for security.`);
}
// 2. Direct IP Check
if (net.isIP(cleanHost)) {
if (UrlValidator.isPrivateIP(cleanHost)) {
throw new HTTPClientError(`Target IP '${cleanHost}' is restricted or private.`);
}
return; // Public IP literal
}
// 3. Async DNS Resolution with Request-Scoped Cache
let addresses: dns.LookupAddress[];
const cacheKey = cleanHost.toLowerCase();
if (dnsCache.has(cacheKey)) {
addresses = dnsCache.get(cacheKey)!;
} else {
try {
addresses = await dns.promises.lookup(cleanHost, { all: true });
dnsCache.set(cacheKey, addresses);
} catch (dnsErr: any) {
throw new HTTPClientError(`DNS resolution failed for host '${cleanHost}': ${dnsErr?.message || dnsErr}`);
}
}
// Check all resolved IPv4/IPv6 addresses
for (const addr of addresses) {
if (UrlValidator.isPrivateIP(addr.address)) {
throw new HTTPClientError(
`Target host '${cleanHost}' resolves to restricted private IP '${addr.address}'.`
);
}
}
}
public async get<T = unknown>(url: string, options: RequestOptions = {}): Promise<HTTPResponse<T>> {
return this.request<T>(url, { ...options, method: "GET" });
}
public async post<T = unknown>(url: string, options: RequestOptions = {}): Promise<HTTPResponse<T>> {
const headers: Record<string, string> = { ...options.headers };
let body: string | undefined = options.body;
if (options.json !== undefined) {
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
body = JSON.stringify(options.json);
}
return this.request<T>(url, { ...options, method: "POST", headers, body });
}
public async request<T = unknown>(
url: string,
options: RequestOptions & { method?: string } = {}
): Promise<HTTPResponse<T>> {
// Request-scoped resources (automatically freed upon request completion)
const requestDnsCache = new Map<string, dns.LookupAddress[]>();
const visitedUrls = new Set<string>();
let currentUrl = this.buildUrl(url, options.params);
let currentMethod = options.method ?? "GET";
let currentBody = options.body;
let redirectCount = 0;
const MAX_REDIRECTS = 5;
const timeoutSec = options.timeoutSeconds ?? this.timeoutSeconds;
const timeoutMs = timeoutSec * 1000;
const t0 = Date.now();
const requestId = Math.random().toString(16).substring(2, 10);
const mergedHeaders: Record<string, string> = {
...this.defaultHeaders,
...options.headers,
};
// Single Security Boundary: Pre-connect network and hostname validation
await this.validateNetworkTarget(currentUrl, requestDnsCache);
visitedUrls.add(currentUrl);
let isTimeoutAborted = false;
const controller = new AbortController();
const timer = setTimeout(() => {
isTimeoutAborted = true;
const logHost = new URL(currentUrl).hostname;
Logger.error(
"[HTTPClient]",
`[Req: ${requestId}] Timeout (${timeoutSec}s) for host ${logHost}`
);
controller.abort();
}, timeoutMs);
// Per-request Undici Agent bound to this request's validated requestDnsCache
const perRequestAgent = new Agent({
connect: {
lookup: (
hostname: string,
options: object,
callback: (err: Error | null, addresses: dns.LookupAddress[]) => void
) => {
const cleanHost = hostname.replace(/^\[|\]$/g, "").split("%")[0].toLowerCase();
if (net.isIP(cleanHost)) {
return callback(null, [{ address: cleanHost, family: net.isIPv6(cleanHost) ? 6 : 4 }]);
}
const cachedAddrs = requestDnsCache.get(cleanHost);
if (cachedAddrs && cachedAddrs.length > 0) {
return callback(null, cachedAddrs);
}
return callback(
new Error(`DNS Security Error: Hostname '${cleanHost}' not found in validated request DNS cache.`),
[]
);
},
},
});
try {
let res: Response | null = null;
while (redirectCount <= MAX_REDIRECTS) {
res = await fetch(currentUrl, {
method: currentMethod,
headers: mergedHeaders,
body: currentBody,
signal: controller.signal,
redirect: "manual",
dispatcher: perRequestAgent,
} as any);
// Handle 3xx Redirects
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) break;
if (redirectCount >= MAX_REDIRECTS) {
const redirectHost = new URL(currentUrl).hostname;
throw new HTTPClientError(
`Maximum redirect limit (${MAX_REDIRECTS}) exceeded while fetching host '${redirectHost}'.`
);
}
const resolvedRedirectUrl = new URL(location, currentUrl).toString();
// Redirect Cycle Detection
if (visitedUrls.has(resolvedRedirectUrl)) {
const redirectHost = new URL(resolvedRedirectUrl).hostname;
throw new HTTPClientError(
`HTTP request resulted in a redirect loop involving host '${redirectHost}'.`
);
}
// Re-validate redirect target via HTTPClient security boundary
await this.validateNetworkTarget(resolvedRedirectUrl, requestDnsCache);
visitedUrls.add(resolvedRedirectUrl);
redirectCount++;
// Header security: strip sensitive credentials when redirecting across origins
const currentOrigin = new URL(currentUrl).origin;
const nextOrigin = new URL(resolvedRedirectUrl).origin;
if (currentOrigin !== nextOrigin) {
for (const headerKey of Object.keys(mergedHeaders)) {
if (SENSITIVE_REDIRECT_HEADERS.has(headerKey.toLowerCase())) {
delete mergedHeaders[headerKey];
}
}
}
// HTTP Method adjustment and entity-header cleanup for 301/302/303
if ([301, 302, 303].includes(res.status)) {
currentMethod = "GET";
currentBody = undefined;
for (const headerKey of Object.keys(mergedHeaders)) {
const lowerKey = headerKey.toLowerCase();
if (lowerKey === "content-type" || lowerKey === "content-length") {
delete mergedHeaders[headerKey];
}
}
}
currentUrl = resolvedRedirectUrl;
continue;
}
break;
}
if (!res) {
throw new HTTPClientError("No response received from target host.");
}
const resHeaders: Record<string, string> = {};
res.headers.forEach((val, key) => {
resHeaders[key.toLowerCase()] = val;
});
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let text = "";
try {
text = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
} catch {
text = "";
}
let data: T;
try {
data = text ? (JSON.parse(text) as T) : (null as unknown as T);
} catch {
data = text as unknown as T;
}
return {
statusCode: res.status,
headers: resHeaders,
text,
data,
buffer,
};
} catch (err: unknown) {
const elapsedMs = Date.now() - t0;
const originalError = err instanceof Error ? err : new Error(String(err));
const targetHost = new URL(currentUrl).hostname;
// Concise single-line error on terminal — always visible and sanitized (host-only)
const conciseMsg = isTimeoutAborted
? `[Req: ${requestId}] ${currentMethod} host '${targetHost}' → Timeout (${timeoutSec}s) (${elapsedMs}ms)`
: `[Req: ${requestId}] ${currentMethod} host '${targetHost}' → ${originalError.message} (${elapsedMs}ms)`;
Logger.error("[HTTPClient]", conciseMsg);
throw new HTTPClientError(
`HTTP request to host '${targetHost}' failed after ${elapsedMs}ms: ${originalError.message}`,
originalError
);
} finally {
clearTimeout(timer);
}
}
private buildUrl(
url: string,
params?: Record<string, string | number | boolean | undefined>
): string {
if (!params) return url;
const urlObj = new URL(url);
Object.entries(params).forEach(([key, val]) => {
if (val !== undefined && val !== null) {
urlObj.searchParams.append(key, String(val));
}
});
return urlObj.toString();
}
}
import * as dns from "dns";
import * as net from "net";
import { Agent } from "undici";
import { UrlValidator } from "../engine/normalization/UrlValidator";
import { HTTPClientError } from "../models/errors";
import { Logger } from "../utils/Logger";
export const DEFAULT_TIMEOUT_SECONDS = 30.0;
export const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
export const SENSITIVE_REDIRECT_HEADERS = new Set([
"authorization",
"cookie",
"proxy-authorization",
"x-api-key",
"x-subscription-token",
"api-key",
]);
export interface HTTPClientOptions {
/** Default request timeout in seconds (default: 30.0s). */
timeoutSeconds?: number;
/** Custom User-Agent header string. */
userAgent?: string;
/** Default HTTP headers applied to all requests. */
defaultHeaders?: Record<string, string>;
}
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | { [key: string]: JsonValue } | JsonValue[];
export type JsonObject = { [key: string]: JsonValue };
export type JsonArray = JsonValue[];
export interface RequestOptions {
/** Optional URL query parameters dictionary. */
params?: Record<string, string | number | boolean | undefined>;
/** Optional additional request headers. */
headers?: Record<string, string>;
/** Optional request timeout override in seconds. */
timeoutSeconds?: number;
/** Optional structured JSON payload. */
json?: JsonValue;
/** Optional raw body string for POST/PUT requests. */
body?: string;
}
export interface HTTPResponse<T = unknown> {
statusCode: number;
headers: Record<string, string>;
text: string;
data: T;
buffer: Buffer;
}
/**
* Modern HTTP Client with Python `httpx.Client` API parity.
* Serves as the single security boundary for Browse, validating URL syntax,
* hostnames, DNS IPs, and 3xx redirect chains.
*/
export class HTTPClient {
public readonly timeoutSeconds: number;
public readonly userAgent: string;
public readonly defaultHeaders: Record<string, string>;
constructor(options: HTTPClientOptions = {}) {
this.timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
this.defaultHeaders = {
"User-Agent": this.userAgent,
"Accept-Encoding": "gzip, deflate, br",
...(options.defaultHeaders ?? {}),
};
}
/**
* Single Security Boundary: Validates URL syntax, hostname patterns, and DNS-resolved IPs.
* Emits sanitized error messages containing hostnames without sensitive query strings.
*/
private async validateNetworkTarget(
urlStr: string,
dnsCache: Map<string, dns.LookupAddress[]>
): Promise<void> {
const validUrl = UrlValidator.validateUrl(urlStr);
if (!validUrl) {
throw new HTTPClientError(`Invalid URL syntax or unsupported protocol.`);
}
const parsed = new URL(validUrl);
const rawHostname = parsed.hostname;
const cleanHost = rawHostname.replace(/^\[|\]$/g, "").split("%")[0];
// 1. Fast Path Synchronous Hostname Check
const isPrivateHost = UrlValidator.isPrivateHostname(cleanHost);
if (isPrivateHost) {
throw new HTTPClientError(`Target host '${rawHostname}' is restricted for security.`);
}
// 2. Direct IP Check
if (net.isIP(cleanHost)) {
if (UrlValidator.isPrivateIP(cleanHost)) {
throw new HTTPClientError(`Target IP '${cleanHost}' is restricted or private.`);
}
return; // Public IP literal
}
// 3. Async DNS Resolution with Request-Scoped Cache
let addresses: dns.LookupAddress[];
const cacheKey = cleanHost.toLowerCase();
if (dnsCache.has(cacheKey)) {
addresses = dnsCache.get(cacheKey)!;
} else {
try {
addresses = await dns.promises.lookup(cleanHost, { all: true });
dnsCache.set(cacheKey, addresses);
} catch (dnsErr: any) {
throw new HTTPClientError(`DNS resolution failed for host '${cleanHost}': ${dnsErr?.message || dnsErr}`);
}
}
// Check all resolved IPv4/IPv6 addresses
for (const addr of addresses) {
if (UrlValidator.isPrivateIP(addr.address)) {
throw new HTTPClientError(
`Target host '${cleanHost}' resolves to restricted private IP '${addr.address}'.`
);
}
}
}
public async get<T = unknown>(url: string, options: RequestOptions = {}): Promise<HTTPResponse<T>> {
return this.request<T>(url, { ...options, method: "GET" });
}
public async post<T = unknown>(url: string, options: RequestOptions = {}): Promise<HTTPResponse<T>> {
const headers: Record<string, string> = { ...options.headers };
let body: string | undefined = options.body;
if (options.json !== undefined) {
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
body = JSON.stringify(options.json);
}
return this.request<T>(url, { ...options, method: "POST", headers, body });
}
public async request<T = unknown>(
url: string,
options: RequestOptions & { method?: string } = {}
): Promise<HTTPResponse<T>> {
// Request-scoped resources (automatically freed upon request completion)
const requestDnsCache = new Map<string, dns.LookupAddress[]>();
const visitedUrls = new Set<string>();
let currentUrl = this.buildUrl(url, options.params);
let currentMethod = options.method ?? "GET";
let currentBody = options.body;
let redirectCount = 0;
const MAX_REDIRECTS = 5;
const timeoutSec = options.timeoutSeconds ?? this.timeoutSeconds;
const timeoutMs = timeoutSec * 1000;
const t0 = Date.now();
const requestId = Math.random().toString(16).substring(2, 10);
const mergedHeaders: Record<string, string> = {
...this.defaultHeaders,
...options.headers,
};
// Single Security Boundary: Pre-connect network and hostname validation
await this.validateNetworkTarget(currentUrl, requestDnsCache);
visitedUrls.add(currentUrl);
let isTimeoutAborted = false;
const controller = new AbortController();
const timer = setTimeout(() => {
isTimeoutAborted = true;
const logHost = new URL(currentUrl).hostname;
Logger.error(
"[HTTPClient]",
`[Req: ${requestId}] Timeout (${timeoutSec}s) for host ${logHost}`
);
controller.abort();
}, timeoutMs);
// Per-request Undici Agent bound to this request's validated requestDnsCache
const perRequestAgent = new Agent({
connect: {
lookup: (
hostname: string,
options: object,
callback: (err: Error | null, addresses: dns.LookupAddress[]) => void
) => {
const cleanHost = hostname.replace(/^\[|\]$/g, "").split("%")[0].toLowerCase();
if (net.isIP(cleanHost)) {
return callback(null, [{ address: cleanHost, family: net.isIPv6(cleanHost) ? 6 : 4 }]);
}
const cachedAddrs = requestDnsCache.get(cleanHost);
if (cachedAddrs && cachedAddrs.length > 0) {
return callback(null, cachedAddrs);
}
return callback(
new Error(`DNS Security Error: Hostname '${cleanHost}' not found in validated request DNS cache.`),
[]
);
},
},
});
try {
let res: Response | null = null;
while (redirectCount <= MAX_REDIRECTS) {
res = await fetch(currentUrl, {
method: currentMethod,
headers: mergedHeaders,
body: currentBody,
signal: controller.signal,
redirect: "manual",
dispatcher: perRequestAgent,
} as any);
// Handle 3xx Redirects
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) break;
if (redirectCount >= MAX_REDIRECTS) {
const redirectHost = new URL(currentUrl).hostname;
throw new HTTPClientError(
`Maximum redirect limit (${MAX_REDIRECTS}) exceeded while fetching host '${redirectHost}'.`
);
}
const resolvedRedirectUrl = new URL(location, currentUrl).toString();
// Redirect Cycle Detection
if (visitedUrls.has(resolvedRedirectUrl)) {
const redirectHost = new URL(resolvedRedirectUrl).hostname;
throw new HTTPClientError(
`HTTP request resulted in a redirect loop involving host '${redirectHost}'.`
);
}
// Re-validate redirect target via HTTPClient security boundary
await this.validateNetworkTarget(resolvedRedirectUrl, requestDnsCache);
visitedUrls.add(resolvedRedirectUrl);
redirectCount++;
// Header security: strip sensitive credentials when redirecting across origins
const currentOrigin = new URL(currentUrl).origin;
const nextOrigin = new URL(resolvedRedirectUrl).origin;
if (currentOrigin !== nextOrigin) {
for (const headerKey of Object.keys(mergedHeaders)) {
if (SENSITIVE_REDIRECT_HEADERS.has(headerKey.toLowerCase())) {
delete mergedHeaders[headerKey];
}
}
}
// HTTP Method adjustment and entity-header cleanup for 301/302/303
if ([301, 302, 303].includes(res.status)) {
currentMethod = "GET";
currentBody = undefined;
for (const headerKey of Object.keys(mergedHeaders)) {
const lowerKey = headerKey.toLowerCase();
if (lowerKey === "content-type" || lowerKey === "content-length") {
delete mergedHeaders[headerKey];
}
}
}
currentUrl = resolvedRedirectUrl;
continue;
}
break;
}
if (!res) {
throw new HTTPClientError("No response received from target host.");
}
const resHeaders: Record<string, string> = {};
res.headers.forEach((val, key) => {
resHeaders[key.toLowerCase()] = val;
});
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let text = "";
try {
text = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
} catch {
text = "";
}
let data: T;
try {
data = text ? (JSON.parse(text) as T) : (null as unknown as T);
} catch {
data = text as unknown as T;
}
return {
statusCode: res.status,
headers: resHeaders,
text,
data,
buffer,
};
} catch (err: unknown) {
const elapsedMs = Date.now() - t0;
const originalError = err instanceof Error ? err : new Error(String(err));
const targetHost = new URL(currentUrl).hostname;
// Concise single-line error on terminal — always visible and sanitized (host-only)
const conciseMsg = isTimeoutAborted
? `[Req: ${requestId}] ${currentMethod} host '${targetHost}' → Timeout (${timeoutSec}s) (${elapsedMs}ms)`
: `[Req: ${requestId}] ${currentMethod} host '${targetHost}' → ${originalError.message} (${elapsedMs}ms)`;
Logger.error("[HTTPClient]", conciseMsg);
throw new HTTPClientError(
`HTTP request to host '${targetHost}' failed after ${elapsedMs}ms: ${originalError.message}`,
originalError
);
} finally {
clearTimeout(timer);
}
}
private buildUrl(
url: string,
params?: Record<string, string | number | boolean | undefined>
): string {
if (!params) return url;
const urlObj = new URL(url);
Object.entries(params).forEach(([key, val]) => {
if (val !== undefined && val !== null) {
urlObj.searchParams.append(key, String(val));
}
});
return urlObj.toString();
}
}