From 152954df00bebe0fb39bcf122b2ae7659ed6203d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:47:32 +0100 Subject: [PATCH 1/2] fix(web): block private hosts in S3 endpoint test --- .../app/api/desktop/[...route]/s3Config.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/apps/web/app/api/desktop/[...route]/s3Config.ts b/apps/web/app/api/desktop/[...route]/s3Config.ts index 13b592712e9..8f91001a533 100644 --- a/apps/web/app/api/desktop/[...route]/s3Config.ts +++ b/apps/web/app/api/desktop/[...route]/s3Config.ts @@ -1,3 +1,5 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; import { db } from "@cap/database"; import { decrypt, encrypt } from "@cap/database/crypto"; @@ -57,6 +59,87 @@ const getS3ErrorMetadata = (error: unknown) => { return error.$metadata as { httpStatusCode?: number } | undefined; }; +// SSRF protection for the user-supplied `endpoint` in /test: Cap's server can +// never legitimately reach a user's private-LAN S3 endpoint, so we reject any +// endpoint whose host is/resolves to loopback, private, link-local or reserved +// ranges (incl. the cloud metadata IP) before constructing the S3 client. +const isBlockedIp = (ip: string): boolean => { + const version = isIP(ip); + + if (version === 4) { + const octets = ip.split(".").map((part) => Number.parseInt(part, 10)); + if (octets.length !== 4 || octets.some((o) => Number.isNaN(o))) return true; + const a = octets[0] ?? -1; + const b = octets[1] ?? -1; + if (a === 0) return true; // 0.0.0.0/8 (incl. 0.0.0.0) + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. metadata) + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT + if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + return false; + } + + if (version === 6) { + // Normalise to lowercase and strip any zone id / IPv4-mapped prefix. + const normalized = ip.toLowerCase().split("%")[0] ?? ""; + const mapped = normalized.replace(/^::ffff:/, ""); + if (isIP(mapped) === 4) return isBlockedIp(mapped); // IPv4-mapped IPv6 + if (normalized === "::1" || normalized === "::") return true; // loopback / unspecified + if (normalized.startsWith("fe80")) return true; // fe80::/10 link-local + const firstByte = Number.parseInt(normalized.slice(0, 2), 16); + if (!Number.isNaN(firstByte) && (firstByte & 0xfe) === 0xfc) return true; // fc00::/7 unique-local + return false; + } + + return true; +}; + +const isBlockedHostname = (hostname: string): boolean => { + const host = hostname.toLowerCase().replace(/\.$/, ""); + if (!host) return true; + if (host === "localhost" || host.endsWith(".localhost")) return true; + if (host.endsWith(".internal")) return true; + return false; +}; + +const isBlockedEndpoint = async (endpoint: string): Promise => { + let url: URL; + try { + url = new URL(endpoint); + } catch { + return true; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return true; + + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + if (!hostname) return true; + + if (isBlockedHostname(hostname)) return true; + + // WHATWG URL keeps the surrounding brackets on IPv6 literals + // (new URL("http://[::1]/").hostname === "[::1]"), so strip them before the + // IP check — otherwise isIP() returns 0 and the literal IPv6 SSRF target + // (e.g. [::1], [fc00::1], [::ffff:127.0.0.1]) would fall through to DNS. + const ipCandidate = hostname.replace(/^\[/, "").replace(/\]$/, ""); + + // Literal IP address: validate directly. + if (isIP(ipCandidate) !== 0) return isBlockedIp(ipCandidate); + + // Hostname: resolve all addresses and block if any is private/reserved. + try { + const addresses = await lookup(hostname, { all: true }); + if (addresses.length === 0) return true; + return addresses.some((addr) => isBlockedIp(addr.address)); + } catch { + // Unresolvable host: let the S3 client surface the normal connection error. + return false; + } +}; + app.post( "/", zValidator( @@ -228,6 +311,18 @@ app.post( const data = c.req.valid("json"); try { + if (await isBlockedEndpoint(data.endpoint)) { + return c.json( + { + error: + "Invalid endpoint. Please provide a valid public S3-compatible endpoint URL.", + details: "The provided endpoint is not allowed.", + metadata: undefined, + }, + { status: 400 }, + ); + } + const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); From 2573de4f7dff34d70d93ab2887229782617bd750 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:42:20 +0100 Subject: [PATCH 2/2] fix(web): harden S3 endpoint SSRF guard (IPv6 ranges, userinfo, DNS rebinding) --- .../app/api/desktop/[...route]/s3Config.ts | 150 +++++++++++++++--- 1 file changed, 132 insertions(+), 18 deletions(-) diff --git a/apps/web/app/api/desktop/[...route]/s3Config.ts b/apps/web/app/api/desktop/[...route]/s3Config.ts index 8f91001a533..9ca3a8f140d 100644 --- a/apps/web/app/api/desktop/[...route]/s3Config.ts +++ b/apps/web/app/api/desktop/[...route]/s3Config.ts @@ -1,5 +1,8 @@ +import { lookup as dnsLookupCallback, type LookupAddress } from "node:dns"; import { lookup } from "node:dns/promises"; -import { isIP } from "node:net"; +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; +import { isIP, type LookupFunction } from "node:net"; import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; import { db } from "@cap/database"; import { decrypt, encrypt } from "@cap/database/crypto"; @@ -59,6 +62,52 @@ const getS3ErrorMetadata = (error: unknown) => { return error.$metadata as { httpStatusCode?: number } | undefined; }; +// Expand an IPv6 string (already validated by `isIP() === 6`) into its 8 +// numeric hextets, converting any trailing dotted-quad (IPv4-mapped/compatible +// form) into two hextets so both `::ffff:127.0.0.1` and `::ffff:7f00:1` resolve +// the same. Returns null if it can't be parsed. +const expandIpv6 = ( + ip: string, +): [number, number, number, number, number, number, number, number] | null => { + let value = ip.toLowerCase().split("%")[0] ?? ""; // drop any zone id + const dotted = value.match(/^(.*:)((?:\d{1,3}\.){3}\d{1,3})$/); + if (dotted) { + const prefix = dotted[1]; + const quadStr = dotted[2]; + if (!prefix || !quadStr) return null; + const quad = quadStr.split(".").map((o) => Number.parseInt(o, 10)); + if ( + quad.length !== 4 || + quad.some((o) => !Number.isInteger(o) || o < 0 || o > 255) + ) + return null; + const [q0, q1, q2, q3] = quad as [number, number, number, number]; + value = `${prefix}${((q0 << 8) | q1).toString(16)}:${((q2 << 8) | q3).toString(16)}`; + } + + const halves = value.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + if (halves.length === 1 && head.length !== 8) return null; + const fill = 8 - head.length - tail.length; + if (fill < 0) return null; + const groups = [...head, ...Array(fill).fill("0"), ...tail]; + if (groups.length !== 8) return null; + const hextets = groups.map((g) => Number.parseInt(g || "0", 16)); + if (hextets.some((h) => Number.isNaN(h) || h < 0 || h > 0xffff)) return null; + return hextets as [ + number, + number, + number, + number, + number, + number, + number, + number, + ]; +}; + // SSRF protection for the user-supplied `endpoint` in /test: Cap's server can // never legitimately reach a user's private-LAN S3 endpoint, so we reject any // endpoint whose host is/resolves to loopback, private, link-local or reserved @@ -83,14 +132,32 @@ const isBlockedIp = (ip: string): boolean => { } if (version === 6) { - // Normalise to lowercase and strip any zone id / IPv4-mapped prefix. - const normalized = ip.toLowerCase().split("%")[0] ?? ""; - const mapped = normalized.replace(/^::ffff:/, ""); - if (isIP(mapped) === 4) return isBlockedIp(mapped); // IPv4-mapped IPv6 - if (normalized === "::1" || normalized === "::") return true; // loopback / unspecified - if (normalized.startsWith("fe80")) return true; // fe80::/10 link-local - const firstByte = Number.parseInt(normalized.slice(0, 2), 16); - if (!Number.isNaN(firstByte) && (firstByte & 0xfe) === 0xfc) return true; // fc00::/7 unique-local + const h = expandIpv6(ip); + if (!h) return true; // unparseable IPv6 → fail safe + + // ::ffff:a.b.c.d — IPv4-mapped, in dotted OR pure-hex form (e.g. + // ::ffff:7f00:1 == 127.0.0.1). Evaluate the embedded IPv4 directly. + if ( + h[0] === 0 && + h[1] === 0 && + h[2] === 0 && + h[3] === 0 && + h[4] === 0 && + h[5] === 0xffff + ) { + const a = (h[6] >> 8) & 0xff; + const b = h[6] & 0xff; + const c = (h[7] >> 8) & 0xff; + const d = h[7] & 0xff; + return isBlockedIp(`${a}.${b}.${c}.${d}`); + } + + // ::1 loopback / :: unspecified. + if (h.slice(0, 7).every((part) => part === 0) && h[7] <= 1) return true; + // fe80::/10 link-local (fe80–febf). + if ((h[0] & 0xffc0) === 0xfe80) return true; + // fc00::/7 unique-local (fc00–fdff). + if ((h[0] & 0xfe00) === 0xfc00) return true; return false; } @@ -115,6 +182,10 @@ const isBlockedEndpoint = async (endpoint: string): Promise => { if (url.protocol !== "http:" && url.protocol !== "https:") return true; + // Reject credentials embedded in the URL (http://user:pass@host) to avoid + // surprising behaviour and accidental secret leakage. + if (url.username || url.password) return true; + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); if (!hostname) return true; @@ -140,6 +211,45 @@ const isBlockedEndpoint = async (endpoint: string): Promise => { } }; +// A DNS lookup that refuses to resolve to a blocked (private/reserved) address. +// Used by the S3 client's HTTP agents so the address the socket actually +// connects to is re-validated at connection time — closing the DNS-rebinding / +// TOCTOU window between `isBlockedEndpoint` and the SDK's own DNS resolution. +type LookupCallback = ( + err: NodeJS.ErrnoException | null, + address: string | LookupAddress[], + family?: number, +) => void; + +function guardedLookup( + hostname: string, + options: unknown, + callback: LookupCallback, +): void { + // `dns.lookup` is heavily overloaded; cast to a single concrete signature so + // we can forward the agent-provided options and a union-typed callback. + const lookupFn = dnsLookupCallback as unknown as ( + hostname: string, + options: object, + callback: LookupCallback, + ) => void; + lookupFn(hostname, (options ?? {}) as object, (err, address, family) => { + if (err) return callback(err, address, family); + const candidates: LookupAddress[] = Array.isArray(address) + ? address + : [{ address, family: family ?? 0 }]; + const blocked = candidates.find((entry) => isBlockedIp(entry.address)); + if (blocked) { + const blockErr: NodeJS.ErrnoException = new Error( + `Refused to connect to blocked address ${blocked.address}`, + ); + blockErr.code = "EAI_BLOCKED"; + return callback(blockErr, address, family); + } + callback(err, address, family); + }); +} + app.post( "/", zValidator( @@ -323,11 +433,6 @@ app.post( ); } - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, TIMEOUT_MS); - const s3Client = new S3Client({ endpoint: data.endpoint, region: data.region, @@ -335,16 +440,25 @@ app.post( accessKeyId: data.accessKeyId, secretAccessKey: data.secretAccessKey, }, - requestHandler: { abortSignal: controller.signal }, + // Re-validate the resolved IP at connection time (not just in the + // pre-flight isBlockedEndpoint check) so a low-TTL DNS rebind can't + // point the socket at a private/metadata address after the check. + requestHandler: { + httpAgent: new HttpAgent({ + lookup: guardedLookup as unknown as LookupFunction, + }), + httpsAgent: new HttpsAgent({ + lookup: guardedLookup as unknown as LookupFunction, + }), + connectionTimeout: TIMEOUT_MS, + requestTimeout: TIMEOUT_MS, + }, }); try { await s3Client.send(new HeadBucketCommand({ Bucket: data.bucketName })); - - clearTimeout(timeoutId); } catch (error) { console.log(error); - clearTimeout(timeoutId); let errorMessage = "Failed to connect to S3"; if (error instanceof Error) {