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:
Paul Nothaft
2026-08-03 14:48:33 +02:00
committed by GitHub
parent fecc18cbc8
commit cc49f6997a
5 changed files with 195 additions and 19 deletions
@@ -0,0 +1,111 @@
/**
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
* to keep existing sessions working across the RBAC upgrade window. The catch
* around it used to be unconditional, so ANY transient database failure —
* connection reset, deadlock, statement timeout, pool exhaustion — took the
* same branch and handed the caller super_admin for the duration of the fault.
*
* `roleName` is the sole discriminator for every ownership check (ownership.js,
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
* inverted the whole authorization model rather than failing the request.
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
*/
const jwt = require('jsonwebtoken');
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
// The joined query throws whatever the test stages; the role-less fallback
// query (no .leftJoin) always succeeds, which is what made the original bug
// reachable — it is the cheaper single-table read.
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
let mockJoinError = null;
const mockAdminRow = { id: 7, username: 'scoped', email: '[email protected]', password_changed_at: null };
jest.mock('../../src/database/db', () => ({
db: () => ({
_joined: false,
leftJoin() { this._joined = true; return this; },
where() { return this; },
select() { return this; },
first() {
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
return Promise.resolve({ ...mockAdminRow });
},
}),
}));
const { adminAuth } = require('../../src/middleware/auth');
const SECRET = 'test-secret-for-admin-auth-fallback';
function makeReq() {
const token = jwt.sign(
{ id: mockAdminRow.id, type: 'admin' },
SECRET,
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
);
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
}
function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
}
describe('adminAuth roles-join fallback (#968)', () => {
const OLD_SECRET = process.env.JWT_SECRET;
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
beforeEach(() => { mockJoinError = null; });
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.roleName).toBe('super_admin');
});
it.each([
['connection reset', new Error('Connection terminated unexpectedly')],
['deadlock', new Error('deadlock detected')],
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
['statement timeout', new Error('canceling statement due to statement timeout')],
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
mockJoinError = err;
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
// Fails closed: request rejected, req.admin never populated. The specific
// status is 401 (adminAuth's blanket outer catch) — what matters is that
// the caller is not elevated and does not reach the route.
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
expect(res.statusCode).toBe(401);
});
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
});
});
@@ -29,4 +29,44 @@ describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
it('rejects a missing-table error for an unrelated table', () => { it('rejects a missing-table error for an unrelated table', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false); expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
}); });
// knex prefixes the failing SQL to err.message, and that SQL always names
// `roles` on this join — so the message substring proves nothing about the
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
// These are real knex message shapes, captured from the actual query.
describe('with knex\'s SQL prefix on the message (#968)', () => {
const withSql = (driverMessage) => new Error(
'select `roles`.`name` as `role_name` from `admin_users` '
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
);
it('accepts both legitimate upgrade-window states', () => {
// pre-054: the roles table does not exist yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
// post-054, pre-057: roles exists, admin_users.role_id not added yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
});
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
// pgbouncer transaction pooling loses a named prepared statement
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
// fresh connection, so accepting this would fabricate super_admin.
expect(isMissingRolesSchema(
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
)).toBe(false);
// The DB role/user, not the roles table.
expect(isMissingRolesSchema(
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
)).toBe(false);
expect(isMissingRolesSchema(
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
)).toBe(false);
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
});
});
}); });
+1 -18
View File
@@ -1,6 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const TOKEN_PREFIX = 'pp_live_'; const TOKEN_PREFIX = 'pp_live_';
@@ -32,24 +33,6 @@ function parseScopes(raw) {
.filter((s) => VALID_SCOPES.includes(s)); .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 * Middleware: authenticate via API token. Maps the token to its owner
* admin user, attaches { req.admin, req.apiToken }, then defers to the * admin user, attaches { req.admin, req.apiToken }, then defers to the
+9
View File
@@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation'); const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
@@ -75,6 +76,14 @@ async function adminAuth(req, res, next) {
) )
.first(); .first();
} catch (joinError) { } 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 // Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate // 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 }); logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
+34 -1
View File
@@ -12,4 +12,37 @@ function isUniqueViolation(err) {
return /unique/i.test(msg) || /sqlite_constraint/i.test(msg); 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 };