Skip to content

feat(api): proxy Jira attachment bytes with the connection's vaulted token - #6850

Open
pedrofrxncx wants to merge 1 commit into
mainfrom
fix/jira-attachment-proxy
Open

feat(api): proxy Jira attachment bytes with the connection's vaulted token#6850
pedrofrxncx wants to merge 1 commit into
mainfrom
fix/jira-attachment-proxy

Conversation

@pedrofrxncx

@pedrofrxncx pedrofrxncx commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Lets a sandbox agent fetch a Jira attachment's bytes — today it can enumerate one through the Atlassian MCP (getJiraIssuefields.attachment[]) but never read it.

GET /api/:org/connections/:connectionId/jira/attachments/:attachmentId[?cloudId=]
→ 200 + the bytes

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-token returns 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 just curl this. Verified reachable from a live warm prod sandbox pod (netinit active):

From the pod Result
https://studio.decocms.com/api/config 200, connect 14 ms
https://api.atlassian.com/... 401 — reached, unauthenticated
http://example.com (port 80) blocked

Sandbox egress is port-based (-p tcp --dport 443 ACCEPT after REJECTing RFC1918/link-local), so the public URL is fine.

Security

Two guards, both fail closed:

  1. The connection must be an Atlassian one. Without this, a Notion (or any other) connection's token would be shipped to api.atlassian.com. Host check is a dot-boundary suffix match, so atlassian.com.evil.test does not pass, and a null/unparseable URL is refused.
  2. The upstream URL is not caller-steerable. Host is hardcoded; attachmentId must be digits; and cloudId is validated against the token's own accessible-resources — an id the token can't see is refused rather than fetched. Several reachable sites with no cloudId given 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 wherever Location points; our bearer is not sent on the second hop; 25 MB cap; 30 s upstream timeout; no-store; content-disposition filename sanitized before it's echoed back to a shell.

Authorization: JIRA_ATTACHMENT_READ, added to BASIC_USAGE_TOOLS next 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 hostile content-disposition filenames.

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 fake api.atlassian.com would assert our own mock). Locally 3 passed, covering the security-critical ones:

  • non-Atlassian connection → 400 (and it's 400, not 403, which also proves the permission wiring)
  • look-alike host → 400
  • Atlassian connection with no stored token → 409 "reconnect it", not a 500 and not an unauthenticated call to Atlassian

The other 3 could not be verified locally: Better Auth rate-limits sign-up (HTTP 429) after repeated local runs, and the untouched org-fs-cross-org-read.spec.ts fails 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 lint and knip all clean.

Follow-ups (not in this PR)

  • Upload + Web Links — the same plumbing, opposite direction (POST .../attachments, remote-link PUT/DELETE). That's what unblocks the QA evidence flow end to end; today a run can capture screenshots with qa-screenshot but cannot attach them to a card.
  • Point the osklen-jira skill 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.
  • Worth checking before building more: whether the Atlassian MCP exposes attachments as MCP resources. If it does, some of this is unnecessary. Enumerating that needs the vaulted token, so I didn't.

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.

  • Access is gated by JIRA_ATTACHMENT_READ, added to BASIC_USAGE_TOOLS; API keys must name it or use *.
  • Only Atlassian connections are accepted; the upstream host is fixed and cloudId must be in the token's accessible resources.
  • The 303 to the media CDN is followed by hand, host-checked, and without forwarding the bearer token.
  • Responses are capped at 25 MB, time out after 30s, and are no-store.
  • Unit tests cover the guards; E2E tests cover the refusals; the happy path needs a live Atlassian token.

Written for commit 197c873. Summary will update on new commits.

Review in cubic

…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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:") &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
(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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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 ({

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant