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:
Daniel 2026-04-14 03:52:29 +02:00
parent 43d26fd306
commit 9423ffc3a7
3 changed files with 30 additions and 1 deletions

View file

@ -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

View file

@ -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,

View file

@ -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' });
}