Repository navigation
security: secretless deployments accept admin tokens forged with the empty-string HMAC key #291
Description
Activity
- addedbugSomething isn't workingSomething isn't workingarea/apiHTTP handlers, routing, middlewareHTTP handlers, routing, middlewaresecuritySecurity-sensitive issue or fixSecurity-sensitive issue or fix
on Jun 6, 2026 - addedarea/policyAccess control policies (Hasura-style)Access control policies (Hasura-style)
on Jun 6, 2026 🔗 Related PRs
#123 - fix(api): drop CORS credentials + skip same-origin decoration [merged]
#137 - feat(deploy): local dev o11y stack (#121) [merged]
#172 - feat(rbac)!: fail-closed authorization + default_role public access [merged]
📝 Issue Planner
Check the box below or use the
@coderabbitai plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.- Create Plan
🧪 Issue enrichment is currently in open beta.
You can configure auto-planning by selecting labels in the issue_enrichment configuration.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:issue_enrichment: auto_enrich: enabled: false
💬 Have feedback or questions? Drop into our discord!
Cross-link: this is the acute, empty-secret instance of #228's "any holder of the HMAC secret can mint
admin" bullet. #228 tracks the general production-hardening class (post-alpha); this one is P0/pre-flip because the default secretless quickstart needs no secret holder at all.🤖 Posted autonomously via
/pm-triagewith Claude CodeWe warn loudly on jwt secrets that are not configured or empty. The admin role is forgeable in this case, but that is included in the assumption of the warn message where no token can be validated without a secret or jwks url set. This would be a configuration decision on the client's end.
- added a commit that references this issue
on Jun 8, 2026
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Summary
When neither
auth.jwt_secretnorauth.jwks_urlis configured — the defaultdeployments/compose/standalone.yamlquickstart posture — the JWT middleware verifies HMAC tokens against[]byte(""). An empty HMAC key is a valid key, so anyone can mint{"role":"admin"}signed with the empty string, passRequireAdmin, and reach/v1/admin/query(raw SQL), policy CRUD, schema, and DLQ. Secretless mode is documented and logged as "pure public / default_role only" — it is actually forgeable-admin.This contradicts the startup WARN, several code comments, and (newly, on PR #290) the docs.
Root cause
internal/auth/auth.go:70-92:jwt.ParsewithWithValidMethods(["HS256",...])and an empty-key keyFunc accepts a token an attacker signed with"". Tokenless requests correctly fall todefault_role; the hole is specifically a presented, forged token escalating past it.Proof (golang-jwt v5.3.1, the pinned version)
Using the exact keyFunc +
WithValidMethodsshape from the middleware:(token =
jwt.NewWithClaims(HS256, {"role":"admin","exp":9999999999}).SignedString([]byte("")))Exposure
deployments/compose/standalone.yamlpublishes8080:8080, sets noWH_AUTH_JWT_SECRET, and mounts noconfig.yaml— so a defaultdocker compose upof the quickstart is reachable-admin to anyone who can hit the port.Recommended fix (needs security review — fail-closed invariant, AGENTS.md #7/#13)
In
internal/auth.Middleware, whenjwks == nil && cfg.JWTSecret == "", treat every presented token as unverifiable — route it through the existing bad-token path (records the token error → resolves todefault_role→ fail-loud401on a gated denial). Tokenless requests are unchanged, so no documented public flow breaks; secretless mode becomes genuinely "public, default_role only" as everything already claims.After the code is fixed, these stale claims become true as written and can be closed out:
internal/auth/auth.go:40-42,internal/config/config.go:119, the WARN atcmd/wavehouse/main.go:76,config.yaml, the comment indeployments/compose/standalone.yamldocs/src/content/docs/access-control.md:43,configuration.md:81,getting-started.md:54,103,deployment.md:126(all pre-existing; PR feat(docs): live-demo hero panel via @wavehouse/sdk #290 deliberately did not add a sixth — it removed the one new instance it had introduced and tracks the rest here)Suggested hardening (separate)
Also warn/refuse at startup if
default_roleresolves to an admin-capable role, and consider rejecting thechange-me-in-productionplaceholder in non-dev.Found during the PR #290 docs-review gate; surfaced to the maintainer, who chose to track it here and keep the (unrelated) docs PR moving.