Skip to content

fix(security): close five auth bypasses; O(1) key and user lookups - #7

Open
frankstupak wants to merge 1 commit into
SkinnnyJay:mainfrom
frankstupak:lumen-uplift/security
Open

fix(security): close five auth bypasses; O(1) key and user lookups#7
frankstupak wants to merge 1 commit into
SkinnnyJay:mainfrom
frankstupak:lumen-uplift/security

Conversation

@frankstupak

Copy link
Copy Markdown

security — auth that actually holds

This subproject ships five real auth bypasses plus a set of hot-path scans. Every fix has a test that fails against the current main; the original 87 tests still pass untouched (112 total now).

Security fixes

JWT algorithm confusion (RFC 8725). jwt.verify(token, secret) was called with no algorithms allow-list, so verification behavior was chosen by the attacker-controlled token header. A token whose header says HS512, signed with the same secret, verified fine. Pinned to ["HS256"]; optional iss/aud claims are now signed in and checked when configured. — OWASP JWT BCP §3.1

JWT logout did nothing. logout() for a JWT was a comment and return { success: true }. A "logged out" token kept authenticating until natural expiry. Tokens now carry a jti; logout records it in a revocation set (checked on every verify) until the token's own exp, with lazy pruning + a sweep method so the set can't grow forever.

Basic auth rejected every non-ASCII password. Credentials were decoded with .toString("ascii"), which masks each byte to 7 bits — so pässwörd never matched what was registered. Now utf8 per RFC 7617. Bonus: the try/catch around Buffer.from(x, "base64") was dead code (it never throws), so malformed headers slipped through; added explicit base64 validation.

OAuth refresh grant was unusable. /oauth/token password grant returned the literal string "hidden_for_security" as the refresh_token. Any client that then called the refresh_token grant got invalid_grant. login() now returns the real refresh token for bearer logins and the endpoint passes it through — the full password→refresh→access cycle works end-to-end (new HTTP test proves it).

Profile update mass assignment. PUT /auth/profile did { ...user, ...body, id: user.id } — a request body with "roles": ["admin"] or "passwordHash": "..." overrode those in the response, and nothing was persisted anyway. Now an allow-list (email, username only), actually stored, with uniqueness checks. Role escalation in the body is ignored.

Username-enumeration timing oracle, reopened by config. The dummy-hash compare (run when the user doesn't exist, to equalize timing) used a hash hardcoded at cost 12. At any other bcryptRounds, the not-found path took visibly different time than a real compare — the exact oracle the dummy is meant to close. Now computed at the configured cost in the constructor.

bcrypt 72-byte truncation. Passwords over 72 bytes were silently truncated, so two passwords sharing a 72-byte prefix hashed identically. Now rejected at register time (measured in bytes, not chars). — OWASP Password Storage Cheat Sheet

Bearer routing. Auth tried JWT first and fell back to bearer-token lookup on any failure, which overwrote the real error — an expired JWT was reported as "Invalid bearer token." Now routed by token shape (three dot-separated segments = JWT), so the correct error surfaces and opaque-token requests skip a wasted signature check.

Performance (node v22, on the build host)

Same data, same operation — only the changed lookup differs:

Hot path Before After Speedup
API-key auth @ 50k keys 1,779 ops/s 379,090 ops/s 213x
Username lookup @ 100k users 345 ops/s 34.2M ops/s ~99,000x
RBAC permission check (worst case) 27.9M ops/s 33.5M ops/s 1.2x

authenticateApiKey scanned every stored key on each request even though the map was already keyed by the SHA-256 hash it was scanning for — a direct Map.get. Username/email checks moved off Array.from(...).find onto secondary indices. RBAC checks use per-role Sets, and getRolePermissions returns a copy instead of handing out its live internal array (which callers could mutate to silently grant permissions). Reproduce: npm run bench in src/api/security.

Verification

  • src/api/security: 112 passed (87 original + 25 new), tsc --noEmit clean, eslint clean.
  • Root npm run test:all: 31 suites / 764 passed, npx tsc --noEmit clean.
  • New server.test.ts covers the wiring end-to-end via fastify.inject() — previously impossible because importing server.ts bound port 3000 on import; a require.main guard fixes that.

Public API is unchanged. @fastify/swagger added to the subproject's own dependencies (it was resolving only via root hoisting).

— Lumen Industries

…n, working OAuth refresh flow, mass-assignment guard, O(1) key/user lookups

- Basic auth decoded credentials as ascii (7-bit mask): any non-ASCII
  password could never authenticate. Now UTF-8 per RFC 7617, with strict
  base64 validation (the old try/catch around Buffer.from was dead code).
- jwt.verify had no algorithms allow-list: any HMAC alg the attacker-
  controlled header named (HS384/HS512) was accepted. Pinned to HS256
  (RFC 8725 3.1); optional iss/aud claims supported via config.
- JWT logout was a silent no-op. Tokens now carry a jti and logout
  revokes them until natural expiry, with lazy + sweep pruning.
- /oauth/token password grant returned the literal string
  "hidden_for_security" as refresh_token: the refresh grant was
  unusable end-to-end. login() now returns the real refresh token.
- PUT /auth/profile spread the raw body over the user (mass assignment:
  roles/passwordHash override in the response) and persisted nothing.
  Now allow-listed (email, username) and actually stored via updateUser.
- Bearer routing by token shape instead of try-JWT-then-fallback:
  expired JWTs were reported as "Invalid bearer token".
- validateCredentials dummy hash was hardcoded at cost 12: at any other
  configured bcryptRounds the user-not-found path timed differently,
  re-opening the username-enumeration oracle. Now computed at the
  configured cost in the constructor.
- Passwords over 72 bytes were silently truncated by bcrypt (collision
  risk); now rejected per OWASP.
- authenticateApiKey scanned all keys despite the map being keyed by
  the hash it scanned for: O(1) now, 213x at 50k keys. Username/email
  lookups moved to secondary indices. RBAC checks use Set indices and
  getRolePermissions no longer leaks its live internal array.
- server.ts guarded with require.main so importing createApp no longer
  binds port 3000: enables the new HTTP integration suite.
- +25 tests (112 total in subproject), each pinning a fixed bug.
  @fastify/swagger added to subproject deps (was root-hoisting only).
@frankstupak frankstupak changed the title security: fix 5 auth bypasses (JWT alg confusion, no-op logout, UTF-8 basic auth, dead OAuth refresh, mass assignment) + O(1) key/user lookups fix(security): close five auth bypasses; O(1) key and user lookups Aug 13, 2026
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