Unauthorized access to approvals and work item data via request parameter tampering (context-based permission check bypass)

Description

Problem

The signed app token carries the authorization context (refId, collectionId, permission flags). The auth layer merged all request parameters — query string and form body — into that context, and merged values won for any key the token did not set. Any authenticated user could therefore append ?collectionId=<other project> or ?refId=<other issue> to a REST call and pass the access check, reading and acting on approvals and work item data outside their permissions.

Several endpoints also loaded an approval by id and acted on it without re-checking access to its reference.

Changes

Token context

  • New TokenContextSanitizer, applied in AuthInterceptor: the request context is rebuilt from the signed token only. Just approvalId, restartedApprovalId and singleApprovalPreview may come from the client, and only when the token does not set them.

  • A client-supplied approvalId / restartedApprovalId can only resolve to an approval inside the token's reference (or collection), not an arbitrary one.

Access checks

  • A reference-scoped token can no longer reach a different reference by matching the collection.

  • ensureAccess no longer falls back to an API check when the token carries refId/collectionId; JSM customers are verified against their own portal requests.

  • ensureAccess added to: create, decide, reassign step, archive, delete, edit validity, update priority, notification + preview, users-for-notification, AI prompt preview.

  • Reference view and reference activity now require refId + collectionId from the token.

  • Bulk delete/archive, /approvals/versions and delegation usages filter results to approvals the caller may see; versions are also sanitized (HTTP step templates/errors).

  • Delegation usages require a delegation the caller participates in, or admin of a collection the delegation covers.

Confluence Forge token scope

  • A content/space id claimed by the frontend is verified as-user; the space is derived from the page, and a claimed space that does not own that page is rejected. Space key is resolved server-side instead of being trusted from the request.

  • Permissions are computed from the resolved scope rather than the raw invocation token.

Hardening / performance

  • Access denials return a plain No access; reference, collection and delegation ids go to warning logs only, so responses cannot be used for enumeration.

  • Reference access checks are cached (60s) and resolved in one batched as-user query per request instead of one call per reference.

No DB migration, no new config properties. Backend and Forge frontend can be deployed in either order.


Manual testing

Every step exercises code this branch changed. Nothing is listed "just in case" — screens that only read signed permission flags cannot be affected by the rebuilt context, and they are in the not affected list at the end.

Naming used below

Jira's newer UI calls projects "spaces" as well, so plain "space" is ambiguous. This ticket uses:

  • JIRA-1 / JIRA-2 — two Jira projects (JIRA-1 accessible to the test users, JIRA-2 not).

  • CONF-1 / CONF-2 — two Confluence spaces (CONF-1 accessible, CONF-2 not).

In the app's code and in the Changes section above, both are a collection: collectionId holds a Jira project id or a Confluence space id depending on the product.

Test accounts

Account

Who they are

Access

U1

Regular user, approver

Browse + edit in JIRA-1 and CONF-1. No access to JIRA-2 / CONF-2

U2

Jira project admin / Confluence space admin

Admin of JIRA-1 and CONF-1 only. Not admin of JIRA-2 / CONF-2

U3

Global admin

Jira / Confluence admin, everything

U4

JSM portal customer

Two or more of their own requests in a portal; no Jira licence

U5

JSM agent

Agent on the same service project

Test data

  • Approvals in JIRA-1, JIRA-2, CONF-1, CONF-2. U1 must be an approver on the ones in JIRA-1 and CONF-1, otherwise the decide steps cannot be performed.

  • One approval in JIRA-1 with several versions (edit the definition and restart it).

  • One restarted approval in JIRA-1, so the restarted-approval preview dialog has an origin.

  • One approval in JIRA-1 containing an HTTP step whose URL, headers and body hold a recognisable secret.

  • One approval in JIRA-1 with a failed notification (point a webhook at an unreachable URL).

  • One delegation scoped to JIRA-2, already applied to an approval.

  • One approval on a work item in JIRA-2 where U1 is an approver but cannot browse it — add U1 as approver first, then remove their browse permission on JIRA-2. Several steps depend on this one; it is the only way to reach another project's approval through the UI.

  • One approval on a U4 portal request, flagged visible to JSM customers.

App tokens live 12h — hard-reload after a deploy. Access decisions are cached ~60s — wait a minute after changing any permission.

How much to run

P1 = must pass before release. P2 = full regression, run when there is time. P1 is about 20 checks and covers every rewritten code branch at least once.

1. Happy path — nothing broke

Every row runs through an access check that was rewritten. A failure here is a regression.

Rows 1.2, 1.3 and 1.4 look repetitive but are the three branches of the rewritten access check — a work-item token, a project token, and a token with neither — so all three are needed.

#

P

As

Do

Expect

1.1

P1

U1

On a work item in JIRA-1: Definitions tab, then start an approval

Definitions list, approval starts

1.2

P1

U1

Approve / reject / vote from the work item panel

Decision recorded

1.3

P1

U1

Same decision from Approvals on the global page

Works

1.4

P1

U1

Same decision from the JIRA-1 project page

Works

1.5

P1

U1

Expand version history on the multi-version approval

All versions render

1.6

P1

U1

Select several of their own pending approvals → bulk decide

All decided

1.7

P1

U1

On a page in CONF-1: byline item, content action, approval-summary macro

All load, space name correct

1.8

P1

U2

On the JIRA-1 project page: select 5 approvals → bulk archive, then bulk delete

All succeed, counts correct

