Skip to content

auth/security: hardcoded fallback HMAC key; password reset / passkey / email-verification hardening gaps; plaintext OAuth secrets #1861

@glennmichael123

Description

@glennmichael123

Severity: CRITICAL — token-forgeability when APP_KEY unset, passkey replay attacks, password-reset confusion, plaintext OAuth secrets, weak crypto defaults.

Findings

ID File:line Issue
A-1 auth/src/email-verification.ts:32, 42 Hardcoded 'stacks-default-key' fallback for HMAC when config.app.key is unset. Both generateVerificationToken and verifyToken use this literal. Anyone reading the source can forge email-verification tokens against installs that haven't set APP_KEY.
A-4 defaults/app/Actions/Auth/VerifyAuthenticationAction.ts:48-57 Passkey: action calls verifyAuthenticationResponse(..., userPasskey.counter) but never persists the new counter back. WebAuthn anti-cloning defeated — a stolen authenticator can be reused indefinitely. Also accepts challenge from the request body rather than a server-stored nonce → challenge replay attacks.
A-5 auth/src/password/reset.ts:62-75, 101-130, 138-200 + actions/src/auth/setup.ts:179-205 createResetToken() inserts a new row but never clears prior unused tokens for the same email. Verifier then selectFrom('password_resets').where('email', '=', email).executeTakeFirst() is DB-order-dependent. The password_resets schema has no expires_at column and no unique key on email/token. Single-use enforcement relies on DELETE … WHERE email = X only on the successful path.
M-1 auth/src/client.ts:11 + auth/src/tokens.ts:805-840 + actions/src/auth/setup.ts:232-244 oauth_clients.secret stored in plaintext. DB compromise hands attackers all client secrets, which (per Auth.parseToken flow) double as the AES-GCM passphrase for token-ID encryption. Laravel Passport hashes secrets in newer versions.
M-2 actions/src/auth/setup.ts:179-205 password_resets schema gaps: no expires_at, no email uniqueness, no foreign key. Combined with A-5, table is permissive enough to allow stale-token confusion.
M-3 auth/src/email-verification.ts reads email_verifications but actions/src/auth/setup.ts doesn't create it Either the migration files cover it (not visible from setup) or first call fails at runtime.
H-8 security/src/hash.ts:272-282 bcryptVerify / argon2Verify both delegate to Bun.password.verify, which auto-detects the algorithm. Passing a bcrypt hash to argon2Verify returns true. The function-signature contract is a lie.
M-9 pantry/ts-security-crypto/dist/index.js:8 PBKDF2 iterations only 100k. OWASP 2026 guidance for PBKDF2-SHA256 is ≥600k. The app key acts as the passphrase for every token-id encryption, so every AES-GCM call burns 100k PBKDF2 rounds — perf hit too.
M-10 security/src/key.ts:3-5 vs crypt.ts:9-14 key.ts generates base64:<...> but crypt.ts passes config.app.key directly to PBKDF2 without stripping. 8 fixed prefix bytes + 256-bit random = lowered entropy (not exploitable at 600k+ PBKDF2 but inconsistent).
L-2 auth/src/authentication.ts:107-114 validateClient short-circuit for non-existent client gives a timing oracle: executeTakeFirst() returns instantly for missing clients; existing clients run a buffer compare.
M-12 security/src/webhook.ts:228-232 verifyHmac algo=… prefix is stripped but not validated. Docstring claims "tolerates" the prefix but actually ignores it. Doesn't compromise security (HMAC is computed over digests with the algorithm parameter), but misleading.
L-6 security/src/webhook.ts:144-146 verifyStripe clock-skew check is asymmetric: rejects past timestamps outside tolerance but not future ones. Not exploitable without the secret, but worth a comment.

Recommended fixes

  • A-1: throw if config.app.key is missing; never silently fall back to a static string. Add a doctor check that flags this at boot.
  • A-4 (counter): persist verification.authenticationInfo.newCounter on success; reject if not strictly greater than stored.
  • A-4 (challenge): bind challenges to a server-stored nonce in a per-session row (or signed cookie); reject if the client-submitted challenge doesn't match the server's outstanding one.
  • A-5 + M-2: delete prior rows before insert (like email-verification does); index/select by token-hash + email; add expires_at TIMESTAMP NOT NULL to the schema with a unique constraint on email.
  • M-1: hash oauth_clients.secret at rest with Hash::make; store plaintext only in the response to auth:token; update validate paths to compare against the hash.
  • M-3: add email_verifications create statement to auth:setup or move it into a migration file and document the prerequisite.
  • H-8: bcryptVerify / argon2Verify should call detectAlgorithm(hash) and reject if it doesn't match the function name.
  • M-9: raise PBKDF2 to ≥600k, OR switch to a per-process derived key cached after the initial derivation (one-time cost).
  • M-10: strip the base64: prefix in encrypt/decrypt callers before deriving.
  • L-2: always run a dummy timingSafeEqual against a fixed buffer when the client is missing.

INVESTIGATE

  • The OAuth Authorization Code grant flow is absent entirely — the framework only implements Personal Access Tokens (Passport's PAT feature). If the roadmap intends real third-party OAuth (other apps acting on a user's behalf), the gap means there's no state / PKCE / redirect-URI matching / consent-screen scaffolding to evaluate yet. oauth_clients.redirect is hardcoded to http://localhost (createPersonalAccessClient).
  • TOTP (authenticator.ts) has no scratch/recovery code support — lost-device → permanent 2FA lockout, often driving users to disable 2FA.

Found via an internal audit pass over router, query-builder, and auth. Filed separately: SQL injection, CORS/router info disclosure, auth state/session safety, query-builder broken behaviors, type-safety holes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions