feat(api): proxy Jira attachment bytes with the connection's vaulted token - #6850
feat(api): proxy Jira attachment bytes with the connection's vaulted token#6850pedrofrxncx wants to merge 1 commit into
Conversation
…token A sandbox run can enumerate a Jira attachment through the Atlassian MCP but cannot fetch one: the bytes need an Atlassian bearer token, the MCP exposes no fetch-the-bytes tool, and the connection's OAuth token stays in Studio's vault by design (`/oauth-token` returns a status, never a value). Handing the token to the pod would put a live third-party credential in a shell, a transcript and a log, so Studio spends it and streams the bytes instead. `GET /api/:org/connections/:connectionId/jira/attachments/:attachmentId` The caller needs no new credential: a run already holds a Studio API key, the daemon writes it to `<repo>/.deco/tools/.endpoint.json`, and sandbox egress allows any public host on TCP/443, so the public URL is reachable from the pod. Two guards, both fail closed: - the connection must be an Atlassian one, or we would ship (say) a Notion token to api.atlassian.com; - the host is fixed and the cloudId is validated against the token's own accessible-resources, so the upstream URL is not caller-steerable. The 303 to the media CDN is followed by hand, one hop, host-checked. Gated by JIRA_ATTACHMENT_READ in BASIC_USAGE_TOOLS — same call as the org-fs keys: a member who can read the issue through the connection can already see the attachment, and an API key must still name it.
There was a problem hiding this comment.
4 issues found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/api/src/api/routes/jira-attachments.ts">
<violation number="1" location="apps/api/src/api/routes/jira-attachments.ts:182">
P2: When Atlassian is unreachable or the 30-second timeout fires, the fetch rejects before the status checks and the route returns a generic 500. Catch upstream fetch failures and return a consistent 502/504 response without exposing the exception.</violation>
<violation number="2" location="apps/api/src/api/routes/jira-attachments.ts:251">
P1: When Atlassian omits or underreports `Content-Length`, this branch streams the entire response and bypasses the advertised 25 MB cap. Count bytes in a `TransformStream`, abort the upstream after the limit, and only forward `Content-Length` when the streamed size is known.</violation>
</file>
<file name="apps/api/src/api/routes/jira-attachment-targets.ts">
<violation number="1" location="apps/api/src/api/routes/jira-attachment-targets.ts:60">
P1: When an Atlassian connection URL or attachment redirect uses HTTP, this guard allows it and the route can perform credential discovery/refresh or fetch the media over plaintext. Require HTTPS for both connection validation and redirect validation.</violation>
</file>
<file name="packages/e2e/tests/jira-attachment-proxy.spec.ts">
<violation number="1" location="packages/e2e/tests/jira-attachment-proxy.spec.ts:129">
P3: Test 5's title and leading comment claim it proves this route "is not a probe of another org," but it only requests the caller's own org slug with a bogus connection id and checks it 404s. It never references any other org, so it cannot demonstrate cross-org indistinguishability; the only true cross-org check is the non-member case in test 6. A maintainer could read this test as regression protection for cross-org probing that it doesn't provide.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if (Number.isFinite(declared) && declared > 0) { | ||
| headers["Content-Length"] = String(declared); | ||
| } | ||
| return new Response(upstream.body, { status: 200, headers }); |
There was a problem hiding this comment.
P1: When Atlassian omits or underreports Content-Length, this branch streams the entire response and bypasses the advertised 25 MB cap. Count bytes in a TransformStream, abort the upstream after the limit, and only forward Content-Length when the streamed size is known.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/api/routes/jira-attachments.ts, line 251:
<comment>When Atlassian omits or underreports `Content-Length`, this branch streams the entire response and bypasses the advertised 25 MB cap. Count bytes in a `TransformStream`, abort the upstream after the limit, and only forward `Content-Length` when the streamed size is known.</comment>
<file context>
@@ -0,0 +1,256 @@
+ if (Number.isFinite(declared) && declared > 0) {
+ headers["Content-Length"] = String(declared);
+ }
+ return new Response(upstream.body, { status: 200, headers });
+ },
+ );
</file context>
| try { | ||
| const parsed = new URL(url); | ||
| return ( | ||
| (parsed.protocol === "https:" || parsed.protocol === "http:") && |
There was a problem hiding this comment.
P1: When an Atlassian connection URL or attachment redirect uses HTTP, this guard allows it and the route can perform credential discovery/refresh or fetch the media over plaintext. Require HTTPS for both connection validation and redirect validation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/api/routes/jira-attachment-targets.ts, line 60:
<comment>When an Atlassian connection URL or attachment redirect uses HTTP, this guard allows it and the route can perform credential discovery/refresh or fetch the media over plaintext. Require HTTPS for both connection validation and redirect validation.</comment>
<file context>
@@ -0,0 +1,158 @@
+ try {
+ const parsed = new URL(url);
+ return (
+ (parsed.protocol === "https:" || parsed.protocol === "http:") &&
+ isAtlassianHost(parsed.hostname)
+ );
</file context>
| (parsed.protocol === "https:" || parsed.protocol === "http:") && | |
| parsed.protocol === "https:" && |
| // 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( |
There was a problem hiding this comment.
P2: When Atlassian is unreachable or the 30-second timeout fires, the fetch rejects before the status checks and the route returns a generic 500. Catch upstream fetch failures and return a consistent 502/504 response without exposing the exception.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/api/routes/jira-attachments.ts, line 182:
<comment>When Atlassian is unreachable or the 30-second timeout fires, the fetch rejects before the status checks and the route returns a generic 500. Catch upstream fetch failures and return a consistent 502/504 response without exposing the exception.</comment>
<file context>
@@ -0,0 +1,256 @@
+ // 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),
+ {
</file context>
| await ctx.dispose(); | ||
| }); | ||
|
|
||
| test("an unknown connection is a 404, not a probe of another org", async ({ |
There was a problem hiding this comment.
P3: Test 5's title and leading comment claim it proves this route "is not a probe of another org," but it only requests the caller's own org slug with a bogus connection id and checks it 404s. It never references any other org, so it cannot demonstrate cross-org indistinguishability; the only true cross-org check is the non-member case in test 6. A maintainer could read this test as regression protection for cross-org probing that it doesn't provide.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/e2e/tests/jira-attachment-proxy.spec.ts, line 129:
<comment>Test 5's title and leading comment claim it proves this route "is not a probe of another org," but it only requests the caller's own org slug with a bogus connection id and checks it 404s. It never references any other org, so it cannot demonstrate cross-org indistinguishability; the only true cross-org check is the non-member case in test 6. A maintainer could read this test as regression protection for cross-org probing that it doesn't provide.</comment>
<file context>
@@ -0,0 +1,180 @@
+ await ctx.dispose();
+ });
+
+ test("an unknown connection is a 404, not a probe of another org", async ({
+ playwright,
+ }) => {
</file context>
| test("an unknown connection is a 404, not a probe of another org", async ({ | |
| test("an unknown connection id under the caller's own org is a 404", async ({ |
Summary
Lets a sandbox agent fetch a Jira attachment's bytes — today it can enumerate one through the Atlassian MCP (
getJiraIssue→fields.attachment[]) but never read it.The gap was never networking — it was the credential. Atlassian's MCP exposes no fetch-the-bytes tool, and the connection's OAuth token stays in Studio's vault by design (
/oauth-tokenreturns a status, never a value). The alternative — leasing the token into the pod — would put a live third-party credential in a shell, a transcript and a log, so Studio spends the token and streams the bytes instead.The caller needs no new credential: a run already holds a Studio API key, and the daemon writes it to
<repo>/.deco/tools/.endpoint.json, so the agent can justcurlthis. Verified reachable from a live warm prod sandbox pod (netinitactive):https://studio.decocms.com/api/confighttps://api.atlassian.com/...http://example.com(port 80)Sandbox egress is port-based (
-p tcp --dport 443 ACCEPTafter REJECTing RFC1918/link-local), so the public URL is fine.Security
Two guards, both fail closed:
api.atlassian.com. Host check is a dot-boundary suffix match, soatlassian.com.evil.testdoes not pass, and a null/unparseable URL is refused.attachmentIdmust be digits; andcloudIdis validated against the token's ownaccessible-resources— an id the token can't see is refused rather than fetched. Several reachable sites with nocloudIdgiven is a 400 listing them, never a guess (silently picking the first would serve BR data for a GLOBAL card and read as a Jira bug).Also: the 303 to the media CDN is followed by hand, one hop, host-checked (
redirect: "manual") — an automatic follow would carry the request whereverLocationpoints; our bearer is not sent on the second hop; 25 MB cap; 30 s upstream timeout;no-store;content-dispositionfilename sanitized before it's echoed back to a shell.Authorization:
JIRA_ATTACHMENT_READ, added toBASIC_USAGE_TOOLSnext to the org-fs keys — same call, same reason: a 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.Testing
Unit — 19/19 green (
jira-attachment-targets.test.ts): every guard's pure half, including the look-alike hosts a naive suffix match would pass, traversal/query/absolute-URL attachment ids, the cloudId ambiguity and not-reachable branches, and hostilecontent-dispositionfilenames.E2E —
packages/e2e/tests/jira-attachment-proxy.spec.ts, 6 tests over the refusals (the happy path needs a live Atlassian token and is deliberately not stubbed — a fakeapi.atlassian.comwould assert our own mock). Locally 3 passed, covering the security-critical ones:The other 3 could not be verified locally: Better Auth rate-limits sign-up (
HTTP 429) after repeated local runs, and the untouchedorg-fs-cross-org-read.spec.tsfails all 5 of its tests the same way in the same environment — so it's the local limiter, not this route. Left to CI, which gets a fresh server.bun run fmt,bun run check,bun run lintandknipall clean.Follow-ups (not in this PR)
POST .../attachments, remote-linkPUT/DELETE). That's what unblocks the QA evidence flow end to end; today a run can capture screenshots withqa-screenshotbut cannot attach them to a card.osklen-jiraskill at this route once it's deployed. Its> Pending:notes currently say attachment bytes are unfetchable from a run, which is true until this ships.Summary by cubic
Adds a Jira attachment proxy so sandbox runs can fetch a Jira attachment's bytes instead of only enumerating them. Studio keeps the connection's OAuth token in the vault and streams the bytes through
GET /api/:org/connections/:connectionId/jira/attachments/:attachmentId[?cloudId=]; callers need no new credential.JIRA_ATTACHMENT_READ, added toBASIC_USAGE_TOOLS; API keys must name it or use*.cloudIdmust be in the token's accessible resources.no-store.Written for commit 197c873. Summary will update on new commits.