Files
PaperClipAI/server/src/services/remote-http-endpoint-guard.ts
T
DottaandPaperclip b51112798f feat(apps): improve gateway and workspace connection UX (#12340)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - App connections must work in both the operator UI and agent tool
gateway.
> - The first stack layer adds secure remote connections.
> - Operators still need clear setup, test, and recovery states.
> - This pull request adds the gateway behavior and the workspace
connection experience.
> - The benefit is a connection flow that is easier to understand and
recover.

## Linked Issues or Issue Description

Refs #11965

This is stack 2 of 11. It depends on stack 1 and replaces another
reviewable part of #11965.

## What Changed

- Improve remote tool gateway connection behavior.
- Add clearer app setup, test, and recovery states.
- Add focused server and UI tests for the new paths.
- Keep the diff isolated from later identity and catalog work.
- Stabilize DNS-pinned remote HTTP protocol fixtures and the
managed-runtime public-origin fixture for this independently tested
layer.

## Verification

- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts` (150 passed)
- `pnpm test:run`
- `pnpm check:token-gates`
- `pnpm build`

## Risks

- Gateway errors now surface through new user-facing states.
- A stale connection can require a new setup attempt.
- The change does not add a database migration.
- The injected HTTP transport and public URL are test-only fixtures;
production DNS pinning and runtime behavior are unchanged.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-29 12:08:32 -05:00

212 lines
8.2 KiB
TypeScript

import { lookup as dnsLookup } from "node:dns/promises";
import { isIP } from "node:net";
const DEFAULT_DNS_TIMEOUT_MS = 5_000;
type LookupResult = { address: string; family: number };
export type RemoteHttpEndpointLookup = (hostname: string) => Promise<LookupResult[]>;
export type RemoteHttpEndpointGuardOptions = {
allowPrivateNetwork?: boolean;
dnsTimeoutMs?: number;
lookup?: RemoteHttpEndpointLookup;
};
export type RemoteHttpEndpointErrorFactory = (message: string, code: string) => Error;
export function parseRemoteHttpEndpoint(
value: unknown,
error: RemoteHttpEndpointErrorFactory,
): URL {
if (typeof value !== "string" || value.trim().length === 0) {
throw error("Remote MCP connection requires config.url", "mcp_remote_url_missing");
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw error("Remote MCP connection URL is invalid", "mcp_remote_url_invalid");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw error("Remote MCP connection URL must use http or https", "mcp_remote_url_invalid");
}
return parsed;
}
export async function assertPublicRemoteHttpEndpoint(
endpoint: URL,
options: RemoteHttpEndpointGuardOptions,
error: RemoteHttpEndpointErrorFactory,
): Promise<void> {
await resolveApprovedRemoteHttpAddresses(endpoint, options, error);
}
/**
* Validate a remote endpoint and return the exact address set that was approved.
*
* Callers must dial one of the returned addresses instead of letting the socket
* layer resolve the hostname a second time. A second resolution reopens a
* DNS-rebinding TOCTOU window: an attacker-controlled name server can answer
* with a public address while this guard is looking, then with a loopback,
* private or link-local address a moment later when the connection is made
* (PAP-17098). Returning the resolved set — rather than a bare `void` — is what
* lets `guardedRemoteHttpFetch` close that window.
*
* Hostnames are always resolved and pinned, including in deployments that
* allow private networking. Link-local addresses remain outside that allowance,
* so handing an allowed hostname back to platform fetch would reopen a DNS
* rebinding path to instance metadata.
*/
export async function resolveApprovedRemoteHttpAddresses(
endpoint: URL,
options: RemoteHttpEndpointGuardOptions,
error: RemoteHttpEndpointErrorFactory,
): Promise<string[]> {
const hostname = endpoint.hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (!options.allowPrivateNetwork && (hostname === "localhost" || hostname.endsWith(".localhost"))) {
throw error("Remote MCP connection URL cannot target private or reserved network addresses", "remote_http_private_endpoint");
}
const literalVersion = isIP(hostname);
if (literalVersion !== 0) {
if (isAlwaysDeniedLinkLocalIp(hostname) || (!options.allowPrivateNetwork && isPrivateOrReservedIp(hostname))) {
throw error("Remote MCP connection URL cannot target private or reserved network addresses", "remote_http_private_endpoint");
}
return [hostname];
}
let results: LookupResult[];
try {
results = await lookupWithTimeout(
hostname,
options.lookup ?? defaultLookup,
options.dnsTimeoutMs ?? DEFAULT_DNS_TIMEOUT_MS,
);
} catch {
throw error("Remote MCP connection hostname could not be resolved", "remote_http_dns_failed");
}
if (results.length === 0) {
throw error("Remote MCP connection hostname did not resolve", "remote_http_dns_failed");
}
if (results.some((result) =>
isAlwaysDeniedLinkLocalIp(result.address)
|| (!options.allowPrivateNetwork && isPrivateOrReservedIp(result.address)))) {
throw error("Remote MCP connection URL cannot resolve to private or reserved network addresses", "remote_http_private_endpoint");
}
return results.map((result) => result.address);
}
/**
* Reduce an address to the form used for comparing an approved address against
* the peer a socket actually connected to. `socket.remoteAddress` may come back
* as an IPv4-mapped IPv6 address or carry an IPv6 zone index, neither of which
* appears in a DNS answer.
*/
export function normalizeIpAddress(address: string): string {
const lower = address.trim().toLowerCase().replace(/^\[|\]$/g, "").split("%")[0] ?? "";
const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
if (mappedIpv4?.[1]) return mappedIpv4[1];
const mappedIpv4Hex = parseMappedIpv4Hex(lower);
if (mappedIpv4Hex) return mappedIpv4Hex;
return lower;
}
function defaultLookup(hostname: string): Promise<LookupResult[]> {
return dnsLookup(hostname, { all: true, verbatim: true });
}
async function lookupWithTimeout(hostname: string, lookup: RemoteHttpEndpointLookup, timeoutMs: number) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
lookup(hostname),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`DNS lookup timed out for ${hostname}`));
}, timeoutMs);
timeout.unref?.();
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
export function isPrivateOrReservedIp(address: string): boolean {
const lower = address.toLowerCase();
const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
if (mappedIpv4?.[1]) return isPrivateOrReservedIpv4(mappedIpv4[1]);
const mappedIpv4Hex = parseMappedIpv4Hex(lower);
if (mappedIpv4Hex) return isPrivateOrReservedIpv4(mappedIpv4Hex);
if (isIP(address) === 4) return isPrivateOrReservedIpv4(address);
if (isIP(address) === 6) return isPrivateOrReservedIpv6(lower);
return true;
}
/** Link-local egress is denied in every deployment mode. */
export function isAlwaysDeniedLinkLocalIp(address: string): boolean {
const normalized = normalizeIpAddress(address);
if (isIP(normalized) === 4) {
const octets = parseIpv4Address(normalized);
return octets !== null && octets[0] === 169 && octets[1] === 254;
}
return isIP(normalized) === 6 && /^fe[89ab]/.test(normalized);
}
function isPrivateOrReservedIpv4(address: string): boolean {
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 parseIpv4Address(address: string): [number, number, number, number] | null {
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 parseMappedIpv4Hex(address: string): string | null {
const match = address.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (!match) return null;
const hi = Number.parseInt(match[1]!, 16);
const lo = Number.parseInt(match[2]!, 16);
if (!Number.isInteger(hi) || !Number.isInteger(lo)) return null;
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
}
function isPrivateOrReservedIpv6(address: string): boolean {
if (address === "::" || address === "::1") return true;
if (address.startsWith("fc") || address.startsWith("fd")) return true;
if (/^fe[89ab]/.test(address)) return true;
if (address.startsWith("ff")) return true;
if (address === "100::" || address.startsWith("100:")) return true;
if (/^2001:(?:0{0,4}:|:)/.test(address)) return true;
if (address.startsWith("2001:db8:") || address === "2001:db8::") return true;
if (address.startsWith("2001:2:") || address === "2001:2::") return true;
if (/^2001:0?2[0-9a-f]:/.test(address)) return true;
if (address.startsWith("2002:")) return true;
if (address.startsWith("64:ff9b:")) return true;
return false;
}