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);
});
});