UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

329 lines (328 loc) • 10.4 kB
import { l as normalizeOptionalString } from "./string-coerce-6kR-86XC.mjs"; import { n as isHttpUrl } from "./url-protocol-OU3K-ySz.mjs"; import { a as resolveManagedProxyCaFileForUrl, r as loadManagedProxyTlsOptions } from "./managed-proxy-undici-BZEfvh5e.mjs"; import { r as createHttp1ProxyAgent } from "./undici-runtime-B6tpBayG.mjs"; import { n as fetchWithRuntimeDispatcher } from "./runtime-fetch-BRqf2mvl.mjs"; import { o as probeApnsHttp2ReachabilityViaProxy } from "./push-apns-http2-sgNcgGhQ.mjs"; import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; //#region src/infra/net/proxy/proxy-validation.ts const DEFAULT_PROXY_VALIDATION_ALLOWED_URLS = ["https://example.com/"]; const DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY = "https://api.sandbox.push.apple.com"; const DEFAULT_PROXY_VALIDATION_TIMEOUT_MS = 5e3; const DENIED_CANARY_HEADER = "x-openclaw-proxy-validation-canary"; const APNS_REACHABILITY_REASON = "InvalidProviderToken"; function validateProxyUrl(value) { if (!value) return ["proxy validation requires proxy.proxyUrl, --proxy-url, or OPENCLAW_PROXY_URL"]; if (!isHttpUrl(value)) return ["proxyUrl must use http:// or https://"]; return []; } /** Resolves validation config precedence: explicit override, config, then env. */ function resolveProxyValidationConfig(options) { const overrideUrl = normalizeOptionalString(options.proxyUrlOverride); const configUrl = normalizeOptionalString(options.config?.proxyUrl); const proxyUrl = overrideUrl ?? configUrl ?? normalizeOptionalString(options.env?.OPENCLAW_PROXY_URL); if (proxyUrl) { const enabled = Boolean(overrideUrl) || options.config?.enabled !== false; const proxyCaFile = resolveManagedProxyCaFileForUrl({ proxyUrl, config: overrideUrl ? void 0 : options.config, caFileOverride: options.proxyCaFileOverride }); return { enabled, proxyUrl, ...proxyCaFile ? { proxyCaFile } : {}, source: overrideUrl ? "override" : configUrl ? "config" : "env", errors: enabled ? validateProxyUrl(proxyUrl) : ["proxy validation is disabled by proxy.enabled=false"] }; } if (options.config?.enabled === true) return { enabled: true, source: "missing", errors: validateProxyUrl(void 0) }; return { enabled: false, source: "disabled", errors: ["proxy validation requires proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url"] }; } async function defaultProxyValidationFetchCheck({ proxyUrl, proxyTls, targetUrl, timeoutMs }) { const dispatcher = createHttp1ProxyAgent({ uri: proxyUrl, ...proxyTls ? { proxyTls } : {} }, timeoutMs); try { const response = await fetchWithRuntimeDispatcher(targetUrl, { dispatcher, redirect: "manual" }); response.body?.cancel().catch(() => void 0); return { ok: response.ok, status: response.status, deniedCanaryToken: response.headers.get(DENIED_CANARY_HEADER) ?? void 0 }; } finally { await dispatcher.close(); } } async function defaultProxyValidationApnsCheck({ proxyUrl, proxyTls, authority, timeoutMs }) { const result = await probeApnsHttp2ReachabilityViaProxy({ proxyUrl, ...proxyTls ? { proxyTls } : {}, authority, timeoutMs }); return { status: result.status, apnsId: result.responseHeaders?.["apns-id"], apnsReason: parseApnsErrorReason(result.body) }; } function parseApnsErrorReason(body) { try { const parsed = JSON.parse(body); if (!parsed || typeof parsed !== "object") return; const reason = parsed.reason; return typeof reason === "string" && reason.trim() ? reason : void 0; } catch { return; } } function hasApnsReachabilityProof(result) { if (result.apnsId) return true; return result.status === 403 && result.apnsReason === APNS_REACHABILITY_REASON; } function normalizeTimeoutMs(value) { if (value === void 0 || !Number.isFinite(value) || value <= 0) return DEFAULT_PROXY_VALIDATION_TIMEOUT_MS; return Math.floor(value); } function closeServer(server) { return new Promise((resolve, reject) => { server.close((err) => { if (err) { reject(err); return; } resolve(); }); }); } async function createLoopbackValidationCanary() { const token = randomUUID(); const server = createServer((_request, response) => { response.writeHead(204, { [DENIED_CANARY_HEADER]: token, "cache-control": "no-store" }); response.end(); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address(); if (typeof address === "string" || address === null) { await closeServer(server); throw new Error("Unable to start loopback proxy validation canary"); } return { url: `http://127.0.0.1:${address.port}/`, token, close: () => closeServer(server) }; } /** Probes the active runtime route, independently of explicit proxy-denial checks. */ async function probeManagedProxyLoopback(options) { const config = resolveProxyValidationConfig(options); if (!config.enabled || !config.proxyUrl || config.errors.length > 0) return null; const canary = await createLoopbackValidationCanary(); try { const response = await fetchWithRuntimeDispatcher(canary.url, { redirect: "manual", signal: AbortSignal.timeout(DEFAULT_PROXY_VALIDATION_TIMEOUT_MS) }); response.body?.cancel().catch(() => void 0); return response.ok && response.headers.get(DENIED_CANARY_HEADER) === canary.token; } catch { return false; } finally { await canary.close(); } } async function resolveDeniedTargets(deniedUrls) { if (deniedUrls !== void 0) return { targets: deniedUrls.map((url) => ({ url, transportErrorMeansBlocked: false })), close: async () => void 0 }; const canary = await createLoopbackValidationCanary(); return { targets: [{ url: canary.url, expectedCanaryToken: canary.token, transportErrorMeansBlocked: true }], close: canary.close }; } async function runValidationCheck(kind, url, run) { try { return { kind, url, ...await run() }; } catch (err) { return { kind, url, ok: false, error: err instanceof Error ? err.message : String(err) }; } } async function runAllowedCheck(params) { if (!isHttpUrl(params.url)) return { ok: false, error: "Invalid allowed destination URL" }; const result = await params.fetchCheck({ proxyUrl: params.proxyUrl, ...params.proxyTls ? { proxyTls: params.proxyTls } : {}, targetUrl: params.url, timeoutMs: params.timeoutMs }); if (!result.ok) return { ok: false, status: result.status, error: `Allowed destination returned HTTP ${result.status}` }; return { ok: true, status: result.status }; } async function runDeniedCheck(params) { if (!isHttpUrl(params.target.url)) return { ok: false, error: "Invalid denied destination URL" }; try { const result = await params.fetchCheck({ proxyUrl: params.proxyUrl, ...params.proxyTls ? { proxyTls: params.proxyTls } : {}, targetUrl: params.target.url, timeoutMs: params.timeoutMs }); if (params.target.expectedCanaryToken !== void 0 && result.deniedCanaryToken !== params.target.expectedCanaryToken) { if (result.ok) return { ok: false, status: result.status, error: `Denied loopback canary returned HTTP ${result.status} without the validation token` }; return { ok: true, status: result.status }; } return { ok: false, status: result.status, error: params.target.expectedCanaryToken === void 0 ? `Denied destination returned HTTP ${result.status}; expected the proxy to block the connection` : `Denied loopback canary was reachable through the proxy with HTTP ${result.status}` }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (params.target.transportErrorMeansBlocked) return { ok: true, error: message }; return { ok: false, error: `Denied destination failed without a verifiable proxy-deny signal: ${message}` }; } } async function runApnsReachabilityCheck(params) { const result = await params.apnsCheck({ proxyUrl: params.proxyUrl, ...params.proxyTls ? { proxyTls: params.proxyTls } : {}, authority: params.authority, timeoutMs: params.timeoutMs }); if (!hasApnsReachabilityProof(result)) return { ok: false, error: "APNs reachability check failed: response did not include an apns-id header or APNs InvalidProviderToken body. The proxy may be intercepting the connection instead of tunneling it." }; return { ok: true, status: result.status }; } /** Runs allowed, denied, and optional APNs proxy validation probes. */ async function runProxyValidation(options) { const config = resolveProxyValidationConfig(options); if (config.errors.length > 0 || !config.proxyUrl) return { ok: false, config, checks: [] }; const timeoutMs = normalizeTimeoutMs(options.timeoutMs); let proxyTls; try { proxyTls = await loadManagedProxyTlsOptions(config.proxyCaFile); } catch (err) { return { ok: false, config: { ...config, errors: [...config.errors, err instanceof Error ? err.message : String(err)] }, checks: [] }; } const fetchCheck = options.fetchCheck ?? defaultProxyValidationFetchCheck; const apnsCheck = options.apnsCheck ?? defaultProxyValidationApnsCheck; const apnsAuthority = options.apnsAuthority ?? DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY; const allowedUrls = options.allowedUrls ?? DEFAULT_PROXY_VALIDATION_ALLOWED_URLS; const deniedTargets = await resolveDeniedTargets(options.deniedUrls); const checks = []; const proxyOptions = { proxyUrl: config.proxyUrl, proxyTls, timeoutMs }; try { for (const url of allowedUrls) checks.push(await runValidationCheck("allowed", url, () => runAllowedCheck({ ...proxyOptions, url, fetchCheck }))); for (const target of deniedTargets.targets) checks.push(await runValidationCheck("denied", target.url, () => runDeniedCheck({ ...proxyOptions, target, fetchCheck }))); if (options.apnsReachability === true) checks.push(await runValidationCheck("apns", apnsAuthority, () => runApnsReachabilityCheck({ ...proxyOptions, authority: apnsAuthority, apnsCheck }))); } finally { await deniedTargets.close(); } return { ok: checks.every((check) => check.ok), config, checks }; } //#endregion export { runProxyValidation as n, probeManagedProxyLoopback as t };