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/3] 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/3] 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) { From d0f5847b1eafe3f901401548b09274f3f8676124 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:07:54 +0100 Subject: [PATCH 3/3] fix(web): block private S3 endpoints on Cap Cloud for test and save, including IPv4-mapped IPv6 --- .../__tests__/unit/s3-endpoint-policy.test.ts | 158 +++++++++++ .../app/api/desktop/[...route]/s3Config.ts | 251 +++--------------- apps/web/lib/s3-endpoint-policy.ts | 116 ++++++++ 3 files changed, 313 insertions(+), 212 deletions(-) create mode 100644 apps/web/__tests__/unit/s3-endpoint-policy.test.ts create mode 100644 apps/web/lib/s3-endpoint-policy.ts diff --git a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts new file mode 100644 index 00000000000..fe03acc6a0d --- /dev/null +++ b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts @@ -0,0 +1,158 @@ +import { Agent, createServer, get } from "node:http"; +import type { AddressInfo } from "node:net"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const env = vi.hoisted(() => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" as string | undefined }, + NODE_ENV: "production", +})); + +vi.mock("@cap/env", () => env); + +import { + isBlockedS3Endpoint, + isPrivateAddress, + publicOnlyLookup, + shouldBlockPrivateS3Endpoints, +} from "@/lib/s3-endpoint-policy"; + +const resolveTo = (addresses: string[]) => async () => addresses; + +describe("isPrivateAddress", () => { + it.each([ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "192.168.1.10", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "::", + "::1", + "fc00::1", + "fd12::1", + "fe80::1", + "fe90::1", + "febf::1", + "ff02::1", + "::127.0.0.1", + "::7f00:1", + "::8.8.8.8", + "::ffff:127.0.0.1", + "::ffff:7f00:1", + "::ffff:a9fe:a9fe", + "::ffff:10.0.0.1", + "0:0:0:0:0:ffff:7f00:1", + "64:ff9b::a9fe:a9fe", + "not-an-ip", + ])("blocks %s", (address) => { + expect(isPrivateAddress(address)).toBe(true); + }); + + it.each([ + "8.8.8.8", + "52.216.1.1", + "::ffff:8.8.8.8", + "2600:1f18::1", + "2001:4860:4860::8888", + ])("allows %s", (address) => { + expect(isPrivateAddress(address)).toBe(false); + }); +}); + +describe("isBlockedS3Endpoint", () => { + it.each([ + "http://127.0.0.1:9000", + "http://[::1]:9000", + "http://[::ffff:127.0.0.1]", + "http://[::ffff:a9fe:a9fe]", + "http://[::127.0.0.1]", + "http://0x7f000001", + "http://localhost:9000", + "http://minio.localhost", + "http://metadata.google.internal", + "http://user:pass@s3.example.com", + "ftp://s3.example.com", + "not a url", + ])("blocks %s without resolving", async (endpoint) => { + const resolve = vi.fn(resolveTo(["52.216.1.1"])); + await expect(isBlockedS3Endpoint(endpoint, resolve)).resolves.toBe(true); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("blocks hostnames that resolve to any private address", async () => { + await expect( + isBlockedS3Endpoint( + "https://s3.example.com", + resolveTo(["52.216.1.1", "10.0.0.5"]), + ), + ).resolves.toBe(true); + }); + + it("blocks hostnames that do not resolve", async () => { + await expect( + isBlockedS3Endpoint("https://s3.example.com", async () => { + throw new Error("ENOTFOUND"); + }), + ).resolves.toBe(true); + }); + + it("allows public endpoints", async () => { + await expect( + isBlockedS3Endpoint( + "https://s3.us-east-1.amazonaws.com", + resolveTo(["52.216.1.1", "2600:1f18::1"]), + ), + ).resolves.toBe(false); + await expect( + isBlockedS3Endpoint("https://8.8.8.8", resolveTo([])), + ).resolves.toBe(false); + }); +}); + +describe("publicOnlyLookup", () => { + it("refuses connections whose hostname resolves to a private address", async () => { + const server = createServer((_, response) => response.end("ok")); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const { port } = server.address() as AddressInfo; + + try { + const error = await new Promise( + (resolve) => { + get( + `http://localhost:${port}/`, + { agent: new Agent({ lookup: publicOnlyLookup }), family: 4 }, + (response) => { + response.resume(); + resolve(null); + }, + ).on("error", resolve); + }, + ); + + expect(error?.message).toContain("Refused to connect to private address"); + } finally { + server.close(); + } + }); +}); + +describe("shouldBlockPrivateS3Endpoints", () => { + beforeEach(() => { + env.buildEnv.NEXT_PUBLIC_IS_CAP = "true"; + env.NODE_ENV = "production"; + }); + + it("only enforces on Cap Cloud production", () => { + expect(shouldBlockPrivateS3Endpoints()).toBe(true); + + env.NODE_ENV = "development"; + expect(shouldBlockPrivateS3Endpoints()).toBe(false); + + env.NODE_ENV = "production"; + env.buildEnv.NEXT_PUBLIC_IS_CAP = undefined; + expect(shouldBlockPrivateS3Endpoints()).toBe(false); + }); +}); diff --git a/apps/web/app/api/desktop/[...route]/s3Config.ts b/apps/web/app/api/desktop/[...route]/s3Config.ts index 9ca3a8f140d..496ed72400e 100644 --- a/apps/web/app/api/desktop/[...route]/s3Config.ts +++ b/apps/web/app/api/desktop/[...route]/s3Config.ts @@ -1,8 +1,5 @@ -import { lookup as dnsLookupCallback, type LookupAddress } from "node:dns"; -import { lookup } from "node:dns/promises"; 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"; @@ -14,6 +11,11 @@ import { and, desc, eq, isNull } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { withAuth } from "@/app/api/utils"; +import { + isBlockedS3Endpoint, + publicOnlyLookup, + shouldBlockPrivateS3Endpoints, +} from "@/lib/s3-endpoint-policy"; import { getAccessibleOrganization, getManagedOrganizationStorage, @@ -62,193 +64,16 @@ 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 -// 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) { - 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; - } - - return true; +const blockedEndpointResponse = { + error: + "Invalid endpoint. Please provide a public S3-compatible endpoint URL.", + details: "The provided endpoint is not allowed.", }; -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; - - // 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; - - 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; - } -}; - -// 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); - }); -} +const isDisallowedEndpoint = async (endpoint: string) => + Boolean(endpoint) && + shouldBlockPrivateS3Endpoints() && + (await isBlockedS3Endpoint(endpoint)); app.post( "/", @@ -267,6 +92,10 @@ app.post( const user = c.get("user"); const data = c.req.valid("json"); + if (await isDisallowedEndpoint(data.endpoint)) { + return c.json(blockedEndpointResponse, { status: 400 }); + } + try { const encryptedConfig = { id: S3Bucket.S3BucketId.make(nanoId()), @@ -420,18 +249,19 @@ app.post( const TIMEOUT_MS = 5000; // 5 second timeout const data = c.req.valid("json"); + const blockPrivateAddresses = shouldBlockPrivateS3Endpoints(); + if (await isDisallowedEndpoint(data.endpoint)) { + return c.json( + { ...blockedEndpointResponse, metadata: undefined }, + { status: 400 }, + ); + } + 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(); + }, TIMEOUT_MS); const s3Client = new S3Client({ endpoint: data.endpoint, @@ -440,25 +270,22 @@ app.post( accessKeyId: data.accessKeyId, secretAccessKey: data.secretAccessKey, }, - // 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, - }, + requestHandler: blockPrivateAddresses + ? { + abortSignal: controller.signal, + httpAgent: new HttpAgent({ lookup: publicOnlyLookup }), + httpsAgent: new HttpsAgent({ lookup: publicOnlyLookup }), + } + : { abortSignal: controller.signal }, }); 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) { diff --git a/apps/web/lib/s3-endpoint-policy.ts b/apps/web/lib/s3-endpoint-policy.ts new file mode 100644 index 00000000000..0306255fd99 --- /dev/null +++ b/apps/web/lib/s3-endpoint-policy.ts @@ -0,0 +1,116 @@ +import { lookup as dnsLookup, type LookupAddress } from "node:dns"; +import { lookup } from "node:dns/promises"; +import { BlockList, isIP, type LookupFunction } from "node:net"; +import { buildEnv, NODE_ENV } from "@cap/env"; + +const privateRanges = new BlockList(); + +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + privateRanges.addSubnet(network, prefix, "ipv4"); +} + +// BlockList already applies the IPv4 rules to IPv4-mapped addresses +// (::ffff:a.b.c.d and ::ffff:7f00:1). Adding ::ffff:0:0/96 here would also +// match every plain IPv4 address, so only the IPv4-compatible (::/96) and NAT64 +// (64:ff9b::/96) forms are blocked outright. +for (const [network, prefix] of [ + ["::", 96], + ["64:ff9b::", 96], + ["fc00::", 7], + ["fe80::", 10], + ["fec0::", 10], + ["ff00::", 8], +] as const) { + privateRanges.addSubnet(network, prefix, "ipv6"); +} + +export function isPrivateAddress(address: string) { + const family = isIP(address); + if (family === 4) return privateRanges.check(address, "ipv4"); + if (family === 6) return privateRanges.check(address, "ipv6"); + return true; +} + +export function shouldBlockPrivateS3Endpoints() { + return buildEnv.NEXT_PUBLIC_IS_CAP === "true" && NODE_ENV === "production"; +} + +export async function isBlockedS3Endpoint( + endpoint: string, + resolve: (hostname: string) => Promise = async (hostname) => + (await lookup(hostname, { all: true, verbatim: true })).map( + (entry) => entry.address, + ), +) { + let url: URL; + try { + url = new URL(endpoint); + } catch { + return true; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return true; + if (url.username || url.password) return true; + + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); + if (!hostname) return true; + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".internal") + ) { + return true; + } + + if (isIP(hostname)) return isPrivateAddress(hostname); + + try { + const addresses = await resolve(hostname); + return addresses.length === 0 || addresses.some(isPrivateAddress); + } catch { + return true; + } +} + +export const publicOnlyLookup: LookupFunction = ( + hostname, + options, + callback, +) => { + dnsLookup(hostname, options, (error, address, family) => { + if (error) { + callback(error, address, family); + return; + } + + const addresses: LookupAddress[] = Array.isArray(address) + ? address + : [{ address, family }]; + const blocked = addresses.find((entry) => isPrivateAddress(entry.address)); + if (blocked) { + const blockedError: NodeJS.ErrnoException = new Error( + `Refused to connect to private address ${blocked.address}`, + ); + blockedError.code = "ECONNREFUSED"; + callback(blockedError, address, family); + return; + } + + callback(null, address, family); + }); +};