diff --git a/apps/api/src/api/routes/jira-attachment-targets.test.ts b/apps/api/src/api/routes/jira-attachment-targets.test.ts new file mode 100644 index 0000000000..2123485231 --- /dev/null +++ b/apps/api/src/api/routes/jira-attachment-targets.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "bun:test"; +import { + attachmentContentUrl, + isAtlassianHost, + isAtlassianUrl, + parseAttachmentId, + resolveCloudId, + safeContentDisposition, +} from "./jira-attachment-targets"; + +describe("parseAttachmentId", () => { + it("accepts a numeric id", () => { + expect(parseAttachmentId("41356")).toBe("41356"); + }); + + it("rejects everything that could steer the upstream path", () => { + for (const bad of [ + undefined, + "", + "../../secret", + "41356/../../x", + "41356?x=1", + "https://evil.test/x", + "41356 ", + "abc", + "-1", + "4e3", + "1".repeat(21), + ]) { + expect(parseAttachmentId(bad)).toBeNull(); + } + }); +}); + +describe("isAtlassianHost", () => { + it("accepts Atlassian hosts and their subdomains", () => { + for (const host of [ + "atlassian.com", + "atlassian.net", + "api.atlassian.com", + "api.media.atlassian.com", + "osklenbr.atlassian.net", + "API.Atlassian.COM", + ]) { + expect(isAtlassianHost(host)).toBe(true); + } + }); + + it("rejects look-alikes that a suffix match without a dot boundary would pass", () => { + for (const host of [ + "atlassian.com.evil.test", + "notatlassian.com", + "evilatlassian.net", + "atlassian.co", + "", + ]) { + expect(isAtlassianHost(host)).toBe(false); + } + }); +}); + +describe("isAtlassianUrl", () => { + it("is false for a null, empty, or unparseable url", () => { + expect(isAtlassianUrl(null)).toBe(false); + expect(isAtlassianUrl(undefined)).toBe(false); + expect(isAtlassianUrl("")).toBe(false); + expect(isAtlassianUrl("not a url")).toBe(false); + }); + + it("is true for the MCP connection url and a media redirect", () => { + expect(isAtlassianUrl("https://mcp.atlassian.com/v1/mcp/authv2")).toBe( + true, + ); + expect( + isAtlassianUrl("https://api.media.atlassian.com/file/abc/binary?token=x"), + ).toBe(true); + }); + + it("is false for a non-Atlassian connection — the credential-leak guard", () => { + expect(isAtlassianUrl("https://mcp.notion.com/mcp")).toBe(false); + expect(isAtlassianUrl("https://evil.test/?x=atlassian.com")).toBe(false); + }); + + it("is false for a non-http scheme", () => { + expect(isAtlassianUrl("file:///etc/passwd")).toBe(false); + expect(isAtlassianUrl("ftp://api.atlassian.com/x")).toBe(false); + }); +}); + +describe("resolveCloudId", () => { + const one = [{ id: "cd4e853c-d029-41c2-907e-bef24605b986" }]; + const two = [...one, { id: "11111111-2222-3333-4444-555555555555" }]; + + it("uses the only site when none was requested", () => { + expect(resolveCloudId(null, one)).toEqual({ + ok: true, + cloudId: "cd4e853c-d029-41c2-907e-bef24605b986", + }); + }); + + it("accepts a requested site the token can see", () => { + expect(resolveCloudId(two[1]!.id, two)).toEqual({ + ok: true, + cloudId: two[1]!.id, + }); + }); + + it("refuses a site the token cannot see", () => { + const out = resolveCloudId("99999999-0000-0000-0000-000000000000", two); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.status).toBe(403); + }); + + it("refuses to guess between several sites", () => { + const out = resolveCloudId(null, two); + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.status).toBe(400); + // The caller has to be able to act on the error. + expect(out.error).toContain(two[0]!.id); + expect(out.error).toContain(two[1]!.id); + } + }); + + it("refuses when the token reaches nothing", () => { + const out = resolveCloudId(null, []); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.status).toBe(403); + }); + + it("ignores blank ids rather than selecting one", () => { + const out = resolveCloudId(null, [{ id: "" }]); + expect(out.ok).toBe(false); + }); +}); + +describe("attachmentContentUrl", () => { + it("builds the cloud REST url", () => { + expect(attachmentContentUrl("cloud-1", "41356")).toBe( + "https://api.atlassian.com/ex/jira/cloud-1/rest/api/3/attachment/content/41356", + ); + }); + + it("encodes the cloudId so a hostile value cannot open a new path segment", () => { + expect(attachmentContentUrl("a/../b", "1")).toBe( + "https://api.atlassian.com/ex/jira/a%2F..%2Fb/rest/api/3/attachment/content/1", + ); + }); +}); + +describe("safeContentDisposition", () => { + it("keeps a plain upstream filename", () => { + expect( + safeContentDisposition('attachment; filename="antes-desktop.png"', "9"), + ).toBe('attachment; filename="antes-desktop.png"'); + }); + + it("falls back to the attachment id when there is no filename", () => { + expect(safeContentDisposition(null, "41356")).toBe( + 'attachment; filename="attachment-41356"', + ); + }); + + it("drops a filename carrying a path, a quote, or a newline", () => { + for (const hostile of [ + 'attachment; filename="../../etc/passwd"', + 'attachment; filename="a\\"; rm -rf /"', + 'attachment; filename="a\nb.png"', + ]) { + expect(safeContentDisposition(hostile, "7")).toBe( + 'attachment; filename="attachment-7"', + ); + } + }); +}); diff --git a/apps/api/src/api/routes/jira-attachment-targets.ts b/apps/api/src/api/routes/jira-attachment-targets.ts new file mode 100644 index 0000000000..6b0ec44979 --- /dev/null +++ b/apps/api/src/api/routes/jira-attachment-targets.ts @@ -0,0 +1,158 @@ +/** + * Pure addressing + validation helpers for the Jira attachment proxy + * (`jira-attachments.ts`). Split out so the unit test can cover the guards + * without loading the route — and with it Better Auth and the storage layer. + * + * Every function here decides where a request may go or what may be echoed + * back, so each one fails closed on input it does not recognize. + */ + +/** Where Atlassian's cloud REST + OAuth resource discovery live. */ +export const ATLASSIAN_API_HOST = "api.atlassian.com"; + +/** + * Ceiling on a proxied attachment. Jira's own default limit is 10MB but it is + * configurable per site, and the point of the cap is the pod's disk and this + * process's memory, not Jira's policy. + * + * ponytail: enforced from `content-length` only, then streamed. A response that + * lies about its length gets through — that is Atlassian lying to us, not the + * threat this bounds. Wrap the stream in a counting transform if that changes. + */ +export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; + +/** Bounded so a wedged upstream can't hold a request open indefinitely. */ +export const UPSTREAM_TIMEOUT_MS = 30_000; + +/** + * Numeric Jira attachment id, or null. Digits only: this value lands in the + * upstream URL path, so anything else — a traversal segment, a query, an + * absolute URL — must not survive. Pure; the unit test owns the shape. + */ +export function parseAttachmentId(raw: string | undefined): string | null { + return raw && /^[0-9]{1,20}$/.test(raw) ? raw : null; +} + +/** + * True for a host that belongs to Atlassian. Used twice, for two different + * reasons: to confirm a connection really is Atlassian before its token is + * spent, and to confirm the 303 from the content endpoint lands on Atlassian's + * media CDN rather than somewhere the redirect could carry the request. + * + * Suffix match on a dot boundary — `atlassian.com.evil.test` must not pass. + */ +export function isAtlassianHost(host: string): boolean { + const h = host.toLowerCase(); + return ( + h === "atlassian.com" || + h === "atlassian.net" || + h.endsWith(".atlassian.com") || + h.endsWith(".atlassian.net") + ); +} + +/** True when `url` parses and its host is Atlassian's. Pure. */ +export function isAtlassianUrl(url: string | null | undefined): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return ( + (parsed.protocol === "https:" || parsed.protocol === "http:") && + isAtlassianHost(parsed.hostname) + ); + } catch { + return false; + } +} + +/** One entry of Atlassian's `/oauth/token/accessible-resources`. */ +export interface AccessibleResource { + id: string; + url?: string; + name?: string; +} + +export type CloudIdResolution = + | { ok: true; cloudId: string } + | { ok: false; status: 400 | 403; error: string }; + +/** + * Pick the Jira site to read from: the requested `cloudId` if the token can + * actually see it, or the only one when there is exactly one and the caller + * named none. + * + * Validating against the token's own accessible-resources list — rather than + * shape-checking a caller-supplied id — is what keeps the upstream URL from + * being steerable: an id the token cannot see is refused instead of fetched. + * Ambiguity is an error, never a guess: silently picking the first of several + * sites would serve BR data for a GLOBAL card and look like a Jira bug. + * + * Pure; the unit test owns every branch. + */ +export function resolveCloudId( + requested: string | null | undefined, + accessible: readonly AccessibleResource[], +): CloudIdResolution { + const ids = accessible.map((r) => r.id).filter((id) => id.length > 0); + if (ids.length === 0) { + return { + ok: false, + status: 403, + error: + "This connection's Atlassian token can reach no sites. Reconnect it.", + }; + } + if (requested) { + if (!ids.includes(requested)) { + return { + ok: false, + status: 403, + error: `cloudId ${requested} is not reachable with this connection's token`, + }; + } + return { ok: true, cloudId: requested }; + } + if (ids.length > 1) { + return { + ok: false, + status: 400, + error: `This connection reaches ${ids.length} Atlassian sites — pass ?cloudId= one of: ${ids.join(", ")}`, + }; + } + // Non-null: length is exactly 1 here, but noUncheckedIndexedAccess doesn't + // know that. + const only = ids[0]; + return only + ? { ok: true, cloudId: only } + : { ok: false, status: 403, error: "No usable Atlassian site" }; +} + +/** The attachment-content endpoint for one site. Pure. */ +export function attachmentContentUrl( + cloudId: string, + attachmentId: string, +): string { + return `https://${ATLASSIAN_API_HOST}/ex/jira/${encodeURIComponent( + cloudId, + )}/rest/api/3/attachment/content/${encodeURIComponent(attachmentId)}`; +} + +/** + * `content-disposition` safe to echo back: keep the upstream's filename when it + * is a plain one, otherwise name the file after the attachment id. The header + * reaches a shell that may redirect it to disk, so a quoted path or a newline + * does not get to pass through. + * + * Pure; the unit test owns it. + */ +export function safeContentDisposition( + upstream: string | null, + attachmentId: string, +): string { + const name = upstream?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i)?.[1]; + const safe = + name && /^[A-Za-z0-9._ -]{1,120}$/.test(name) + ? name + : `attachment-${attachmentId}`; + return `attachment; filename="${safe}"`; +} diff --git a/apps/api/src/api/routes/jira-attachments.ts b/apps/api/src/api/routes/jira-attachments.ts new file mode 100644 index 0000000000..182d04d139 --- /dev/null +++ b/apps/api/src/api/routes/jira-attachments.ts @@ -0,0 +1,256 @@ +/** + * Jira attachment bytes, proxied with the connection's vaulted OAuth token. + * + * `GET /api/:org/connections/:connectionId/jira/attachments/:attachmentId` + * + * Why this route exists: a sandbox run can enumerate a Jira attachment through + * the Atlassian MCP (`getJiraIssue` → `fields.attachment[]`) but cannot fetch + * one — the bytes need an Atlassian bearer token, the MCP exposes no + * fetch-the-bytes tool, and the connection's token stays in Studio's vault by + * design (`/oauth-token` returns only a *status*, never a value). Handing the + * token to the pod instead would put a live third-party credential in a shell, + * a transcript, and a log; proxying the bytes keeps it here. + * + * The caller needs no new credential: a run already holds a Studio API key and + * the daemon writes it to `/.deco/tools/.endpoint.json`, so the agent can + * call this over plain HTTPS. Sandbox egress allows any public host on TCP/443 + * (port-based `netinit` iptables), so the public URL is reachable. + * + * ⚠️ SECURITY: this route spends someone else's OAuth token. Two guards are + * load-bearing and both fail closed: + * 1. the connection must be an ATLASSIAN one — otherwise we would ship, say, + * a Notion token to `api.atlassian.com`; + * 2. the target host is fixed (`api.atlassian.com`) and the cloudId is + * validated against what the token can actually see, so the path cannot + * be steered at an arbitrary URL. + * Never log the token, and never put a response body in an error message. + */ + +import { Hono } from "hono"; +import type { StudioContext } from "@/core/studio-context"; +import { ForbiddenError, UnauthorizedError } from "@/core/access-control"; +import { getValidDownstreamAccessToken } from "@/oauth/token-refresh"; +import { DownstreamTokenStorage } from "@/storage/downstream-token"; +import { + ATLASSIAN_API_HOST, + type AccessibleResource, + attachmentContentUrl, + isAtlassianUrl, + MAX_ATTACHMENT_BYTES, + parseAttachmentId, + resolveCloudId, + safeContentDisposition, + UPSTREAM_TIMEOUT_MS, +} from "./jira-attachment-targets"; + +type Variables = { studioContext: StudioContext }; + +/** + * Resource key gating this route. Not an MCP tool, so it lives in + * `BASIC_USAGE_TOOLS` next to the org-fs keys rather than in a capability's + * tool list — same decision, same reason: any org member who can read the + * issue through the connection can already see the attachment, so the gate is + * membership. An API key must still name it (or `*`), so a narrowly-scoped key + * fails closed. + * + * Duplicated as a literal in `BASIC_USAGE_TOOLS` rather than imported from it: + * `packages/shared` must not import app source (`ban-cross-tree-imports`), and + * the org-fs keys next to it are spelled the same way for the same reason. + */ +const JIRA_ATTACHMENT_READ = "JIRA_ATTACHMENT_READ"; + +/** Fetch the token's accessible Atlassian sites. */ +async function fetchAccessibleResources( + accessToken: string, +): Promise { + const res = await fetch( + `https://${ATLASSIAN_API_HOST}/oauth/token/accessible-resources`, + { + headers: { + authorization: `Bearer ${accessToken}`, + accept: "application/json", + }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as unknown; + if (!Array.isArray(body)) return null; + return body.flatMap((entry) => { + if (typeof entry !== "object" || entry === null) return []; + const id = (entry as Record).id; + if (typeof id !== "string" || !id) return []; + const url = (entry as Record).url; + const name = (entry as Record).name; + return [ + { + id, + ...(typeof url === "string" ? { url } : {}), + ...(typeof name === "string" ? { name } : {}), + }, + ]; + }); +} + +export const createJiraAttachmentRoutes = () => { + const app = new Hono<{ Variables: Variables }>(); + + app.get( + "/connections/:connectionId/jira/attachments/:attachmentId", + async (c) => { + const ctx = c.get("studioContext"); + const connectionId = c.req.param("connectionId"); + + const userId = ctx.auth?.user?.id ?? ctx.auth?.apiKey?.userId ?? null; + if (!userId) return c.json({ error: "Unauthorized" }, 401); + + const organizationId = ctx.organization?.id; + if (!organizationId) { + return c.json({ error: "Organization context required" }, 403); + } + + const attachmentId = parseAttachmentId(c.req.param("attachmentId")); + if (!attachmentId) { + return c.json( + { error: "attachmentId must be the numeric Jira attachment id" }, + 400, + ); + } + + try { + await ctx.access.check(JIRA_ATTACHMENT_READ); + } catch (err) { + if (err instanceof UnauthorizedError) { + return c.json({ error: err.message }, 401); + } + if (err instanceof ForbiddenError) { + return c.json({ error: err.message }, 403); + } + throw err; + } + + const connection = await ctx.storage.connections.findById( + connectionId, + organizationId, + ); + if (!connection) return c.json({ error: "Connection not found" }, 404); + if (connection.status !== "active") { + return c.json({ error: "Connection is not active" }, 409); + } + // Guard 1: never spend a non-Atlassian connection's token against + // Atlassian. Fails closed on a null/unparseable url. + if (!isAtlassianUrl(connection.connection_url)) { + return c.json( + { + error: + "Not an Atlassian connection — refusing to use its credential", + }, + 400, + ); + } + + const tokenStorage = new DownstreamTokenStorage(ctx.db, ctx.vault); + const token = await getValidDownstreamAccessToken({ + connectionId, + connectionUrl: connection.connection_url, + tokenStorage, + }); + if (!token.accessToken) { + return c.json( + { + error: `No usable Atlassian credential for this connection (${token.state}). Reconnect it in Studio.`, + }, + 409, + ); + } + + const accessible = await fetchAccessibleResources(token.accessToken); + if (!accessible) { + return c.json( + { error: "Could not list the connection's Atlassian sites" }, + 502, + ); + } + // Guard 2: the site must be one this token can see, so the upstream URL + // is not steerable by the caller. + const site = resolveCloudId(c.req.query("cloudId"), accessible); + if (!site.ok) return c.json({ error: site.error }, site.status); + + // The content endpoint 303s to Atlassian's media CDN. Followed by hand, + // one hop, host-checked: an automatic follow would carry the request + // wherever the Location header points. + const first = await fetch( + attachmentContentUrl(site.cloudId, attachmentId), + { + headers: { authorization: `Bearer ${token.accessToken}` }, + redirect: "manual", + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + + let upstream = first; + if (first.status >= 300 && first.status < 400) { + const location = first.headers.get("location"); + if (!isAtlassianUrl(location)) { + return c.json( + { + error: "Attachment redirect left Atlassian — refusing to follow", + }, + 502, + ); + } + upstream = await fetch(location as string, { + // The media URL carries its own credential; ours must not ride along + // to a different host. + redirect: "manual", + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + } + + if (upstream.status === 404) { + return c.json({ error: "Attachment not found" }, 404); + } + if (upstream.status === 401 || upstream.status === 403) { + return c.json( + { error: "Atlassian refused the attachment for this connection" }, + 403, + ); + } + if (!upstream.ok || !upstream.body) { + return c.json( + { + error: `Atlassian returned ${upstream.status} for this attachment`, + }, + 502, + ); + } + + const declared = Number(upstream.headers.get("content-length") ?? ""); + if (Number.isFinite(declared) && declared > MAX_ATTACHMENT_BYTES) { + return c.json( + { + error: `Attachment is ${declared} bytes, over the ${MAX_ATTACHMENT_BYTES}-byte proxy limit`, + }, + 413, + ); + } + + const headers: Record = { + "Content-Type": + upstream.headers.get("content-type") ?? "application/octet-stream", + "Content-Disposition": safeContentDisposition( + upstream.headers.get("content-disposition"), + attachmentId, + ), + // Never cached by a shared cache: the bytes are a tenant's issue data. + "Cache-Control": "private, max-age=0, no-store", + }; + if (Number.isFinite(declared) && declared > 0) { + headers["Content-Length"] = String(declared); + } + return new Response(upstream.body, { status: 200, headers }); + }, + ); + + return app; +}; diff --git a/apps/api/src/api/routes/org-scoped.ts b/apps/api/src/api/routes/org-scoped.ts index ab30efe183..2ec822f080 100644 --- a/apps/api/src/api/routes/org-scoped.ts +++ b/apps/api/src/api/routes/org-scoped.ts @@ -19,6 +19,7 @@ import { createDevAssetsRoutes } from "./dev-assets"; import { createCredentialVaultRoutes } from "./credential-vault"; import { createDownstreamTokenRoutes } from "./downstream-token"; import { createFileUploadRoutes } from "./file-uploads"; +import { createJiraAttachmentRoutes } from "./jira-attachments"; import { createKVRoutes } from "./kv"; import { createOrgFsRoutes } from "./org-fs"; import { createOrgScopedWellKnownProtectedResourceRoutes } from "./oauth-proxy"; @@ -94,6 +95,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { app.route("/", createTaskBoardImportRoutes()); // /api/:org/internal/task-board/import — service-token batch import app.route("/", createCommerceDiagnosticShareRoutes()); // /api/:org/internal/commerce-diagnostic/share-invite — service-token share invite app.route("/", createThreadOutputsRoutes()); // /api/:org/threads/:threadId/outputs + app.route("/", createJiraAttachmentRoutes()); // /api/:org/connections/:connectionId/jira/attachments/:attachmentId app.route("/tools", createToolsRestRoutes()); // /api/:org/tools[/:toolName] — REST builtin-tool dispatch app.route("/", createObjectStorageRoutes()); // /api/:org/object-storage/* app.route("/", createKVRoutes({ kvStorage: deps.kvStorage })); diff --git a/packages/e2e/tests/jira-attachment-proxy.spec.ts b/packages/e2e/tests/jira-attachment-proxy.spec.ts new file mode 100644 index 0000000000..1a657dd9d3 --- /dev/null +++ b/packages/e2e/tests/jira-attachment-proxy.spec.ts @@ -0,0 +1,180 @@ +/** + * Guards on `GET /api/:org/connections/:id/jira/attachments/:attId`. + * + * The route spends a connection's vaulted Atlassian OAuth token on the + * caller's behalf, so the interesting contract is everything it REFUSES. All + * of that is assertable without an Atlassian credential: each case below stops + * before any upstream call, which is exactly the property worth pinning — + * a regression here means a token gets spent, or sent somewhere it shouldn't. + * + * The happy path (real token → real bytes) needs a live Atlassian connection + * and is deliberately not faked here; a stubbed `api.atlassian.com` would + * assert our own mock, not the contract. + * + * Black-box: real signup, real middleware, real DB. Assertions on HTTP status + * and body only. + */ + +import { signUpViaApi } from "../fixtures/auth-api"; +import { createHttpConnection } from "../fixtures/mcp-tools"; +import { expect, newApiContext, test } from "../fixtures/test"; + +/** Shape of the org's real Atlassian MCP connection, minus the credential. */ +const ATLASSIAN_MCP_URL = "https://mcp.atlassian.com/v1/mcp/authv2"; + +const attachmentUrl = (orgSlug: string, connectionId: string, attId: string) => + `/api/${orgSlug}/connections/${connectionId}/jira/attachments/${attId}`; + +test.describe("jira attachment proxy guards", () => { + test("refuses to spend a non-Atlassian connection's credential", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const owner = await signUpViaApi(ctx); + + // A connection that is emphatically not Atlassian. Without the host guard + // its token would be shipped to api.atlassian.com. + const notAtlassian = await createHttpConnection(ctx, owner.orgSlug, { + title: "Notion (not Atlassian)", + url: "https://mcp.notion.com/mcp", + }); + + const res = await ctx.get( + attachmentUrl(owner.orgSlug, notAtlassian.id, "41356"), + ); + // 400, not 403: the member IS authorized (JIRA_ATTACHMENT_READ is a + // basic-usage key) — the connection is simply the wrong kind. A 403 here + // would mean the permission wiring is broken instead. + expect(res.status()).toBe(400); + expect(await res.text()).toContain("Not an Atlassian connection"); + + await ctx.dispose(); + }); + + test("a look-alike host does not pass for Atlassian", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const owner = await signUpViaApi(ctx); + + const lookalike = await createHttpConnection(ctx, owner.orgSlug, { + title: "Atlassian look-alike", + url: "https://atlassian.com.example.test/mcp", + }); + + const res = await ctx.get( + attachmentUrl(owner.orgSlug, lookalike.id, "41356"), + ); + expect(res.status()).toBe(400); + expect(await res.text()).toContain("Not an Atlassian connection"); + + await ctx.dispose(); + }); + + test("an Atlassian connection with no stored token fails closed", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const owner = await signUpViaApi(ctx); + + const atlassian = await createHttpConnection(ctx, owner.orgSlug, { + title: "Osklen - Atlassian (Jira)", + url: ATLASSIAN_MCP_URL, + }); + + const res = await ctx.get( + attachmentUrl(owner.orgSlug, atlassian.id, "41356"), + ); + // 409 = "reconnect it", a state the caller can act on — not a 500, and + // not an unauthenticated call to Atlassian. + expect(res.status()).toBe(409); + expect(await res.text()).toContain("No usable Atlassian credential"); + + await ctx.dispose(); + }); + + test("a non-numeric attachment id is rejected before anything else", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const owner = await signUpViaApi(ctx); + + const atlassian = await createHttpConnection(ctx, owner.orgSlug, { + title: "Atlassian", + url: ATLASSIAN_MCP_URL, + }); + + for (const hostile of [ + encodeURIComponent("../../../etc/passwd"), + encodeURIComponent("41356?x=1"), + "abc", + "-1", + ]) { + const res = await ctx.get( + attachmentUrl(owner.orgSlug, atlassian.id, hostile), + ); + // 400 from the id guard, or 404 when the encoded segment doesn't match + // the route at all — either way it is never served. No body assertion: + // the 404 handler echoes the request path, so any substring taken from + // the input matches by construction and proves nothing. + expect( + [400, 404], + `attachment id ${hostile} should not be served`, + ).toContain(res.status()); + } + + await ctx.dispose(); + }); + + test("an unknown connection is a 404, not a probe of another org", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const owner = await signUpViaApi(ctx); + + const res = await ctx.get( + attachmentUrl(owner.orgSlug, "conn_does-not-exist", "41356"), + ); + expect(res.status()).toBe(404); + + await ctx.dispose(); + }); + + test("a non-member cannot fetch through another org's connection", async ({ + playwright, + }) => { + const ownerCtx = await newApiContext(playwright); + const owner = await signUpViaApi(ownerCtx); + const atlassian = await createHttpConnection(ownerCtx, owner.orgSlug, { + title: "Atlassian", + url: ATLASSIAN_MCP_URL, + }); + + // Sanity: the owner reaches the route at all (409 = past every gate, + // stopped only by the missing token). Without this the 403 below could + // pass for the wrong reason. + const ownerRes = await ownerCtx.get( + attachmentUrl(owner.orgSlug, atlassian.id, "41356"), + ); + expect(ownerRes.status()).toBe(409); + + const outsiderCtx = await newApiContext(playwright); + await signUpViaApi(outsiderCtx); + const outsiderRes = await outsiderCtx.get( + attachmentUrl(owner.orgSlug, atlassian.id, "41356"), + ); + expect(outsiderRes.status()).toBe(403); + + const anonCtx = await newApiContext(playwright); + const anonRes = await anonCtx.get( + attachmentUrl(owner.orgSlug, atlassian.id, "41356"), + ); + // The middleware owns the exact code for an anonymous caller; the contract + // this pins is that the route is not served. + expect([401, 403]).toContain(anonRes.status()); + + await anonCtx.dispose(); + await outsiderCtx.dispose(); + await ownerCtx.dispose(); + }); +}); diff --git a/packages/shared/src/tools/registry-metadata.ts b/packages/shared/src/tools/registry-metadata.ts index ce6c8c494f..7973bf7789 100644 --- a/packages/shared/src/tools/registry-metadata.ts +++ b/packages/shared/src/tools/registry-metadata.ts @@ -1758,6 +1758,11 @@ export const BASIC_USAGE_TOOLS: ReadonlySet = new Set([ // changes. See `.context/org-filesystem-proposal.md`. "ORG_FS_READ", "ORG_FS_WRITE", + // Jira attachment proxy (`/api/:org/connections/:id/jira/attachments/:attId`) + // — same reasoning as the org-fs keys: any member who can read the issue + // through the connection can already see the attachment, so the gate is + // membership. Listed here so an API key must still name it explicitly. + "JIRA_ATTACHMENT_READ", ]); /**