From 9423ffc3a7bfb14665ead34f6785b21953abbcf8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 03:52:29 +0200 Subject: [PATCH] Collation-drift guard + lookup-miss visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docker-compose.yml | 4 +++- src/db/database.js | 24 ++++++++++++++++++++++++ src/routes/auth.js | 3 +++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4bf14a5..6baaa59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/src/db/database.js b/src/db/database.js index 2682baf..b17ac18 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -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, diff --git a/src/routes/auth.js b/src/routes/auth.js index 94054fa..fcb22e3 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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' }); }