fix(api/v2): don't scope autopatch's internal GET against api token permissions - #3541
Conversation
Preview DeploymentPreview deployments for this PR are available at:
The preview environment will start automatically on first visit. Subsequent pushes to this PR will update the Run locally with Dockerdocker pull ghcr.io/go-vikunja/vikunja:pr-3541
docker run -p 3456:3456 ghcr.io/go-vikunja/vikunja:pr-3541Last updated for commit 7cac168 |
tink-bot
left a comment
There was a problem hiding this comment.
🤖 PR Swarm — automated review, round 1. See inline comments.
|
🤖 PR Swarm — automated review. Panel: bughunter · security · conventions · design · verifier — round 3 @ Verdict: ✅ APPROVERounds 1 and 2 each found a live authorization bypass in the code this PR adds; both are fixed and pinned by regression tests that were verified to fail against the unfixed code. Round 3 attacked the result hard — 30+ crafted path encodings, 18 header variants, both patch content types, a JSON Patch read oracle, and a full scope matrix across all eight token-reachable autopatch routes, all observed end to end through the real router — and found no third bypass. Only a latent fail-open remained, now closed. The predicate is now structurally sound in a way the first two versions were not: it compares the leg's own matched route template against the parent's, so a skip mathematically implies the leg is running the same echo handler the client's PATCH was already authorised for, and the GET-only plus empty-query narrowing removes the two channels through which that handler could be made to do more than read the resource being patched. ❓ Needs your input (4)
✅ Fixed this round (1)
💤 Resolved as nit (1)
📝 Noted, not actioned
🔎 Runtime verificationBuilt and ran the branch against an isolated SQLite database; every result below is an observed status code plus a re-read of the target with the owner's JWT. No screenshots — API-only change.
Reviewer summaries
Previous rounds (2)round 1 @ `9ce3620` — 🚫 BLOCKED: encoded-slash pivot let an `update`-scoped token read and overwrite a task comment; fixed by binding the marker to the authorised route, plus audit-event gating and test isolation. Took 92m 36s.round 2 @ `441eac0` — |
tink-bot
left a comment
There was a problem hiding this comment.
🤖 PR Swarm — automated review, round 2. See inline comments.
tink-bot
left a comment
There was a problem hiding this comment.
🤖 PR Swarm — automated review, round 3. See inline comments.
2876c90 to
ca520de
Compare
Huma's AutoPatch implements PATCH as an internal GET + PUT re-dispatched through the router, so both legs re-enter the API token middleware. Scoping the GET leg like a client request made every /api/v2 PATCH additionally demand the resource's read_one permission, so a token scoped to tasks: update could never patch a task. Mark the re-dispatched requests with the route the client request was matched against, and skip the scope check only for a GET leg that carries no query string and resolves to that exact route. Anything looser is exploitable: echo routes on the raw path while autopatch re-dispatches the decoded one, so an encoded slash steers the unchecked leg onto a deeper route and an encoded question mark smuggles a query onto it. Fixes #3528
One client PATCH re-enters the token middleware three times, so it wrote three api_token.used audit entries, two of them for requests the client never made.
ca520de to
7cac168
Compare
Every
/api/v2PATCH with a scoped API token returned 401 unless the token also carried the resource'sread_onepermission. Huma's AutoPatch implements PATCH as an internal GET + PUT re-dispatched through the router, and the GET leg was matched against the token's permissions like a client request — so a token scoped totasks: updatecould never patch a task.Fix: mark requests going through the group prefix adapter (only path autopatch's re-dispatch takes) as internal, skip route check for them. Client PATCH still authorised normally,
read_onestill enforced for client GETs.Fixes #3528
How to verify
tasksscope limited toread_all,createandupdate— leaveread_oneunchecked."done": truein the response body.Before this PR: 401
{"code":11,"message":"missing, malformed, expired or otherwise invalid token provided"}.GET /api/v2/tasks/1directly.read_onepermission.