mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Restrict invite resolution probes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a25af41dc7
commit
6dfa6dc9df
2 files changed
+414
-23
No files matched your search
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
+228
-23
@@ -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<InviteResolutionLookupResult[]>;
|
||||
requestHead(
|
||||
target: ResolvedInviteResolutionTarget,
|
||||
timeoutMs: number
|
||||
): Promise<InviteResolutionHeadResponse>;
|
||||
};
|
||||
|
||||
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<InviteResolutionLookupResult[]> {
|
||||
return dnsLookup(hostname, { all: true, verbatim: true });
|
||||
}
|
||||
|
||||
async function defaultInviteResolutionHeadRequest(
|
||||
target: ResolvedInviteResolutionTarget,
|
||||
timeoutMs: number
|
||||
): Promise<InviteResolutionHeadResponse> {
|
||||
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<InviteResolutionNetwork> | null
|
||||
) {
|
||||
inviteResolutionNetwork = network
|
||||
? { ...defaultInviteResolutionNetwork, ...network }
|
||||
: defaultInviteResolutionNetwork;
|
||||
}
|
||||
|
||||
async function lookupInviteResolutionHostname(hostname: string) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
inviteResolutionNetwork.lookup(hostname),
|
||||
new Promise<never>((_, 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<ResolvedInviteResolutionTarget> {
|
||||
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<InviteResolutionProbe> {
|
||||
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`,
|
||||
|
||||
Reference in new issue
Block a user