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 13b592712e9..496ed72400e 100644 --- a/apps/web/app/api/desktop/[...route]/s3Config.ts +++ b/apps/web/app/api/desktop/[...route]/s3Config.ts @@ -1,3 +1,5 @@ +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; import { db } from "@cap/database"; import { decrypt, encrypt } from "@cap/database/crypto"; @@ -9,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, @@ -57,6 +64,17 @@ const getS3ErrorMetadata = (error: unknown) => { return error.$metadata as { httpStatusCode?: number } | undefined; }; +const blockedEndpointResponse = { + error: + "Invalid endpoint. Please provide a public S3-compatible endpoint URL.", + details: "The provided endpoint is not allowed.", +}; + +const isDisallowedEndpoint = async (endpoint: string) => + Boolean(endpoint) && + shouldBlockPrivateS3Endpoints() && + (await isBlockedS3Endpoint(endpoint)); + app.post( "/", zValidator( @@ -74,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()), @@ -227,6 +249,14 @@ 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 { const controller = new AbortController(); const timeoutId = setTimeout(() => { @@ -240,7 +270,13 @@ app.post( accessKeyId: data.accessKeyId, secretAccessKey: data.secretAccessKey, }, - requestHandler: { abortSignal: controller.signal }, + requestHandler: blockPrivateAddresses + ? { + abortSignal: controller.signal, + httpAgent: new HttpAgent({ lookup: publicOnlyLookup }), + httpsAgent: new HttpsAgent({ lookup: publicOnlyLookup }), + } + : { abortSignal: controller.signal }, }); try { 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); + }); +};