Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion src/features/web2/dahr/DAHR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 84 additions & 2 deletions src/features/web2/proxy/Proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -182,7 +183,80 @@ export class Proxy {

private async createNewServer(targetUrl: string): Promise<void> {
return new Promise<void>((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({
Expand Down Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions src/features/web2/validator.ts
Original file line number Diff line number Diff line change
@@ -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" }
}
}
16 changes: 14 additions & 2 deletions src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -57,9 +58,20 @@ export async function handleWeb2ProxyRequest({
)
}

const validation = validateAndNormalizeHttpUrl(
web2Request.raw.url,
)
if (!validation.ok) {
return createRPCResponse(
validation.status,
null,
validation.message,
)
}

Comment on lines +61 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

SSRF gaps remain: DNS and redirects can still target private/metadata IPs.

Blocking only localhost/loopback at parse-time is insufficient. Attackers can use a public hostname that resolves to 169.254.169.254, 10.0.0.0/8, fc00::/7, etc., or abuse redirects. Enforce IP allow/block after DNS resolution and on every redirect in the Proxy layer.

Action:

  • In Proxy HTTP client, provide a custom lookup/agent to resolve the target and reject connections to private/link-local/reserved ranges (IPv4 and IPv6, including IPv4-mapped IPv6).
  • Re-check the resolved address after each redirect hop before following it.
  • Consider an allowlist instead (e.g., public CIDRs) if feasible.
    Want a patch against Proxy to add a guarded lookup + redirect guard?
🤖 Prompt for AI Agents
In src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts around lines
61-71: the current URL validation only blocks parse-time localhost/loopback
which leaves SSRF via DNS and redirects possible; update the Proxy HTTP client
to use a custom DNS lookup/agent that resolves hostnames before connecting and
rejects/resolves-to addresses in private/link-local/reserved ranges (e.g.,
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16,
100.64.0.0/10, 192.0.0.0/24, 198.18.0.0/15, fc00::/7, fe80::/10, IPv4-mapped
IPv6 ranges), add the same check for every redirect hop before following it, and
fail the request if a resolved IP is disallowed; implement this by injecting a
guarded lookup function into the HTTP/HTTPS agent used by the proxy, optionally
replace the blacklist with an allowlist of public CIDRs if policy permits, and
ensure errors include clear status/messages so createRPCResponse can return
appropriate error codes.

dahr.web2Request.raw = {
...dahr.web2Request.raw,
url: web2Request.raw.url,
url: validation.normalizedUrl,
}

const { method, headers } = web2Request.raw
Expand All @@ -69,7 +81,7 @@ export async function handleWeb2ProxyRequest({
headers,
payload,
authorization,
url: web2Request.raw.url,
url: validation.normalizedUrl,
})

return createRPCResponse(200, response)
Expand Down