Skip to content
Open
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
225 changes: 217 additions & 8 deletions apps/web/app/api/desktop/[...route]/s3Config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
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";
Expand Down Expand Up @@ -57,6 +62,194 @@ 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 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<boolean> => {
let url: URL;
try {
url = new URL(endpoint);
} catch {
return true;
}

if (url.protocol !== "http:" && url.protocol !== "https:") return true;
Comment thread
richiemcilroy marked this conversation as resolved.

// 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;
}
};
Comment thread
richiemcilroy marked this conversation as resolved.

// 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(
Expand Down Expand Up @@ -228,10 +421,17 @@ app.post(
const data = c.req.valid("json");

try {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, TIMEOUT_MS);
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 s3Client = new S3Client({
endpoint: data.endpoint,
Expand All @@ -240,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) {
Expand Down
Loading