Collation-drift guard + lookup-miss visibility
Root cause of recent "invalid credentials on correct password" was
a silent btree index corruption: pgvector/pgvector:pg16 was pulled
with a different ICU library than the one used to build existing
indexes. Queries returned 0 rows even though matching heap rows
existed. Postgres logged nothing (corrupt index → empty result set
is a "successful" query) and the login path never logged unknown-
user attempts (enumeration protection).
Three defenses:
1. Pin postgres image by digest in docker-compose.yml so a
silent pull can't change ICU under our feet.
2. Startup collation-drift check in src/db/database.js:
compares pg_database.datcollversion to the library's actual
version and, on mismatch, runs REINDEX DATABASE + ALTER
DATABASE REFRESH COLLATION VERSION. Logs "Collation versions:
aligned" on clean boot.
3. Server-side console.warn on login lookup-miss (no email, no
audit row — preserves enumeration protection but gives
Grafana/Loki a signal for unusual miss rates).
This commit is contained in:
parent
43d26fd306
commit
9423ffc3a7
3 changed files with 30 additions and 1 deletions
|
|
@ -21,7 +21,9 @@ services:
|
|||
start_period: 20s
|
||||
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
# Pinned by digest — changing the pgvector image can change the bundled
|
||||
# ICU library and corrupt existing text indexes. REINDEX after any bump.
|
||||
image: pgvector/pgvector:pg16@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff
|
||||
environment:
|
||||
POSTGRES_DB: pedscribe
|
||||
POSTGRES_USER: pedscribe
|
||||
|
|
|
|||
|
|
@ -26,6 +26,30 @@ async function initDatabase() {
|
|||
console.warn('⚠️ pgvector extension not available. Vector search disabled. Install: apt-get install postgresql-16-pgvector');
|
||||
}
|
||||
|
||||
// Collation-drift check — btree indexes on text become silently corrupt
|
||||
// when the ICU / glibc library version bundled with the postgres image
|
||||
// changes. pg_database tracks the collation version used when the DB
|
||||
// was created/last refreshed; if it differs from what the library now
|
||||
// reports, REINDEX and REFRESH to clear the flag.
|
||||
try {
|
||||
var dbDrift = await client.query(
|
||||
"SELECT datcollversion AS recorded, " +
|
||||
"pg_database_collation_actual_version(oid) AS actual " +
|
||||
"FROM pg_database WHERE datname = current_database()"
|
||||
);
|
||||
var row = dbDrift.rows[0] || {};
|
||||
if (row.recorded && row.actual && row.recorded !== row.actual) {
|
||||
console.warn('⚠️ Collation drift: DB recorded ' + row.recorded + ', library reports ' + row.actual + '. REINDEX-ing to prevent silent index corruption...');
|
||||
await client.query('REINDEX DATABASE pedscribe');
|
||||
await client.query('ALTER DATABASE pedscribe REFRESH COLLATION VERSION').catch(function() {});
|
||||
console.log('✅ REINDEX complete. Collation version refreshed.');
|
||||
} else {
|
||||
console.log('✅ Collation versions: aligned');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Collation check skipped:', e.message);
|
||||
}
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -305,6 +305,9 @@ router.post('/login', async (req, res) => {
|
|||
// the same generic message for unknown-user, wrong-password, disabled, unverified.
|
||||
var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.';
|
||||
if (!user) {
|
||||
// Server-side only — no email in the message, so operators can watch
|
||||
// lookup-miss rates in Grafana without leaking which addresses exist.
|
||||
console.warn('[Auth] login: user not found (ip=' + req.ip + ')');
|
||||
await bcrypt.compare(password, DUMMY_HASH).catch(function(){});
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue