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
158 changes: 158 additions & 0 deletions apps/web/__tests__/unit/s3-endpoint-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const { port } = server.address() as AddressInfo;

try {
const error = await new Promise<NodeJS.ErrnoException | null>(
(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);
});
});
38 changes: 37 additions & 1 deletion apps/web/app/api/desktop/[...route]/s3Config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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()),
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 {
Expand Down
116 changes: 116 additions & 0 deletions apps/web/lib/s3-endpoint-policy.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> = 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);
});
};
Loading