1.9

P1

U4

Portal request with an approval: view and approve it

Works

1.10

P1

U4

Portal user menu → personal approvals

Only U4's own requests

1.11

P2

U1

Activity tab on the work item, then Activity on the global page

Both list entries

1.12

P2

U1

Open the restarted-approval preview dialog

Origin approval shown

1.13

P2

U2

On a work item in JIRA-1: reassign a step, edit validity date, change priority

All succeed

1.14

P2

U2

Archive, then delete a single approval in JIRA-1

Both succeed

1.15

P2

U2

JIRA-1 project page and CONF-1 space settings: open the definitions list

Loads, scoped to that project / space

1.16

P2

U2

Send a custom reminder and open its preview

Preview renders, reminder sent

1.17

P2

U2

Notification failures on the approval with a failed notification

Failure listed

1.18

P2

U2

Conditions → a condition's config usages

Usages listed, only from JIRA-1

1.19

P2

U2

Delegations → "Usage in approvals" for a delegation covering JIRA-1

Usages listed

1.20

P2

U3

AI settings: prompt template preview; rerun an AI step from a work item

Both work

While running these, check the action buttons each user sees match dev. The one non-obvious rule: on the global page, delete / archive / notify are available to global admins only — U1 and U2 must not see them there, even though U2 does on the JIRA-1 project page.

Bulk archive and delete are admin-only outside a work-item view — that is why 1.8 is U2 on a project page while 1.6 is U1 doing bulk decide, which any approver may do.

2. Must now be blocked — reachable in the UI

#

P

As

Do

Expect

2.1

P1

U1

Global page → Approvals, look for anything from JIRA-2

Only approvals where U1 participates; no other JIRA-2 data

2.2

P1

U1

Decide the approval in JIRA-2 where U1 is approver but cannot browse

Denied — No access flag. See "Decisions to confirm"

2.3

P1

U1

Version history on the approval containing the HTTP step

URL, headers, body masked as abc**************xyz; no secret readable

2.4

P1

U2

Delegations → "Usage in approvals" for the delegation scoped to JIRA-2

Rejected with No access

Everything else that must be blocked needs a replayed request, because the UI gives no way to point at another project — that is section 4.

3. Modules not covered above — P2

Each Forge module mints a different token, so passing in one proves nothing about another:

  • jira:issueContext and jira:issueActivity as U1 — same work item as 1.1, different token shape.

  • jiraServiceManagement:portalRequestDetail and the portal user menu as U5 — the agent token differs from U4's customer token.

  • confluence:spacePage (CONF-1) as U1 and confluence:globalSettings as U3.

  • jira:dashboardGadget as U1 (non-admin) — must show personal scope only, never every approval in the instance.

4. Tampering — as U1 unless stated

Open any Approval Path screen, DevTools → Network, right-click a /forge/.../rest/... request → Copy as cURL, paste into a terminal and edit. Keep the jwt parameter exactly as-is — that is the whole point: the token stays valid while the other parameters change. Jira project and work item ids come from /rest/api/3/project and /rest/api/3/issue/<KEY>; Confluence space and page ids from /wiki/api/v2/spaces and /wiki/api/v2/pages.

#

P

Add or change

Expect

4.1

P1

collectionId=<JIRA-2 id> — then repeat on condition config usages as U2

No JIRA-2 data in either

4.2

P1

refId=<work item id in JIRA-2>

No JIRA-2 data

4.3

P1

approvalId=<approval in JIRA-2> on the work item list

Not returned

4.4

P1

user_is_admin=true on the gadget request

Still personal scope, not all approvals

4.5

P1

isJsmCustomer=false (as U4)

Ignored, still treated as a customer

4.6

P1

Bulk delete / archive with an approval id from JIRA-2 added (as U2, from the JIRA-1 project page)

JIRA-2 item errors, nothing deleted or archived there

4.7

P1

?delegationId=<delegation scoped to JIRA-2> (as U2)

Rejected

4.8

P1

POST /rest/approvals/versions with ids from JIRA-2

Nothing returned for them

4.9

P1

POST /forge/confluence/api/atlassian-forge-app body {"contentId":"<page in CONF-2>"}

Rejected

4.10

P1

Same, body {"contentId":"<page in CONF-1>","spaceId":"<CONF-2 id>"}

Rejected

4.11

P2

user_is_collection_admin=true, hasResetApprovalPermission=true, hasArchiveApprovalPermission=true, canEditRef=true — all at once

All ignored; no new buttons, no new rights

4.12

P2

Same substitutions in the form body of POST /forge/jira/action, not the query string

All rejected

4.13

P2

Portal call replayed with another customer's request id (as U4)

Rejected

4.14

P2

?delegationId=abc, then ?delegationId=1.5

HTTP 400 with a clear message, not 500

Every rejection must return No access and contain no work item, page, project, space or delegation id anywhere in the response body.

Note for 4.9 and 4.10: the token endpoint answers HTTP 200 with an $error message rather than an error status, and the app shows "Authorization failed. Please verify the app installation." That is the rejection — a 200 here does not mean it passed.

5. Confluence page and space scope

#

P

As

Do

Expect

5.1

P1

U3

From a page in CONF-1, navigate in Confluence to a page in CONF-2 without a full reload

App follows, space name updates

5.2

P1

U1

Open a page they cannot view

Authorization error, no data

5.3

P2

any

Open a page in a space the app's technical user cannot access

The "does not have access to this Confluence space" message