diff --git a/package.json b/package.json index 126d6dbb7..b3507cd28 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@fastify/cors": "^9.0.1", "@fastify/swagger": "^8.15.0", "@fastify/swagger-ui": "^4.1.0", - "@kynesyslabs/demosdk": "^2.3.17", + "@kynesyslabs/demosdk": "^2.3.22", "@modelcontextprotocol/sdk": "^1.13.3", "@octokit/core": "^6.1.5", "@types/express": "^4.17.21", diff --git a/src/features/web2/dahr/DAHR.ts b/src/features/web2/dahr/DAHR.ts index a15aa0910..81da47315 100644 --- a/src/features/web2/dahr/DAHR.ts +++ b/src/features/web2/dahr/DAHR.ts @@ -8,6 +8,7 @@ import { ProxyFactory } from "src/features/web2/proxy/ProxyFactory" import required from "src/utilities/required" import { generateUniqueId } from "src/utilities/generateUniqueId" import { EnumWeb2Actions } from "@kynesyslabs/demosdk/types" +import { validateAndNormalizeHttpUrl } from "src/features/web2/validator" /** * DAHR - Data Agnostic HTTPS Relay, class that handles the Web2 request and proxy process. @@ -70,13 +71,21 @@ export class DAHR { // Make sure we have a web2Request at this point required(this._web2Request, "web2Request") + // Validate and normalize URL without echoing sensitive details + const validation = validateAndNormalizeHttpUrl(url) + if (!validation.ok) { + const err = new Error(validation.message) + ;(err as any).status = validation.status + throw err + } + const web2Response = await this._proxy.sendHTTPRequest({ web2Request: { ...this._web2Request, raw: { ...this._web2Request.raw, action: EnumWeb2Actions.START_PROXY, - url, + url: validation.normalizedUrl, }, }, targetMethod: method, diff --git a/src/features/web2/proxy/Proxy.ts b/src/features/web2/proxy/Proxy.ts index c23247878..bd369fe1b 100644 --- a/src/features/web2/proxy/Proxy.ts +++ b/src/features/web2/proxy/Proxy.ts @@ -3,6 +3,7 @@ import http from "http" import httpProxy from "http-proxy" import { URL } from "url" import net from "net" +import dns from "node:dns/promises" import { IWeb2Request, IWeb2Result, @@ -182,7 +183,80 @@ export class Proxy { private async createNewServer(targetUrl: string): Promise { return new Promise((resolve, reject) => { - const { targetProtocol } = this.parseUrl(targetUrl) + const { targetProtocol, targetHostname } = this.parseUrl(targetUrl) + + // SSRF hardening: resolve DNS and block private/link-local/loopback destinations + const isDisallowedAddress = (addr: string): boolean => { + const lower = addr.toLowerCase() + const ipVersion = net.isIP(lower) + + // Helper for IPv4 space + const isDisallowedV4 = (v4: string): boolean => { + if (/^127(?:\.\d{1,3}){3}$/.test(v4)) return true // loopback + if (/^10\./.test(v4)) return true // private + const m = v4.match(/^172\.(\d{1,3})\./) + if (m) { + const o = Number(m[1]) + if (o >= 16 && o <= 31) return true + } + if (/^192\.168\./.test(v4)) return true // private + if (/^169\.254\./.test(v4)) return true // link-local + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(v4)) + return true // CGNAT 100.64/10 + if (/^0\./.test(v4)) return true // this network + if (/^(?:22[4-9]|23\d)\./.test(v4)) return true // multicast 224/4 + if (/^(?:24\d|25[0-5])\./.test(v4)) return true // reserved 240/4 incl 255.255.255.255 + return false + } + + if (ipVersion === 6) { + if (lower === "::" || lower === "::1") return true // unspecified/loopback + if (lower.startsWith("ff")) return true // multicast ff00::/8 + // ULA fc00::/7 + if (lower.startsWith("fc") || lower.startsWith("fd")) + return true + // Link-local fe80::/10 → fe8x, fe9x, feax, febx + if (/^fe[89ab][0-9a-f]*:/i.test(lower)) return true + // IPv4-mapped IPv6 ::ffff:a.b.c.d → re-check mapped v4 + const v4map = lower.match( + /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/, + ) + if (v4map && isDisallowedV4(v4map[1])) return true + return false + } + + if (ipVersion === 4) { + return isDisallowedV4(lower) + } + return false + } + + const preflight = async () => { + try { + // If hostname is already an IP, just check it; otherwise resolve all + const ipVersion = net.isIP(targetHostname) + if (ipVersion) { + if (isDisallowedAddress(targetHostname)) { + throw new Error( + "Target resolves to a private/link-local/loopback address", + ) + } + } else { + const answers = await dns.lookup(targetHostname, { + all: true, + }) + if (answers.some(a => isDisallowedAddress(a.address))) { + throw new Error( + "Target resolves to a private/link-local/loopback address", + ) + } + } + } catch (e) { + reject(e) + return false + } + return true + } // Create the proxy server (defaults; per-request options are supplied in proxyServer.web) const proxyServer = httpProxy.createProxyServer({ @@ -254,13 +328,21 @@ export class Proxy { }) // Create the main HTTP server - this._server = http.createServer((req, res) => { + this._server = http.createServer(async (req, res) => { if (!this.isAuthorizedRequest(req)) { res.writeHead(403) res.end("Unauthorized") return } + // Ensure target is still safe at request time (DNS may have changed) + const ok = await preflight() + if (!ok) { + res.writeHead(400) + res.end("Invalid target host") + return + } + const { targetPathname, targetSearch, targetOrigin } = this.parseUrl(targetUrl) const outgoingPath = targetPathname + targetSearch diff --git a/src/features/web2/validator.ts b/src/features/web2/validator.ts new file mode 100644 index 000000000..df35bb674 --- /dev/null +++ b/src/features/web2/validator.ts @@ -0,0 +1,146 @@ +export type UrlValidationResult = + | { ok: true; normalizedUrl: string } + | { ok: false; status: 400; message: string } + +/** + * Validate and normalize a URL for DAHR. + * - Trims whitespace + * - Ensures protocol is http(s) + * - Rejects URLs with embedded credentials (username/password) + * - Rejects URLs without a hostname + * - Rejects localhost and loopback IP addresses/hostnames (SSRF protection) + * - Lowercases host, strips default ports, and removes fragments for canonicalization + * - Redacts sensitive data in error messages (does not echo the full URL) + */ +import net from "node:net" + +export function validateAndNormalizeHttpUrl( + input: string, +): UrlValidationResult { + const trimmed = (input ?? "").trim() + if (!trimmed) { + return { ok: false, status: 400, message: "Invalid URL: empty value" } + } + try { + const parsed = new URL(trimmed) + + // 1. Ensure protocol is http(s) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { + ok: false, + status: 400, + message: "Invalid URL scheme. Only http(s) are allowed", + } + } + + // 2. Reject URLs with embedded credentials (username/password) + if (parsed.username || parsed.password) { + return { + ok: false, + status: 400, + message: "Invalid URL: embedded credentials are not allowed", + } + } + + // 3. Reject URLs without a hostname + if (!parsed.hostname) { + return { + ok: false, + status: 400, + message: "Invalid URL: URL must have a hostname", + } + } + + const hostLower = parsed.hostname.toLowerCase() + + // 4. Reject localhost and loopback hostnames + if (hostLower === "localhost" || hostLower.endsWith(".localhost")) { + return { + ok: false, + status: 400, + message: "Localhost targets are not allowed", + } + } + + // 5. Block loopback and private/link-local/reserved ranges (IPv4, IPv6, and IPv4-mapped IPv6) + const ipVersion = net.isIP(hostLower) + const isIPv6Unspecified = hostLower === "::" + const isIPv6Loopback = hostLower === "::1" + const isIPv6Multicast = hostLower.startsWith("ff") + const isIPv4MappedLoopback = hostLower.startsWith("::ffff:127.") + const isIPv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(hostLower) + const isIPv4Private = + /^10\./.test(hostLower) || + (/^172\.(\d{1,3})\./.test(hostLower) && + (() => { + const m = hostLower.match(/^172\.(\d{1,3})\./) + if (!m) return false + const o = Number(m[1]) + return o >= 16 && o <= 31 + })()) || + /^192\.168\./.test(hostLower) || + /^169\.254\./.test(hostLower) || + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(hostLower) || // 100.64.0.0/10 + /^0\./.test(hostLower) + const isIPv6ULAorLL = + ipVersion === 6 && + (hostLower.startsWith("fc") || + hostLower.startsWith("fd") || + /^fe[89ab][0-9a-f]*:/i.test(hostLower)) + // Also block IPv4-mapped private ranges (::ffff:10.x, ::ffff:172.16-31.x, ::ffff:192.168.x, ::ffff:169.254.x, ::ffff:100.64-127.x) + const mappedMatch = hostLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/) + let isMappedPrivate = false + if (mappedMatch) { + const v4 = mappedMatch[1] + if (/^127(?:\.\d{1,3}){3}$/.test(v4)) isMappedPrivate = true + if (/^10\./.test(v4)) isMappedPrivate = true + const m172 = v4.match(/^172\.(\d{1,3})\./) + if (m172) { + const o = Number(m172[1]) + if (o >= 16 && o <= 31) isMappedPrivate = true + } + if (/^192\.168\./.test(v4)) isMappedPrivate = true + if (/^169\.254\./.test(v4)) isMappedPrivate = true + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(v4)) + isMappedPrivate = true + if (/^0\./.test(v4)) isMappedPrivate = true + } + if ( + isIPv6Unspecified || + isIPv6Loopback || + isIPv6Multicast || + isIPv4MappedLoopback || + isIPv4Private || + isMappedPrivate || + isIPv6ULAorLL + ) { + return { + ok: false, + status: 400, + message: + "Private, link-local, or loopback targets are not allowed", + } + } + + // 6. Canonicalize the URL (lowercase host, strip default ports, remove fragment) + const canonicalUrlObject = new URL(parsed.toString()) + canonicalUrlObject.hostname = canonicalUrlObject.hostname.toLowerCase() + + // Strip default ports + if ( + (canonicalUrlObject.protocol === "http:" && + canonicalUrlObject.port === "80") || + (canonicalUrlObject.protocol === "https:" && + canonicalUrlObject.port === "443") + ) { + canonicalUrlObject.port = "" + } + + // Remove fragment + canonicalUrlObject.hash = "" + + return { ok: true, normalizedUrl: canonicalUrlObject.toString() } + } catch { + return { ok: false, status: 400, message: "Invalid URL format" } + } +} diff --git a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts index f63d1a2ad..3d9fd6125 100644 --- a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts +++ b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts @@ -6,6 +6,7 @@ import { } from "@kynesyslabs/demosdk/types" import { handleWeb2 } from "src/features/web2/handleWeb2" import { DAHRFactory } from "src/features/web2/dahr/DAHRFactory" +import { validateAndNormalizeHttpUrl } from "src/features/web2/validator" type IHandleWeb2ProxyRequestStepParams = Pick< IWeb2Payload["message"], @@ -57,9 +58,20 @@ export async function handleWeb2ProxyRequest({ ) } + const validation = validateAndNormalizeHttpUrl( + web2Request.raw.url, + ) + if (!validation.ok) { + return createRPCResponse( + validation.status, + null, + validation.message, + ) + } + dahr.web2Request.raw = { ...dahr.web2Request.raw, - url: web2Request.raw.url, + url: validation.normalizedUrl, } const { method, headers } = web2Request.raw @@ -69,7 +81,7 @@ export async function handleWeb2ProxyRequest({ headers, payload, authorization, - url: web2Request.raw.url, + url: validation.normalizedUrl, }) return createRPCResponse(200, response)