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 inAuthInterceptor: the request context is rebuilt from the signed token only. JustapprovalId,restartedApprovalIdandsingleApprovalPreviewmay come from the client, and only when the token does not set them. -
A client-supplied
approvalId/restartedApprovalIdcan 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.
-
ensureAccessno longer falls back to an API check when the token carriesrefId/collectionId; JSM customers are verified against their own portal requests. -
ensureAccessadded 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+collectionIdfrom the token. -
Bulk delete/archive,
/approvals/versionsand 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 — |
|
2.3 |
P1 |
U1 |
Version history on the approval containing the HTTP step |
URL, headers, body masked as |
|
2.4 |
P1 |
U2 |
Delegations → "Usage in approvals" for the delegation scoped to JIRA-2 |
Rejected with |
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:issueContextandjira:issueActivityas U1 — same work item as 1.1, different token shape. -
jiraServiceManagement:portalRequestDetailand the portal user menu as U5 — the agent token differs from U4's customer token. -
confluence:spacePage(CONF-1) as U1 andconfluence:globalSettingsas U3. -
jira:dashboardGadgetas 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 |
|
No JIRA-2 data in either |
|
4.2 |
P1 |
|
No JIRA-2 data |
|
4.3 |
P1 |
|
Not returned |
|
4.4 |
P1 |
|
Still personal scope, not all approvals |
|
4.5 |
P1 |
|
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 |
|
Rejected |
|
4.8 |
P1 |
|
Nothing returned for them |
|
4.9 |
P1 |
|
Rejected |
|
4.10 |
P1 |
Same, body |
Rejected |
|
4.11 |
P2 |
|
All ignored; no new buttons, no new rights |
|
4.12 |
P2 |
Same substitutions in the form body of |
All rejected |
|
4.13 |
P2 |
Portal call replayed with another customer's request id (as U4) |
Rejected |
|
4.14 |
P2 |
|
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 |