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:
@@ -29,4 +29,44 @@ describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
|
||||
it('rejects a missing-table error for an unrelated table', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user