Skip to content

fix(apikey): block API-key auth from /api-keys routes (post-#16 hotfix)#17

Merged
dedeez14 merged 1 commit intomainfrom
devin/1777104375-apikey-session-guard
Apr 25, 2026
Merged

fix(apikey): block API-key auth from /api-keys routes (post-#16 hotfix)#17
dedeez14 merged 1 commit intomainfrom
devin/1777104375-apikey-session-guard

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 25, 2026

Summary

Hotfix for a privilege-escalation path that landed with PR #16. The /api/v1/api-keys self-service routes were mounted under the plain authed group, so a caller presenting a narrow-scoped API key (e.g. scopes=["reports.read"]) could still call POST /api/v1/api-keys and mint a wildcard-scoped successor for itself — one-hop escalation that defeats the PR's own invariant that a service key's scope is an upper bound on what it can do. Ships the RequireUserSession() middleware and wires the /api-keys subtree behind it so credential management is only reachable from an interactive JWT session.

Why

PR #16 review flagged this vector; the fix commit (f0e4d1f) was ready on the source branch but the merge happened one commit earlier (a119779), so main shipped with the gap still open. This PR closes it.

How

  • internal/adapter/http/middleware/apikey.go — new RequireUserSession() fiber.Handler. It checks c.Locals(CtxKeyAPIKeyScopes): when non-nil (i.e. the request came in on an API-key bearer) it returns 403 apikey.user_session_required. When nil (JWT path) it calls c.Next(). Purely structural — there's no scope-math to get wrong.
  • internal/infrastructure/server/router.go/api-keys moved into its own sub-group layered on top of authed with RequireUserSession() as a second middleware. JWT bearers are unaffected; API-key bearers get a clean 403 regardless of payload.
  • Regression test TestRequireUserSession_RejectsAPIKeyAuth covers both legs (API-key → 403, JWT → 200) so a future refactor that removes the sub-group breaks the suite.

Strict "no credential management from API keys" was chosen over a subset-scope check because the latter still permits a leaked key to revoke the owner's other keys (DoS) and to rotate itself (evading the owner's revoke).

Test plan

  • make lint clean (golangci-lint run ./...)
  • make test clean (go test -race ./internal/adapter/http/middleware/...)
  • Manual reproduction: curl -H "Authorization: Bearer gf_test_aaaaaaaaaaaa_<secret>" -X POST /api/v1/api-keys → 403 with apikey.user_session_required; same call with a JWT → 201.
  • Migrations applied + rolled back successfully (no migration changes)

Risk

  • Low. Additive middleware; only affects /api/v1/api-keys. JWT callers see no change. API-key callers of those three endpoints now get 403 instead of succeeding — which is the intended security fix.
  • Rollback: revert this PR; the vulnerability returns, so don't.

Checklist

  • Doc comments updated for any public API change
  • No secrets / debug logs / fmt.Println left behind

Link to Devin session: https://app.devin.ai/sessions/8fdfc20358514c97a766adca630a2527
Requested by: @dedeez14


Open in Devin Review

…#16)

Critical finding confirmed. The /api-keys self-service routes were
registered under the plain authed group with no structural guard,
so a narrow-scoped API key could call POST /api/v1/api-keys with
scopes=["*"] and mint a wildcard successor for itself - one-hop
privilege escalation that defeats the PR's own invariant that a
service key's scope is an upper bound on what the caller can do.

Took the strict option (approach 3 from the review prompt) because
it's the only one that's safe by default:

  - RequireUserSession() middleware in middleware/apikey.go
    returns 403 with code apikey.user_session_required when
    CtxKeyAPIKeyScopes is set on Locals - i.e. the request came
    in with an API-key bearer.
  - server/router.go wraps GET/POST/DELETE /api-keys in an
    authed.Group("/api-keys", RequireUserSession()). JWT bearers
    still work, API-key bearers get 403 regardless of payload.

Why not subset-check the scopes (approach 2)? Because that still
lets an API key manage credentials, which means a leaked key can
*revoke* the legitimate owner's other keys (denial of service) and
rotate the leaked key itself (evading the owner's revoke). Strict
"no credential management from API keys" is the simpler and more
auditable contract; future admin tooling that legitimately needs
it can use a JWT-exchange flow rather than a scope.

Tests:
  - TestRequireUserSession_RejectsAPIKeyAuth: covers both the
    reject (API-key bearer → 403) and pass-through (JWT bearer →
    200) paths. Regression for the escalation vector.

go test -race ./internal/... clean, golangci-lint clean.

Co-Authored-By: dede febriansyah <febriansyahd65@gmail.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@dedeez14 dedeez14 merged commit ea60dc7 into main Apr 25, 2026
3 checks passed
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