Files
PaperClipAI/packages/shared/src/oauth-endpoint-url.ts
T
DottaandPaperclip cabc9146d0 feat(apps): add secure remote MCP and PostHog setup (#12339)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Apps give those agents governed access to external tools.
> - Remote MCP setup needs secure endpoint validation and durable
credentials.
> - PostHog needs both browser sign-in and personal API key setup paths.
> - This pull request adds the shared remote MCP foundation and the
PostHog definition.
> - The benefit is a secure and reusable base for later app connection
work.

## Linked Issues or Issue Description

Refs #11965

This is stack 1 of 11. It replaces the first reviewable part of #11965.

## What Changed

- Add guarded remote MCP setup and credential handling.
- Add PostHog OAuth and API key connection methods.
- Add focused server, shared contract, and UI coverage.
- Keep the migration replay-safe and idempotent.
- Give the late-close security regression the same 10-second CI headroom
as the adjacent real-timer handshake test.
- Synchronize fake-timer handshake tests at the exact ensure-session
boundary so real filesystem setup cannot race the fake deadline.
- Drive PTY overflow coverage only after listener registration so
scheduling cannot reorder the test fixture.

## Verification

- pnpm exec vitest run
packages/adapter-utils/src/acpx-engine/execute.test.ts
server/src/__tests__/plugin-worker-manager.test.ts (220 passed; affected
cases also passed five focused stress repetitions)
- `pnpm exec vitest run
packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never leaks a
sandbox-provided value from a late close rejection into logs or the
result"` (1 passed)
- `pnpm exec vitest run
packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never
promotes a late ensureSession resolution|closes a late-resolving real
handle exactly once"` (2 passed)
- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm build`

## Risks

- Remote endpoint validation can reject configurations that previously
passed without checks.
- OAuth configuration errors can block setup until the operator corrects
the provider settings.
- The migration uses guarded statements so repeated execution is safe.
- The test-only synchronization changes do not affect runtime behavior;
they remove filesystem/fake-clock and listener-registration races
observed under parallel CI load.

> 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [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

174 lines
6.8 KiB
TypeScript

/**
* Scheme/transport safety for OAuth endpoints Paperclip learned from a remote
* server (PAP-17099).
*
* A generic remote MCP connection can point at any endpoint, and that endpoint
* gets to advertise its own authorization-server metadata. The
* `authorization_endpoint` it names is not just fetched server-side — Paperclip
* hands it to the operator's browser as a top-level navigation, so an arbitrary
* string there is a code-execution and open-redirect primitive:
* `javascript:` / `data:` would run in the board's origin, and plain `http:`
* would carry the authorization request (and the operator) over a network any
* intermediary can rewrite.
*
* So every endpoint URL discovered from metadata, pasted by an operator, taken
* from a `WWW-Authenticate` hint, or shipped as a gallery default is parsed
* here — once, in shared code — and must be:
*
* - a well-formed absolute URL (anything `new URL` rejects is rejected);
* - `https:`, or `http:` only for a loopback host under the caller's explicit
* local-development policy (the server passes its private-network policy, the
* board passes "am I myself served over plaintext HTTP");
* - free of embedded credentials, because `https://evil.test@real.test/...`
* reads as the wrong origin to a human and Paperclip must not help;
* - free of a fragment, which never survives an authorization request usefully
* and is a classic way to hide the effective target from a reader.
*
* Both the server and the board import this so a value that passes the API
* boundary cannot fail the navigation boundary, or vice versa.
*/
export type OAuthEndpointKind = "authorization" | "token" | "registration" | "metadata";
export type OAuthEndpointUrlRejection =
| "missing"
| "malformed"
| "unsupported_scheme"
| "insecure_transport"
| "embedded_credentials"
| "fragment";
export interface OAuthEndpointUrlOptions {
/**
* Allow `http:` for loopback hosts. Only true under an explicit
* local-development policy — never for a public deployment.
*/
allowInsecureLoopback?: boolean;
/**
* Origins whose `http:` is already the operator's own trust boundary —
* in practice just Paperclip's own deployment origin. A deployment served over
* plaintext HTTP on a LAN address can still run its own authorization
* endpoints (the smoke-lab fixture does), and opening Paperclip from Paperclip
* adds no exposure the board does not already have.
*/
allowInsecureOrigins?: string[];
}
export type OAuthEndpointUrlCheck =
| { ok: true; url: string; host: string }
| { ok: false; reason: OAuthEndpointUrlRejection };
/** `127.0.0.0/8` — the whole loopback range, not just `127.0.0.1`. */
function isLoopbackIpv4(hostname: string): boolean {
const parts = hostname.split(".");
if (parts.length !== 4) return false;
if (!parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return false;
return parts[0] === "127";
}
function isLoopbackHost(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (host === "::1") return true;
const mapped = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
if (mapped?.[1]) return isLoopbackIpv4(mapped[1]);
return isLoopbackIpv4(host);
}
function normalizedOrigin(value: string): string | null {
try {
const origin = new URL(value).origin;
return origin === "null" ? null : origin;
} catch {
return null;
}
}
function insecureTransportAllowed(parsed: URL, options: OAuthEndpointUrlOptions): boolean {
if (options.allowInsecureLoopback && isLoopbackHost(parsed.hostname)) return true;
return (options.allowInsecureOrigins ?? []).some((origin) => normalizedOrigin(origin) === parsed.origin);
}
/**
* Is `value` an OAuth endpoint Paperclip is willing to use — and, for the
* authorization endpoint, to navigate a browser to? Returns the normalized URL
* and its host on success, or the specific rejection reason so callers can
* produce an actionable message.
*/
export function checkOAuthEndpointUrl(
value: unknown,
options: OAuthEndpointUrlOptions = {},
): OAuthEndpointUrlCheck {
if (typeof value !== "string" || value.trim().length === 0) {
return { ok: false, reason: "missing" };
}
let parsed: URL;
try {
parsed = new URL(value.trim());
} catch {
return { ok: false, reason: "malformed" };
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return { ok: false, reason: "unsupported_scheme" };
}
// A `javascript:`/`data:` URL has no host, and neither does something like
// `http:relative` — without an authority there is no origin to trust.
if (!parsed.hostname) return { ok: false, reason: "malformed" };
if (parsed.protocol === "http:" && !insecureTransportAllowed(parsed, options)) {
return { ok: false, reason: "insecure_transport" };
}
if (parsed.username || parsed.password) return { ok: false, reason: "embedded_credentials" };
if (parsed.hash) return { ok: false, reason: "fragment" };
return { ok: true, url: parsed.toString(), host: parsed.host };
}
export function isSafeOAuthEndpointUrl(value: unknown, options: OAuthEndpointUrlOptions = {}): boolean {
return checkOAuthEndpointUrl(value, options).ok;
}
const KIND_LABELS: Record<OAuthEndpointKind, string> = {
authorization: "sign-in",
token: "token",
registration: "client registration",
metadata: "metadata",
};
/**
* A UI-safe explanation for a rejected endpoint. Never echoes the URL, because
* the string came from the remote server and may itself be the attack.
*/
export function oauthEndpointUrlRejectionMessage(
kind: OAuthEndpointKind,
reason: OAuthEndpointUrlRejection,
): string {
const label = KIND_LABELS[kind];
switch (reason) {
case "missing":
return `This server did not provide a ${label} address.`;
case "malformed":
return `This server's ${label} address is not a valid URL, so Paperclip stopped.`;
case "unsupported_scheme":
return `This server's ${label} address does not use https, so Paperclip stopped.`;
case "insecure_transport":
return `This server's ${label} address is not secure (https), so Paperclip stopped.`;
case "embedded_credentials":
return `This server's ${label} address hides a different site behind a username, so Paperclip stopped.`;
case "fragment":
return `This server's ${label} address is malformed for sign-in, so Paperclip stopped.`;
}
}
/**
* The host to show an operator before they are sent to an authorization page.
* `null` when the value is not a usable URL — callers should be refusing to
* navigate at that point anyway.
*/
export function oauthEndpointDisplayHost(value: unknown): string | null {
if (typeof value !== "string" || !value.trim()) return null;
try {
return new URL(value.trim()).host || null;
} catch {
return null;
}
}