fix(security): close five auth bypasses; O(1) key and user lookups - #7
Open
frankstupak wants to merge 1 commit into
Open
fix(security): close five auth bypasses; O(1) key and user lookups#7frankstupak wants to merge 1 commit into
frankstupak wants to merge 1 commit into
Conversation
…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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
security— auth that actually holdsThis 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 noalgorithmsallow-list, so verification behavior was chosen by the attacker-controlled token header. A token whose header saysHS512, signed with the same secret, verified fine. Pinned to["HS256"]; optionaliss/audclaims are now signed in and checked when configured. — OWASP JWT BCP §3.1JWT logout did nothing.
logout()for a JWT was a comment andreturn { success: true }. A "logged out" token kept authenticating until natural expiry. Tokens now carry ajti; logout records it in a revocation set (checked on every verify) until the token's ownexp, 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 — sopässwördnever matched what was registered. Nowutf8per RFC 7617. Bonus: thetry/catcharoundBuffer.from(x, "base64")was dead code (it never throws), so malformed headers slipped through; added explicit base64 validation.OAuth refresh grant was unusable.
/oauth/tokenpassword grant returned the literal string"hidden_for_security"as therefresh_token. Any client that then called therefresh_tokengrant gotinvalid_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/profiledid{ ...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,usernameonly), 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:
authenticateApiKeyscanned every stored key on each request even though the map was already keyed by the SHA-256 hash it was scanning for — a directMap.get. Username/email checks moved offArray.from(...).findonto secondary indices. RBAC checks use per-roleSets, andgetRolePermissionsreturns a copy instead of handing out its live internal array (which callers could mutate to silently grant permissions). Reproduce:npm run benchinsrc/api/security.Verification
src/api/security: 112 passed (87 original + 25 new),tsc --noEmitclean,eslintclean.npm run test:all: 31 suites / 764 passed,npx tsc --noEmitclean.server.test.tscovers the wiring end-to-end viafastify.inject()— previously impossible because importingserver.tsbound port 3000 on import; arequire.mainguard fixes that.Public API is unchanged.
@fastify/swaggeradded to the subproject's owndependencies(it was resolving only via root hoisting).— Lumen Industries