fix(settings): serialize GET /api/settings from an allowlist (#1299)

The settings read returned the entire global_settings map minus a small
denylist of auth keys. Other subsystems persist their config in the same
table, so cloud backup values (endpoint, bucket, access key) were returned
to any authenticated user, including read-only roles, bypassing the
redaction the dedicated cloud-backup endpoint applies.

Project the response from the operational allowlist instead, so only the
keys the settings UI reads are returned and any key written to
global_settings by another subsystem is excluded by default. Remove the
now-unused denylist constant.
This commit is contained in:
Anso
2026-06-04 01:49:03 -04:00
committed by GitHub
parent cfadd76159
commit 4596a90474
2 changed files with 75 additions and 13 deletions
+61 -4
View File
@@ -55,10 +55,67 @@ describe('GET /api/settings', () => {
expect(res.body.auth_jwt_secret).toBeUndefined();
});
it('allows non-admin users to read settings', async () => {
// Settings is read-only for non-admins; write is admin-gated separately.
const res = await request(app).get('/api/settings').set('Cookie', viewerCookie);
expect(res.status).toBe(200);
describe('credential projection (leak prevention)', () => {
// Seeded leak-probe values are reset afterward so the shared test DB does
// not carry them into later suites (matches the cleanup convention used by
// the mesh_auto_recreate write test below).
afterAll(() => {
const db = DatabaseService.getInstance();
for (const k of [
'cloud_backup_access_key',
'cloud_backup_secret_key',
'cloud_backup_endpoint',
'cloud_backup_bucket',
'some_future_setting',
]) {
db.updateGlobalSetting(k, '');
}
db.updateGlobalSetting('trivy_auto_update', '0');
db.updateGlobalSetting('mesh_auto_recreate', '0');
});
it('never returns credentials or other non-allowlisted keys, even to admins', async () => {
// Cloud backup config is stored in global_settings by the cloud-backup
// route (access key plaintext, secret key encrypted). The generic settings
// GET projects an allowlist, so these credentials and any other key written
// to the table are never disclosed here.
const db = DatabaseService.getInstance();
db.updateGlobalSetting('cloud_backup_access_key', 'AKIA-must-not-leak');
db.updateGlobalSetting('cloud_backup_secret_key', 'cipher-must-not-leak');
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
db.updateGlobalSetting('cloud_backup_bucket', 'private-bucket');
db.updateGlobalSetting('trivy_auto_update', '1');
// A key that is neither allowlisted nor obviously sensitive: the allowlist
// must still exclude it. This locks the fail-closed contract that is the
// reason the GET uses an allowlist rather than a denylist.
db.updateGlobalSetting('some_future_setting', 'should-not-appear');
db.updateGlobalSetting('host_cpu_limit', '80');
db.updateGlobalSetting('mesh_auto_recreate', '1');
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.cloud_backup_access_key).toBeUndefined();
expect(res.body.cloud_backup_secret_key).toBeUndefined();
expect(res.body.cloud_backup_endpoint).toBeUndefined();
expect(res.body.cloud_backup_bucket).toBeUndefined();
expect(res.body.trivy_auto_update).toBeUndefined();
expect(res.body.some_future_setting).toBeUndefined();
// Allowlisted keys still come through. Two structurally different keys (a
// numeric and an enum-shaped one) prove the projection passes the whole
// allowlist, not just one key.
expect(res.body.host_cpu_limit).toBe('80');
expect(res.body.mesh_auto_recreate).toBe('1');
});
it('allows non-admin users to read settings without leaking credentials', async () => {
// Settings is read-only for non-admins; write is admin-gated separately.
// The leak matters most here: a viewer must never receive backup creds.
DatabaseService.getInstance().updateGlobalSetting('cloud_backup_access_key', 'AKIA-viewer-must-not-see');
const res = await request(app).get('/api/settings').set('Cookie', viewerCookie);
expect(res.status).toBe(200);
expect(res.body.cloud_backup_access_key).toBeUndefined();
expect(res.body.auth_password_hash).toBeUndefined();
});
});
});