fix(auth): fail closed when the adminAuth roles join errors (#974)
Closes #968. The roles-join fallback in adminAuth fabricated role_name='super_admin' on ANY database error, so a transient fault (connection reset, deadlock, statement timeout, pool exhaustion) silently granted super_admin for its duration. roleName is the sole discriminator for every ownership check, so this inverted the authorization model rather than failing the request. Gate the fallback on isMissingRolesSchema(), moved to utils/dbErrors.js and shared with apiTokenAuth. The predicate was also tightened: knex prefixes the failing SQL to err.message and that SQL always names `roles`, so the old /roles/i gate was vacuous and a generic /does not exist/ could accept unrelated faults. Now trusts SQLSTATE 42P01/42703 on Postgres and exact driver phrasing on SQLite.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isMissingRolesSchema } = require('../utils/dbErrors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const TOKEN_PREFIX = 'pp_live_';
|
||||
@@ -32,24 +33,6 @@ function parseScopes(raw) {
|
||||
.filter((s) => VALID_SCOPES.includes(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this error mean the `roles` table/column genuinely isn't there yet
|
||||
* (mid-upgrade), as opposed to the database being briefly unhappy?
|
||||
*
|
||||
* The distinction matters because the fallback below grants super_admin: a
|
||||
* catch-all would turn any transient failure — connection reset, deadlock,
|
||||
* statement timeout — into a privilege escalation that hands a demoted viewer
|
||||
* exactly the access GHSA-9697 closes.
|
||||
*/
|
||||
function isMissingRolesSchema(err) {
|
||||
const message = String(err?.message || '');
|
||||
if (!/roles/i.test(message)) return false;
|
||||
// PG: 42P01 undefined_table / 42703 undefined_column. SQLite carries no
|
||||
// codes, so match its wording too.
|
||||
return err?.code === '42P01' || err?.code === '42703'
|
||||
|| /no such table|no such column|does not exist|unknown column/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: authenticate via API token. Maps the token to its owner
|
||||
* admin user, attaches { req.admin, req.apiToken }, then defers to the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isMissingRolesSchema } = require('../utils/dbErrors');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -83,6 +84,15 @@ async function adminAuth(req, res, next) {
|
||||
)
|
||||
.first();
|
||||
} catch (joinError) {
|
||||
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
|
||||
// the fallback below fabricates super_admin, so a transient query failure
|
||||
// (connection reset, deadlock, statement timeout, pool exhaustion) must
|
||||
// not become a free privilege upgrade for every scoped admin. Rethrow →
|
||||
// outer catch → 401, which is already how every other transient DB fault
|
||||
// in this try block behaves (isTokenRevoked / isTokenBeforeCutoff both
|
||||
// hit the DB here). apiTokenAuth takes the same posture on the v1
|
||||
// surface, differing only in its 500.
|
||||
if (!isMissingRolesSchema(joinError)) throw joinError;
|
||||
// Fallback: roles table may not exist yet during upgrade
|
||||
// Query without role join - user will have no role info but can still authenticate
|
||||
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
|
||||
|
||||
@@ -12,4 +12,37 @@ function isUniqueViolation(err) {
|
||||
return /unique/i.test(msg) || /sqlite_constraint/i.test(msg);
|
||||
}
|
||||
|
||||
module.exports = { isUniqueViolation };
|
||||
/**
|
||||
* Does this error mean the `roles` table/column genuinely isn't there yet
|
||||
* (mid-upgrade), as opposed to the database being briefly unhappy?
|
||||
*
|
||||
* The distinction matters because both auth paths fall back to granting
|
||||
* super_admin when the roles join fails: a catch-all would turn any transient
|
||||
* failure — connection reset, deadlock, statement timeout, pool exhaustion —
|
||||
* into a privilege escalation that hands a demoted viewer exactly the access
|
||||
* GHSA-9697 closes. Callers must rethrow anything this returns false for.
|
||||
*/
|
||||
function isMissingRolesSchema(err) {
|
||||
if (!err) return false;
|
||||
const message = String(err.message || '');
|
||||
|
||||
// Postgres is authoritative via SQLSTATE: 42P01 undefined_table, 42703
|
||||
// undefined_column. Both are schema conditions, never transient.
|
||||
if (err.code === '42P01' || err.code === '42703') return true;
|
||||
|
||||
// SQLite carries no SQLSTATE, so the driver's wording is all there is — but
|
||||
// it must be matched EXACTLY, naming the object the roles join needs. A
|
||||
// generic /does not exist/ test would be unsound here: knex prefixes the
|
||||
// failing SQL to err.message, and that SQL always names `roles` on this
|
||||
// join, so any "... does not exist" fault on the connection (e.g. pgbouncer
|
||||
// losing a named prepared statement, SQLSTATE 26000) would read as a missing
|
||||
// roles schema and fabricate super_admin.
|
||||
//
|
||||
// Two states are legitimate, per the migration order:
|
||||
// pre-054 → roles table absent
|
||||
// post-054, pre-057 → roles exists, admin_users.role_id not added yet
|
||||
return /no such table: roles\b/i.test(message)
|
||||
|| /no such column: (roles\.|admin_users\.role_id\b)/i.test(message);
|
||||
}
|
||||
|
||||
module.exports = { isUniqueViolation, isMissingRolesSchema };
|
||||
|
||||
Reference in New Issue
Block a user