From 6dfa6dc9df85421d59b3a36dea3ea5ddcd29537c Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 20 Apr 2026 08:21:39 -0500 Subject: [PATCH] Restrict invite resolution probes Co-Authored-By: Paperclip --- .../invite-test-resolution-route.test.ts | 186 +++++++++++++ server/src/routes/access.ts | 251 ++++++++++++++++-- 2 files changed, 414 insertions(+), 23 deletions(-) create mode 100644 server/src/__tests__/invite-test-resolution-route.test.ts diff --git a/server/src/__tests__/invite-test-resolution-route.test.ts b/server/src/__tests__/invite-test-resolution-route.test.ts new file mode 100644 index 00000000000..7cfcb590227 --- /dev/null +++ b/server/src/__tests__/invite-test-resolution-route.test.ts @@ -0,0 +1,186 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + accessRoutes, + setInviteResolutionNetworkForTest, +} from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +function createSelectChain(rows: unknown[]) { + const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, + where() { + return query; + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(inviteRows: unknown[]) { + return { + select() { + return createSelectChain(inviteRows); + }, + }; +} + +function createInvite(overrides: Record = {}) { + return { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + ...overrides, + }; +} + +function createApp(db: Record) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "anon" }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token/test-resolution", () => { + const lookup = vi.fn(); + const requestHead = vi.fn(); + + beforeEach(() => { + lookup.mockReset(); + requestHead.mockReset(); + setInviteResolutionNetworkForTest({ lookup, requestHead }); + }); + + afterEach(() => { + setInviteResolutionNetworkForTest(null); + }); + + it.each([ + ["localhost", "http://localhost:3100/api/health", "127.0.0.1"], + ["IPv4 loopback", "http://127.0.0.1:3100/api/health", "127.0.0.1"], + ["IPv6 loopback", "http://[::1]:3100/api/health", "::1"], + ["RFC1918 10/8", "http://10.0.0.5/api/health", "10.0.0.5"], + ["RFC1918 172.16/12", "http://172.16.10.5/api/health", "172.16.10.5"], + ["RFC1918 192.168/16", "http://192.168.1.10/api/health", "192.168.1.10"], + ["link-local metadata", "http://169.254.169.254/latest/meta-data", "169.254.169.254"], + ["multicast", "http://224.0.0.1/probe", "224.0.0.1"], + ])("rejects %s targets before probing", async (_label, url, address) => { + lookup.mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]); + const app = createApp(createDbStub([createInvite()])); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + "url resolves to a private, local, multicast, or reserved address", + ); + expect(requestHead).not.toHaveBeenCalled(); + }); + + it("rejects hostnames that resolve to private addresses", async () => { + lookup.mockResolvedValue([{ address: "10.1.2.3", family: 4 }]); + const app = createApp(createDbStub([createInvite()])); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://gateway.example.test/health" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + "url resolves to a private, local, multicast, or reserved address", + ); + expect(lookup).toHaveBeenCalledWith("gateway.example.test"); + expect(requestHead).not.toHaveBeenCalled(); + }); + + it("rejects hostnames when any resolved address is private", async () => { + lookup.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]); + const app = createApp(createDbStub([createInvite()])); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://mixed.example.test/health" }); + + expect(res.status).toBe(400); + expect(requestHead).not.toHaveBeenCalled(); + }); + + it("allows public HTTPS targets through the resolved and pinned probe path", async () => { + lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + requestHead.mockResolvedValue({ httpStatus: 204 }); + const app = createApp(createDbStub([createInvite()])); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://gateway.example.test/health", timeoutMs: "2500" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + inviteId: "invite-1", + requestedUrl: "https://gateway.example.test/health", + timeoutMs: 2500, + status: "reachable", + method: "HEAD", + httpStatus: 204, + }); + expect(requestHead).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedAddress: "93.184.216.34", + resolvedAddresses: ["93.184.216.34"], + hostHeader: "gateway.example.test", + tlsServername: "gateway.example.test", + }), + 2500, + ); + }); + + it.each([ + ["missing invite", []], + ["revoked invite", [createInvite({ revokedAt: new Date("2026-03-07T00:05:00.000Z") })]], + ["expired invite", [createInvite({ expiresAt: new Date("2020-03-07T00:10:00.000Z") })]], + ])("returns not found for %s tokens before DNS lookup", async (_label, inviteRows) => { + const app = createApp(createDbStub(inviteRows)); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://gateway.example.test/health" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Invite not found"); + expect(lookup).not.toHaveBeenCalled(); + expect(requestHead).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index aeebd615101..fc9c6a0948e 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -4,7 +4,12 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; +import { lookup as dnsLookup } from "node:dns/promises"; import fs from "node:fs"; +import type { IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; @@ -84,6 +89,7 @@ const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const INVITE_TOKEN_SUFFIX_LENGTH = 8; const INVITE_TOKEN_MAX_RETRIES = 5; const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; +const INVITE_RESOLUTION_DNS_TIMEOUT_MS = 3_000; type MemberGrantPayload = { permissionKey: PermissionKey; @@ -2101,44 +2107,244 @@ type InviteResolutionProbe = { message: string; }; +type InviteResolutionLookupResult = { + address: string; + family?: number; +}; + +type ResolvedInviteResolutionTarget = { + url: URL; + resolvedAddress: string; + resolvedAddresses: string[]; + hostHeader: string; + tlsServername?: string; +}; + +type InviteResolutionHeadResponse = { + httpStatus: number | null; +}; + +type InviteResolutionNetwork = { + lookup(hostname: string): Promise; + requestHead( + target: ResolvedInviteResolutionTarget, + timeoutMs: number + ): Promise; +}; + +function parseIpv4Address(address: string) { + const parts = address.split("."); + if (parts.length !== 4) return null; + const parsed = parts.map((part) => { + if (!/^\d+$/.test(part)) return NaN; + return Number(part); + }); + if (parsed.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return null; + } + return parsed as [number, number, number, number]; +} + +function isPrivateOrReservedIpv4(address: string) { + const octets = parseIpv4Address(address); + if (!octets) return true; + const [a, b, c] = octets; + if (a === 0) return true; + if (a === 10) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 0 && c === 0) return true; + if (a === 192 && b === 168) return true; + if (a === 192 && b === 0 && c === 2) return true; + if (a === 192 && b === 88 && c === 99) return true; + if (a === 198 && (b === 18 || b === 19)) return true; + if (a === 198 && b === 51 && c === 100) return true; + if (a === 203 && b === 0 && c === 113) return true; + if (a >= 224) return true; + return false; +} + +function isPrivateOrReservedIpv6(address: string) { + const lower = address.toLowerCase(); + const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); + if (mappedIpv4?.[1]) return isPrivateOrReservedIpv4(mappedIpv4[1]); + if (lower === "::" || lower === "::1") return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + if (/^fe[89ab]/.test(lower)) return true; + if (lower.startsWith("ff")) return true; + if (lower === "100::" || lower.startsWith("100:")) return true; + if (lower.startsWith("2001:db8:") || lower === "2001:db8::") return true; + if (lower.startsWith("2001:2:") || lower === "2001:2::") return true; + if (lower.startsWith("2002:")) return true; + return false; +} + +function isPublicIpAddress(address: string) { + const ipVersion = isIP(address); + if (ipVersion === 4) return !isPrivateOrReservedIpv4(address); + if (ipVersion === 6) return !isPrivateOrReservedIpv6(address); + return false; +} + +function hostnameForResolution(url: URL) { + return url.hostname.replace(/^\[|\]$/g, ""); +} + +async function defaultInviteResolutionLookup( + hostname: string +): Promise { + return dnsLookup(hostname, { all: true, verbatim: true }); +} + +async function defaultInviteResolutionHeadRequest( + target: ResolvedInviteResolutionTarget, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const url = target.url; + const request = url.protocol === "https:" ? httpsRequest : httpRequest; + const options: HttpRequestOptions & { servername?: string } = { + protocol: url.protocol, + hostname: target.resolvedAddress, + port: url.port || undefined, + method: "HEAD", + path: `${url.pathname}${url.search}`, + headers: { + Host: target.hostHeader + } + }; + if (target.tlsServername) { + options.servername = target.tlsServername; + } + + let settled = false; + const req = request(options, (response: IncomingMessage) => { + settled = true; + response.resume(); + resolve({ httpStatus: response.statusCode ?? null }); + }); + req.setTimeout(timeoutMs, () => { + if (settled) return; + const error = new Error("Invite resolution probe timed out"); + error.name = "AbortError"; + req.destroy(error); + }); + req.on("error", (error) => { + if (settled) return; + settled = true; + reject(error); + }); + req.end(); + }); +} + +const defaultInviteResolutionNetwork: InviteResolutionNetwork = { + lookup: defaultInviteResolutionLookup, + requestHead: defaultInviteResolutionHeadRequest +}; + +let inviteResolutionNetwork = defaultInviteResolutionNetwork; + +export function setInviteResolutionNetworkForTest( + network: Partial | null +) { + inviteResolutionNetwork = network + ? { ...defaultInviteResolutionNetwork, ...network } + : defaultInviteResolutionNetwork; +} + +async function lookupInviteResolutionHostname(hostname: string) { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + inviteResolutionNetwork.lookup(hostname), + new Promise((_, reject) => { + timeout = setTimeout( + () => + reject( + badRequest( + `url hostname DNS lookup timed out after ${INVITE_RESOLUTION_DNS_TIMEOUT_MS}ms` + ) + ), + INVITE_RESOLUTION_DNS_TIMEOUT_MS + ); + }) + ]); + } catch (error) { + if (error instanceof Error && "status" in error) throw error; + throw badRequest("url hostname could not be resolved"); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function resolveInviteResolutionTarget( + url: URL +): Promise { + const hostname = hostnameForResolution(url); + const results = await lookupInviteResolutionHostname(hostname); + if (results.length === 0) { + throw badRequest("url hostname did not resolve to any addresses"); + } + + const resolvedAddresses = results.map((result) => result.address); + const unsafeAddress = resolvedAddresses.find((address) => !isPublicIpAddress(address)); + if (unsafeAddress) { + throw badRequest( + "url resolves to a private, local, multicast, or reserved address" + ); + } + + return { + url, + resolvedAddress: resolvedAddresses[0]!, + resolvedAddresses, + hostHeader: url.host, + tlsServername: url.protocol === "https:" && isIP(hostname) === 0 + ? hostname + : undefined + }; +} + async function probeInviteResolutionTarget( - url: URL, + target: ResolvedInviteResolutionTarget, timeoutMs: number ): Promise { const startedAt = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(url, { - method: "HEAD", - redirect: "manual", - signal: controller.signal - }); + const response = await inviteResolutionNetwork.requestHead(target, timeoutMs); const durationMs = Date.now() - startedAt; if ( - response.ok || - response.status === 401 || - response.status === 403 || - response.status === 404 || - response.status === 405 || - response.status === 422 || - response.status === 500 || - response.status === 501 + response.httpStatus !== null && + ( + (response.httpStatus >= 200 && response.httpStatus < 300) || + response.httpStatus === 401 || + response.httpStatus === 403 || + response.httpStatus === 404 || + response.httpStatus === 405 || + response.httpStatus === 422 || + response.httpStatus === 500 || + response.httpStatus === 501 + ) ) { return { status: "reachable", method: "HEAD", durationMs, - httpStatus: response.status, - message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.` + httpStatus: response.httpStatus, + message: `Webhook endpoint responded to HEAD with HTTP ${response.httpStatus}.` }; } return { status: "unreachable", method: "HEAD", durationMs, - httpStatus: response.status, - message: `Webhook endpoint probe returned HTTP ${response.status}.` + httpStatus: response.httpStatus, + message: response.httpStatus === null + ? "Webhook endpoint probe did not return an HTTP status." + : `Webhook endpoint probe returned HTTP ${response.httpStatus}.` }; } catch (error) { const durationMs = Date.now() - startedAt; @@ -2161,8 +2367,6 @@ async function probeInviteResolutionTarget( ? error.message : "Webhook endpoint probe failed." }; - } finally { - clearTimeout(timeout); } } @@ -2927,7 +3131,8 @@ export function accessRoutes( const timeoutMs = Number.isFinite(parsedTimeoutMs) ? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs))) : 5000; - const probe = await probeInviteResolutionTarget(target, timeoutMs); + const resolvedTarget = await resolveInviteResolutionTarget(target); + const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs); res.json({ inviteId: invite.id, testResolutionPath: `/api/invites/${token}/test-resolution`,