fix(auth): fail closed when the adminAuth roles join errors (stable) (#975)
Closes #968 on stable. Backport of #974. The roles-join fallback in adminAuth fabricated role_name='super_admin' on ANY database error, so a transient fault silently granted super_admin for its duration. Gate it on isMissingRolesSchema(), moved to utils/dbErrors.js and shared with apiTokenAuth, with the predicate tightened to trust 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 logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
@@ -75,6 +76,14 @@ 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 a transient DB fault in this
|
||||
// try block behaves (isTokenRevoked hits 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