Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 16 additions & 39 deletions apps/server/src/integrations/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,16 +786,17 @@ export async function registerIntegrationRoutes(app: FastifyInstance): Promise<v
const limit = clampSearchLimit(request.body.limit, 10);

if (externalAccountId) {
// Per-user search — fan-out across all workspaces the linked user belongs to
// Per-user search, scoped to the integration's own workspace.
//
// GHSA-fwgr-c6wh-xxff: this used to fan out across every workspace the linked user
// belonged to. document-read looks only in integration.workspaceId and 404s anything
// else, so search surfaced path/title/snippet for documents read would deny — an
// integration authorized for one workspace could enumerate the user's other ones.
// Search must never return what read would refuse, so the two now share this scope.
const externalProvider = (request.body.externalProvider ?? integration.providerKey).trim();
const link = await resolveLink(integration, externalProvider, externalAccountId, reply);
if (!link) return;

const memberships = await prisma.workspaceMembership.findMany({
where: { userId: link.userId },
select: { workspaceId: true },
});
const workspaceIds = memberships.map((m) => m.workspaceId);
await prisma.externalAccountLink.update({ where: { id: link.id }, data: { lastUsedAt: new Date() } });

// A per-user search is an agent tool call; mirror the MCP path.
Expand All @@ -806,40 +807,16 @@ export async function registerIntegrationRoutes(app: FastifyInstance): Promise<v
{ tool_name: "document_search", token_id: null, token_kind: "integration", change_source: "agent", surface: "integration_rest", external_provider: externalProvider },
);

if (workspaceIds.length === 1) {
const results = await searchDocuments({
userId: link.userId,
workspaceId: workspaceIds[0]!,
query,
limit,
canonicalOnly: request.body.canonicalOnly ?? false,
});
return { results: results.map((r) => ({ ...r, workspaceId: workspaceIds[0]! })) };
}

const workspaceNames = await prisma.workspace.findMany({
where: { id: { in: workspaceIds } },
select: { id: true, name: true },
// searchDocuments applies the linked user's own permissions within the workspace, so a
// document they cannot read is filtered out before any snippet is built.
const results = await searchDocuments({
userId: link.userId,
workspaceId: integration.workspaceId,
query,
limit,
canonicalOnly: request.body.canonicalOnly ?? false,
});
const nameById = new Map(workspaceNames.map((w) => [w.id, w.name]));

const settled = await Promise.allSettled(
workspaceIds.map((wsId) =>
searchDocuments({ userId: link.userId, workspaceId: wsId, query, limit, canonicalOnly: request.body.canonicalOnly ?? false }),
),
);
const resultGroups: Array<{ workspaceId: string; workspaceName: string; results: SearchDocumentsResult[] }> = [];
const errors: { workspaceId: string; reason: string }[] = [];
for (let i = 0; i < settled.length; i++) {
const outcome = settled[i]!;
const wsId = workspaceIds[i]!;
if (outcome.status === "fulfilled") {
resultGroups.push({ workspaceId: wsId, workspaceName: nameById.get(wsId) ?? wsId, results: outcome.value });
} else {
errors.push({ workspaceId: wsId, reason: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason) });
}
}
return { results: mergeWorkspaceSearchResults(resultGroups, limit), errors };
return { results: results.map((r) => ({ ...r, workspaceId: integration.workspaceId })) };
}

// Workspace-level search: canonical only, restricted to allowedFolders
Expand Down
81 changes: 74 additions & 7 deletions apps/server/test/integration/integrations-linking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,10 +430,13 @@ describe("REST-mode document action endpoints", () => {
expect(res.json().results[0]).toHaveProperty("workspaceId");
});

