fix(security): salt password hashes with scrypt (CWE-328)#374
Open
sebastiondev wants to merge 2 commits into
Open
fix(security): salt password hashes with scrypt (CWE-328)#374sebastiondev wants to merge 2 commits into
sebastiondev wants to merge 2 commits into
Conversation
Passwords were hashed with a single unsalted SHA-256 digest, making the users table trivially crackable with precomputed rainbow tables if it ever leaked (e.g. via the admin backup download or a filesystem disclosure). - Replace hash.js with an async scrypt implementation using a per-user 16-byte random salt, serialized as scrypt$N$r$p$salt$hash. - Add verify() with constant-time comparison and a legacy branch that still accepts existing 64-char hex SHA-256 hashes. - Add needsRehash() and transparently upgrade legacy hashes to the new scheme on the next successful login (no forced password reset). - Make upsertUser/ensureAdminUserExists/ensureDemoUserExists async and await them at every call site. - Add unit tests covering salting, verification, legacy compatibility and rejection of malformed hashes.
Owner
|
Some checks are failing. please check |
Contributor
Author
|
Thanks for the ping. The failing check was |
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.
Summary
Fredy currently stores user passwords as unsalted SHA-256 digests (see
lib/services/security/hash.jsonmaster, which is justcrypto.createHash('sha256').update(password).digest('hex')). This is CWE-328 (Use of Weak Hash) combined with CWE-916 (Password Hash With Insufficient Computational Effort) and CWE-759 (Missing Salt).Any time the users table leaks — for example via the admin backup download, a filesystem disclosure, a stolen volume snapshot, or a misconfigured Docker mount — the stored hashes can be reversed in seconds with a precomputed rainbow table, or brute-forced trivially on commodity GPUs (SHA-256 sustains billions of guesses per second). Because self-hosted users often reuse passwords, the practical impact of a users-table leak is credential theft that extends well beyond Fredy itself.
Fix
lib/services/security/hash.jsis rewritten to:N=32768, r=8, p=1, 16-byte random salt, 64-byte key), serialized asscrypt$N$r$p$saltHex$hashHexso parameters can be evolved later without a schema change.verify(password, stored)with constant-time comparison (crypto.timingSafeEqual).verify()that still accepts existing 64-char hex SHA-256 hashes, so no user is locked out on upgrade.needsRehash(stored)— returnstruefor legacy hashes or weaker scrypt parameters.loginRoute, on a successful login against a legacy hash, transparently re-hash with the new scheme and persist. No forced password reset, no admin action required — existing installs migrate silently as users log in.hash()andverify()are now async; every call site (index.js,generalSettingsRoute,loginRoute,userRoute,userStorage.upsertUser/ensureAdminUserExists/ensureDemoUserExists) is updated toawaitthem.Tests
test/services/hash.test.jsadds coverage for:verify()accepts the correct password and rejects a wrong one;needsRehash()returnstruefor legacy andfalsefor freshly-produced hashes;yarn testpasses locally; lint is clean.Security analysis
Threat model: an attacker who obtains a copy of the users table (e.g. through the existing admin backup download endpoint, a leaked SQLite file, or a compromised backup). Before this fix, that read-only exposure is sufficient to recover plaintext passwords in minutes. After this fix, scrypt with a per-user salt raises the offline attack cost from "seconds on a laptop" to "computationally infeasible for any reasonable password", which is a genuine capability reduction beyond what the leak precondition alone provides. Timing-safe comparison also removes a theoretical side-channel on the verify path.
Adversarial review
Before submitting, we tried to disprove this finding. The main counter-arguments we considered were: (1) "the users table is only readable by admins, so leakage is already game-over" — but a leaked backup, stolen disk image, or accidental repo commit are all realistic exposure paths that don't require live admin access, and the shared-password blast radius extends beyond Fredy. (2) "Node/Fastify or the DB layer already provides password protection" — it doesn't; the hashing is entirely in
lib/services/security/hash.jsand was custom SHA-256. (3) "This is just a hardening nit" — CWE-328 with no salt is not a hardening nit; it converts a database read into plaintext credentials. The fix is minimal, backwards compatible, and covered by tests.Proof of concept
Reproduce on
masterbefore the fix:After this PR, the stored value looks like
scrypt$32768$8$1$<32 hex salt>$<128 hex hash>, is unique per user even when passwords collide, and is not present in any rainbow table.To confirm the migration path end-to-end: start a pre-fix Fredy, create a user, upgrade to this branch, log in with the same password — login succeeds and the stored hash is rewritten to the
scrypt$…form on that request (observable via a quickSELECT password FROM usersbefore and after).Discovered by the Sebastion AI GitHub App.