Close OAuth admission abuse paths and verify API and eval contracts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-01 10:27:00 -05:00
1 parent 826de9e6c2
commit fb7946a4b8
13 files changed
+1096 -17

No files matched your search

@@ -61,6 +61,7 @@ const apiPrefixes: Record<string, string> = {
"plugin-ui-static.ts": "/api",
"plugins.ts": "/api",
"projects.ts": "/api",
"public-mcp.ts": "/api",
"project-tools.ts": "/api",
"resource-memberships.ts": "/api",
"remote-agent-profiles.ts": "/api",
@@ -92,6 +93,13 @@ const HTTP_METHODS = new Set([
const explicitOpenApiCoverageExclusions = new Set<string>();
const explicitOpenApiOperationCoverageExclusions = new Set([
// OAuth discovery and protocol endpoints have their own metadata contract;
// browser connection-management operations remain documented in the board API.
"GET /.well-known/oauth-authorization-server",
"POST /mcp/oauth/register",
"GET /mcp/oauth/authorize",
"POST /mcp/oauth/token",
"POST /mcp/oauth/revoke",
// This endpoint is authenticated by the provider signature rather than by a
// Paperclip board/agent credential. It intentionally stays out of the public
// board API document, while this exact exclusion keeps route coverage honest.
@@ -132,6 +140,7 @@ function normalizeExpressPath(routePath: string) {
}
function resolveMountedPath(file: string, prefix: string, routePath: string) {
if (file === "public-mcp.ts" && (routePath.startsWith("/mcp/oauth/") || routePath.startsWith("/.well-known/"))) return routePath;
if (
(file === "chat-channels.ts" || file === "email.ts") &&
routePath.startsWith("/api/chat-webhooks/")
+32 -3
View File
@@ -3,7 +3,7 @@ import express, { type Request } from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createDb, authUsers, companies, companyMemberships, mcpOauthTokens, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db";
import { createDb, authUsers, companies, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db";
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret } from "../services/public-mcp/oauth.js";
import { createMcpApiDispatch, createPublicMcpExecutor, publicMcpCapabilities } from "../services/public-mcp/capabilities.js";
import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../routes/public-mcp.js";
@@ -51,7 +51,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
const [company] = await db.insert(companies).values({ name: "Team", issuePrefix: "M" + randomBytes(4).toString("hex") }).returning();
const [membership] = await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: role, status: "active" }).returning();
const actor: Request["actor"] = { type: "board", source: "session", userId };
const client = await oauth.register({ client_name: "Test client", redirect_uris: [redirectUri] });
const client = await oauth.register({ client_name: "Test client", redirect_uris: [redirectUri] }, randomUUID());
const url = await oauth.authorize({ client_id: client.client_id, redirect_uri: redirectUri, resource: config.resource, scope: "paperclip:read paperclip:write offline_access", state: "state", response_type: "code", code_challenge: challenge, code_challenge_method: "S256" });
const id = url.split("/").at(-1)!;
const consent = await oauth.consent(id, actor, { decision: "approve", companyId: company!.id, allowWrites: write });
@@ -268,7 +268,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
await db.insert(mcpOauthClients).values({ id: stale, name: "Never connected", redirectUris: [redirectUri], createdAt: old });
await db.update(mcpOauthClients).set({ createdAt: old });
const replica = createPublicMcpOAuth(db, config);
const registrations = await Promise.allSettled(Array.from({ length: 70 }, (_, index) => (index % 2 ? oauth : replica).register({ client_name: "Quota fixture", redirect_uris: [redirectUri] })));
const registrations = await Promise.allSettled(Array.from({ length: 70 }, (_, index) => (index % 2 ? oauth : replica).register({ client_name: "Quota fixture", redirect_uris: [redirectUri] }, `quota-source-${index}`)));
const accepted = registrations.filter((r): r is PromiseFulfilledResult<Awaited<ReturnType<typeof oauth.register>>> => r.status === "fulfilled");
try {
expect(accepted).toHaveLength(60);
@@ -280,4 +280,33 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
}
});
it("limits one registration source without denying another source", async () => {
const source = randomUUID();
const sourceHash = hashMcpSecret(config.resource + ":" + source);
const replica = createPublicMcpOAuth(db, config);
try {
const batch = await Promise.allSettled(Array.from({ length: 8 }, (_, i) => (i % 2 ? oauth : replica).register({ client_name: "Source quota", redirect_uris: [redirectUri] }, source)));
expect(batch.filter(r => r.status === "fulfilled")).toHaveLength(6);
await expect(replica.register({ client_name: "Other source", redirect_uris: [redirectUri] }, randomUUID())).resolves.toHaveProperty("client_id");
for (let batch = 0; batch < 4; batch++) {
await db.update(mcpOauthClients).set({ createdAt: new Date(Date.now() - 2 * 60_000) }).where(eq(mcpOauthClients.registrationSourceHash, sourceHash));
for (let i = 0; i < 6; i++) await replica.register({ client_name: "Source quota", redirect_uris: [redirectUri] }, source);
}
await db.update(mcpOauthClients).set({ createdAt: new Date(Date.now() - 2 * 60_000) }).where(eq(mcpOauthClients.registrationSourceHash, sourceHash));
await expect(oauth.register({ client_name: "Source quota", redirect_uris: [redirectUri] }, source)).rejects.toMatchObject({ status: 429 });
} finally { await db.delete(mcpOauthClients).where(eq(mcpOauthClients.registrationSourceHash, sourceHash)); }
});
it("bounds authorization starts across replicas and reclaims expired requests without another registration", async () => {
const client = await oauth.register({ client_name: "Request quota", redirect_uris: [redirectUri] }, randomUUID());
const input = { client_id: client.client_id, redirect_uri: redirectUri, response_type: "code", resource: config.resource, code_challenge: challenge, code_challenge_method: "S256" };
const replica = createPublicMcpOAuth(db, config);
const starts = await Promise.allSettled(Array.from({ length: 12 }, (_, i) => (i % 2 ? oauth : replica).authorize(input)));
expect(starts.filter(r => r.status === "fulfilled")).toHaveLength(10);
expect(starts.filter(r => r.status === "rejected").map(r => r.reason.status)).toEqual([429, 429]);
await db.update(mcpOauthRequests).set({ expiresAt: new Date(0) }).where(eq(mcpOauthRequests.clientId, client.client_id));
await expect(replica.authorize(input)).resolves.toContain("/mcp-connect/");
expect(await db.select().from(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, client.client_id))).toHaveLength(1);
});
});
+25
View File
@@ -264,6 +264,7 @@ import {
revokeToolTrustRuleSchema,
unbindToolProfileBindingSchema,
importMcpJsonSchema,
mcpConsentSchema,
toolPolicyTestRequestSchema,
createToolMcpGatewaySchema,
completeConnectionIntentSchema,
@@ -1660,6 +1661,8 @@ function resolveOperationAuthLevel(
path: string,
): OpenApiAuthLevel {
const key = operationKey(method, path);
if (key === "GET /api/mcp/requests/{id}") return "public";
if (path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board";
if (PUBLIC_OPERATIONS.has(key)) return "public";
if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run";
if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools";
@@ -11299,6 +11302,28 @@ registerCurrentRoute({
},
});
registerCurrentRoute({
method: "get", path: "/api/mcp/requests/{id}", tags: ["tool-gateway"],
summary: "Describe an assistant connection request and available sign-in options",
responses: { 200: r.ok(), 404: r.notFound },
});
registerCurrentRoute({
method: "post", path: "/api/mcp/requests/{id}/consent", tags: ["tool-gateway"],
summary: "Approve or deny assistant access using a same-origin browser session",
body: mcpConsentSchema,
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registerCurrentRoute({
method: "get", path: "/api/mcp/connections", tags: ["tool-gateway"],
summary: "List the signed-in person's assistant connections",
responses: { 200: r.ok(), 401: r.unauthorized },
});
registerCurrentRoute({
method: "delete", path: "/api/mcp/connections/{id}", tags: ["tool-gateway"],
summary: "Revoke an assistant connection using a same-origin browser session",
responses: { 204: { description: "Connection revoked" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registerCurrentRoute({
method: "get",
path: "/api/tool-gateway/gateways/{gatewayId}/mcp",
+1 -1
View File
@@ -55,7 +55,7 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
code_challenge_methods_supported: ["S256"], scopes_supported: PUBLIC_MCP_SCOPES,
}));
router.use("/mcp/oauth", authRateLimit(), express.urlencoded({ extended: false, limit: "16kb" }));
router.post("/mcp/oauth/register", async (req, res) => res.status(201).json(await oauth.register(req.body)));
router.post("/mcp/oauth/register", async (req, res) => res.status(201).json(await oauth.register(req.body, req.ip ?? req.socket.remoteAddress ?? "unknown")));
router.get("/mcp/oauth/authorize", async (req, res) => res.redirect(303, await oauth.authorize(req.query)));
router.post("/mcp/oauth/token", async (req, res) => res.json(await oauth.token(req.body ?? {})));
router.post("/mcp/oauth/revoke", async (req, res) => {
+21 -8
View File
@@ -98,24 +98,26 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
return {
config,
async register(input: unknown) {
async register(input: unknown, source = "unknown") {
const parsed = registrationSchema.safeParse(input);
if (!parsed.success) throw new McpOAuthError("invalid_client_metadata", "Supply a client name, valid redirect URIs, and public-client PKCE authentication.");
const client = { id: secret("pcmcp_client_"), name: parsed.data.client_name, redirectUris: parsed.data.redirect_uris };
const client = { id: secret("pcmcp_client_"), name: parsed.data.client_name, registrationSourceHash: hashMcpSecret(config.resource + ":" + source), redirectUris: parsed.data.redirect_uris };
await db.transaction(async (tx) => {
// Bound public DCR across replicas, not only per-IP in each process.
await tx.execute(sql`SELECT pg_advisory_xact_lock(736721042)`);
const now = new Date();
await tx.delete(mcpOauthRequests).where(lt(mcpOauthRequests.expiresAt, now));
await tx.delete(mcpOauthClients).where(and(
lt(mcpOauthClients.createdAt, new Date(now.getTime() - 24 * 60 * minute)),
lt(mcpOauthClients.createdAt, new Date(now.getTime() - 60 * minute)),
notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id))),
notExists(tx.select({ id: mcpOauthRequests.id }).from(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, mcpOauthClients.id))),
));
const [counts] = await tx.select({ total: sql<number>`count(*)::int`,
sourceTotal: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${client.registrationSourceHash}))::int`,
sourceRecent: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${client.registrationSourceHash} AND ${mcpOauthClients.createdAt} > ${new Date(now.getTime() - minute).toISOString()}::timestamptz))::int`,
recent: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.createdAt} > ${new Date(now.getTime() - minute).toISOString()}::timestamptz))::int`,
}).from(mcpOauthClients);
if (!counts || counts.total >= 10_000 || counts.recent >= 60) {
}).from(mcpOauthClients).where(notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id))));
if (!counts || counts.total >= 10_000 || counts.recent >= 60 || counts.sourceTotal >= 30 || counts.sourceRecent >= 6) {
throw new McpOAuthError("temporarily_unavailable", "Registration capacity reached. Retry later.", 429);
}
await tx.insert(mcpOauthClients).values(client);
@@ -136,9 +138,20 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
throw new McpOAuthError("invalid_scope", "Unsupported Paperclip scope.");
}
const id = secret("pcmcp_request_");
await db.insert(mcpOauthRequests).values({
id, clientId: client.id, redirectUri: p.redirect_uri, resource: p.resource, scopes,
state: p.state ?? null, challenge: p.code_challenge, expiresAt: new Date(Date.now() + 10 * minute),
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(736721042)`);
const now = new Date();
await tx.delete(mcpOauthRequests).where(lt(mcpOauthRequests.expiresAt, now));
const [counts] = await tx.select({ total: sql<number>`count(*)::int`,
client: sql<number>`(count(*) FILTER (WHERE ${mcpOauthRequests.clientId} = ${client.id}))::int`,
}).from(mcpOauthRequests);
if (!counts || counts.total >= 1000 || counts.client >= 10) {
throw new McpOAuthError("temporarily_unavailable", "Too many pending connection requests. Retry later.", 429);
}
await tx.insert(mcpOauthRequests).values({
id, clientId: client.id, redirectUri: p.redirect_uri, resource: p.resource, scopes,
state: p.state ?? null, challenge: p.code_challenge, expiresAt: new Date(now.getTime() + 10 * minute),
});
});
return config.origin + "/mcp-connect/" + id;
},