it("document-search: 200 fans out across all user workspaces when user is a member of multiple", async () => {
// GHSA-fwgr-c6wh-xxff. This endpoint used to fan out across every workspace the linked
// user belonged to, while document-read only ever looks in the integration's workspace.
// Search must never surface a document read would refuse — not even its path or snippet.
it("document-search: stays inside the integration's workspace, matching document-read", async () => {
const { s, auth } = await setupActions();

// Add the same user to a second workspace with its own document
// The linked user is also an admin of a second workspace the integration knows nothing about.
const ws2 = await createWorkspace("Second Workspace", `ws2-${randomUUID().slice(0, 8)}`);
await addMember(ws2.id, s.admin.id, "admin");
const f2 = await req({
Expand All @@ -450,20 +453,84 @@ describe("REST-mode document action endpoints", () => {
payload: { workspaceId: ws2.id, folderId: f2.json().id, title: "Runbook 2", slug: "runbook-2", content: "# Runbook 2\n" },
});
expect(d2.statusCode).toBe(201);
const outOfScopeDocId = d2.json().id as string;

// document-read refuses the out-of-scope document...
const read = await req({
method: "POST",
url: "/api/integrations/actions/document-read",
headers: auth,
payload: { externalProvider: "hermes", externalAccountId: "hermes-admin-1", documentId: outOfScopeDocId },
});
expect(read.statusCode).toBe(404);

// ...so search must not leak its existence either.
const res = await req({
method: "POST",
url: "/api/integrations/actions/document-search",
headers: auth,
payload: { externalProvider: "hermes", externalAccountId: "hermes-admin-1", query: "Runbook" },
});
expect(res.statusCode).toBe(200);
const { results, errors } = res.json() as { results: Array<{ workspaceId: string }>; errors?: unknown[] };
const { results } = res.json() as { results: Array<{ id: string; workspaceId: string }> };
expect(results).toBeInstanceOf(Array);
expect(errors ?? []).toHaveLength(0);
const wsIds = new Set(results.map((r) => r.workspaceId));
expect(wsIds.has(s.ws.id)).toBe(true);
expect(wsIds.has(ws2.id)).toBe(true);
// In-scope documents still come back — this is a scope fix, not a shutdown.
expect(results.length).toBeGreaterThan(0);
expect(new Set(results.map((r) => r.workspaceId))).toEqual(new Set([s.ws.id]));
expect(results.some((r) => r.id === outOfScopeDocId)).toBe(false);
});

it("document-search: never returns a document the linked user cannot read", async () => {
const { s, auth } = await setupActions();

// A plain member with no grants on a private folder: document-read denies them.
const member = await createUser(`member-${randomUUID()}@t.co`, "Member");
await addMember(s.ws.id, member.id, "member");
const link = await req({
method: "POST",
url: "/api/integrations/connect-sessions",
headers: auth,
payload: { externalProvider: "hermes", externalAccountId: "hermes-member-1" },
});
expect(link.statusCode).toBe(201);
const { sessionId, connectUrl } = link.json() as { sessionId: string; connectUrl: string };
await confirm(sessionId, tokenFromConnectUrl(connectUrl), member.id);

const rf = await req({
method: "POST",
url: "/api/folders",
cookies: s.adminCookie,
payload: { workspaceId: s.ws.id, name: "Restricted", slug: "restricted" },
});
expect(rf.statusCode).toBe(201);
const rd = await req({
method: "POST",
url: "/api/documents",
cookies: s.adminCookie,
payload: { workspaceId: s.ws.id, folderId: rf.json().id, title: "Layoffs", slug: "layoffs", content: "# Layoffs\nzebrafish plan\n" },
});
expect(rd.statusCode).toBe(201);
const restrictedDocId = rd.json().id as string;

const read = await req({
method: "POST",
url: "/api/integrations/actions/document-read",
headers: auth,
payload: { externalProvider: "hermes", externalAccountId: "hermes-member-1", documentId: restrictedDocId },
});
expect(read.statusCode).toBe(403);

const res = await req({
method: "POST",
url: "/api/integrations/actions/document-search",
headers: auth,
payload: { externalProvider: "hermes", externalAccountId: "hermes-member-1", query: "zebrafish" },
});
expect(res.statusCode).toBe(200);
const { results } = res.json() as { results: Array<{ id: string; snippet: string | null }> };
expect(results.some((r) => r.id === restrictedDocId)).toBe(false);
// Not even a snippet: the body must never be excerpted for a denied document.
expect(results.some((r) => (r.snippet ?? "").includes("zebrafish"))).toBe(false);
});

it("document-search: 400 when query is missing", async () => {
Expand Down
Loading