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
9b407d1e18
commit
b23cb3300e
3 changed files with 30 additions and 1 deletions
|
|
@ -21,7 +21,9 @@ services:
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
postgres:
|
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:
|
environment:
|
||||||
POSTGRES_DB: pedscribe
|
POSTGRES_DB: pedscribe
|
||||||
POSTGRES_USER: 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');
|
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(`
|
await client.query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
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.
|
// the same generic message for unknown-user, wrong-password, disabled, unverified.
|
||||||
var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.';
|
var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.';
|
||||||
if (!user) {
|
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(){});
|
await bcrypt.compare(password, DUMMY_HASH).catch(function(){});
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue