810801a9ab
PUT /admin/events/:id spreads the body into the UPDATE. SQLite resolves
quoted identifiers case-insensitively, so `{ "Event_Name": ... }` lands
on event_name there — while every check in the handler (validators, the
field-level permission guards, the deny-set) keys on the exact lowercase
name. The deny-set already case-folded for its own columns; every other
column was reachable through a spelling variant.
Every events column and every input-only key the handler accepts is
lowercase snake_case, so a key with any uppercase in it is not something
a legitimate client sends. Such keys are now removed before anything
looks at the body. Postgres was unaffected (quoted identifiers are
case-sensitive there; a variant produced a 500 instead).
Surfaced by the Codex review of the folder-watcher change, where a
photos.upload guard on external_watch could be walked around this way.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
220 lines
10 KiB
JavaScript
220 lines
10 KiB
JavaScript
/**
|
|
* Authorization / ownership gaps (GHSA permission cluster):
|
|
* - jm7j: API-token list must scope to the caller (non-super sees only own)
|
|
* - gprq: API-token revoke must be owner-or-super_admin
|
|
* - 3rqx: event update must not mass-assign identity/secret columns
|
|
* - j2f4: category hero must belong to that category
|
|
*/
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'authz-test-secret';
|
|
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-storage-'));
|
|
|
|
const request = require('supertest');
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const bcrypt = require('bcrypt');
|
|
const {
|
|
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
|
|
} = require('../integration/helpers/crmDb');
|
|
|
|
describe('authorization / ownership gaps', () => {
|
|
let db; let cleanup; let app;
|
|
let superId; let superTok; let adminId; let adminTok;
|
|
|
|
const grantPermissionToRole = async (roleName, permName) => {
|
|
const role = await db('roles').where({ name: roleName }).first();
|
|
const perm = await db('permissions').where({ name: permName }).first();
|
|
const exists = await db('role_permissions')
|
|
.where({ role_id: role.id, permission_id: perm.id }).first();
|
|
if (!exists) {
|
|
await db('role_permissions').insert({ role_id: role.id, permission_id: perm.id });
|
|
}
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
({ adminId: superId } = await seedMinimal(db));
|
|
await assignAdminRole(db, superId, 'super_admin');
|
|
superTok = mintAdminToken(superId);
|
|
|
|
const pass = await bcrypt.hash('x', 4);
|
|
const ins = await db('admin_users').insert({
|
|
username: 'plain-admin', email: 'plain@example.com',
|
|
password_hash: pass, must_change_password: false, created_at: new Date(),
|
|
}).returning('id');
|
|
adminId = ins[0]?.id ?? ins[0];
|
|
await assignAdminRole(db, adminId, 'admin');
|
|
// Grant settings.integrations to the admin role BEFORE any request populates
|
|
// the 60s permission cache, so the revoke test exercises the ownership check
|
|
// (404) rather than the missing-permission gate (403). Migration 174 split
|
|
// API-token management out of the catch-all settings.edit into the dedicated
|
|
// settings.integrations perm; this models a custom role that carries it —
|
|
// the scenario GHSA-gprq needs.
|
|
await grantPermissionToRole('admin', 'settings.integrations');
|
|
adminTok = mintAdminToken(adminId);
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
|
|
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
|
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
|
});
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
|
|
|
|
describe('API tokens (jm7j / gprq)', () => {
|
|
let superTokenId;
|
|
|
|
beforeAll(async () => {
|
|
const res = await auth(request(app).post('/api/admin/api-tokens'), superTok)
|
|
.send({ name: 'super-token', scopes: ['read'] });
|
|
expect(res.status).toBe(201);
|
|
superTokenId = res.body.id;
|
|
});
|
|
|
|
it('non-super admin does not see another admin\'s tokens in the list', async () => {
|
|
const res = await auth(request(app).get('/api/admin/api-tokens'), adminTok);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.find((t) => t.id === superTokenId)).toBeUndefined();
|
|
});
|
|
|
|
it('super_admin sees all tokens', async () => {
|
|
const res = await auth(request(app).get('/api/admin/api-tokens'), superTok);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
|
|
});
|
|
|
|
it('a non-owner (with settings.integrations) cannot revoke another admin\'s token', async () => {
|
|
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
|
|
expect(res.status).toBe(404);
|
|
const row = await db('api_tokens').where({ id: superTokenId }).first();
|
|
expect(row.revoked_at).toBeFalsy();
|
|
});
|
|
|
|
it('the owner can revoke their own token', async () => {
|
|
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), superTok);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('event update mass-assignment (3rqx)', () => {
|
|
it('ignores identity/secret columns in the request body', async () => {
|
|
const seedShareToken = 'orig-share-token';
|
|
const ins = await db('events').insert({
|
|
slug: 'authz-mass-assign', event_type: 'wedding', event_name: 'Before',
|
|
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
|
password_hash: 'orig-hash', share_link: '/gallery/authz/share', share_token: seedShareToken, expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
|
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
const eventId = ins[0]?.id ?? ins[0];
|
|
|
|
const res = await auth(request(app).put(`/api/admin/events/${eventId}`), superTok).send({
|
|
event_name: 'After',
|
|
created_by: 99999,
|
|
slug: 'hijacked-slug',
|
|
share_token: 'hijacked-token',
|
|
password_hash: 'hijacked-hash',
|
|
is_archived: 1,
|
|
archive_path: '/hijacked/archive/path',
|
|
hero_logo_path: '/etc/passwd',
|
|
is_draft: 1,
|
|
project_id: 99999,
|
|
// Case-variant keys — SQLite matches columns case-insensitively.
|
|
Password_Hash: 'case-hijack-hash',
|
|
Created_By: 88888,
|
|
// A case variant of an ORDINARY column must not reach the UPDATE
|
|
// either: field-level guards in the handler key on the exact name,
|
|
// and on SQLite the variant would still land on the real column.
|
|
Event_Name: 'case-variant-name',
|
|
Welcome_Message: 'case-variant-welcome',
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const row = await db('events').where({ id: eventId }).first();
|
|
expect(row.event_name).toBe('After'); // legit field applied; Event_Name variant dropped
|
|
expect(row.welcome_message).toBeFalsy(); // case variant of an ordinary column dropped
|
|
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
|
|
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
|
|
expect(row.share_token).toBe(seedShareToken); // secret untouched
|
|
expect(row.password_hash).toBe('orig-hash'); // secret untouched (+ case-variant)
|
|
expect(row.is_archived).toBeFalsy(); // archive lifecycle untouched
|
|
expect(row.archive_path).toBeFalsy(); // forged archive path rejected
|
|
expect(row.hero_logo_path).toBeFalsy(); // fs.unlink primitive blocked
|
|
expect(row.is_draft).toBeFalsy(); // publish workflow not bypassed
|
|
expect(row.project_id).toBeFalsy(); // server-managed relationship untouched
|
|
});
|
|
|
|
it('returns 200 (no-op) when the body contains only protected fields', async () => {
|
|
const ins = await db('events').insert({
|
|
slug: 'authz-empty-update', event_type: 'wedding', event_name: 'Keep',
|
|
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
|
password_hash: 'x', share_link: '/gallery/authz-empty/share', share_token: 'authz-empty-share',
|
|
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
|
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
const id = ins[0]?.id ?? ins[0];
|
|
// Body reduces to {} after the denylist — must not 500 (Knex rejects
|
|
// .update({})).
|
|
const res = await auth(request(app).put(`/api/admin/events/${id}`), superTok)
|
|
.send({ created_by: 1, slug: 'x', is_archived: 1 });
|
|
expect(res.status).toBe(200);
|
|
const row = await db('events').where({ id }).first();
|
|
expect(row.event_name).toBe('Keep');
|
|
});
|
|
});
|
|
|
|
describe('category hero cross-category (j2f4)', () => {
|
|
it('rejects a hero photo that is not in the category', async () => {
|
|
const evIns = await db('events').insert({
|
|
slug: 'authz-cat', event_type: 'wedding', event_name: 'Cat Event',
|
|
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
|
password_hash: 'x', share_link: '/gallery/authz-cat/share', share_token: 'authz-cat-share', expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
|
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
const evId = evIns[0]?.id ?? evIns[0];
|
|
|
|
const mkCat = async (name) => {
|
|
const c = await db('photo_categories').insert({
|
|
event_id: evId, name, slug: name.toLowerCase(), created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
return c[0]?.id ?? c[0];
|
|
};
|
|
const cat1 = await mkCat('Cat1');
|
|
const cat2 = await mkCat('Cat2');
|
|
|
|
const pIns = await db('photos').insert({
|
|
event_id: evId, filename: 'p.jpg', path: 'authz-cat/p.jpg', type: 'individual',
|
|
category_id: cat1, uploaded_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
const photoInCat1 = pIns[0]?.id ?? pIns[0];
|
|
|
|
// Pointing cat2's hero at a photo that lives in cat1 must be refused.
|
|
const bad = await auth(request(app).put(`/api/admin/categories/${cat2}/hero`), superTok)
|
|
.send({ hero_photo_id: photoInCat1 });
|
|
expect(bad.status).toBe(404);
|
|
|
|
// The photo's own category accepts it.
|
|
const ok = await auth(request(app).put(`/api/admin/categories/${cat1}/hero`), superTok)
|
|
.send({ hero_photo_id: photoInCat1 });
|
|
expect(ok.status).toBe(200);
|
|
});
|
|
});
|
|
});
|