v0.7.1 Surface Runner Framework
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 23s
CI/CD / test-frontend (pull_request) Successful in 26s
CI/CD / test-go-pg (pull_request) Successful in 2m35s
CI/CD / test-sqlite (pull_request) Successful in 3m19s
CI/CD / build-and-deploy (pull_request) Successful in 1m46s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 23s
CI/CD / test-frontend (pull_request) Successful in 26s
CI/CD / test-go-pg (pull_request) Successful in 2m35s
CI/CD / test-sqlite (pull_request) Successful in 3m19s
CI/CD / build-and-deploy (pull_request) Successful in 1m46s
sw.testing SDK module — structured test framework for surface runners: - suite/test registration, lifecycle hooks (beforeAll/afterAll/beforeEach/afterEach) - Assertion library (ok/eq/neq/gt/match/throws/status/shape/arrayOf) - Auto-cleanup via track(type, id) with LIFO deletion - Three result statuses: passed/failed/warned - Structured JSON results with timing, warnings, cleanup stats test-runner manifest type: - ValidateManifest accepts "test-runner" packages - Excluded from sidebar nav (extensionNavItems filters surface/full only) - DB migrations: SQLite 013, Postgres 014 (CHECK constraint) ICD runner migration (kernel-only): - Migrated from T.test()/T.assert() to sw.testing.suite() - Stripped extension-dependent tests (channels, notes, personas, etc.) - Kernel suites: smoke, crud, authz, security, providers, packaging, sdk - Deleted ui.js + css (rendering delegated to registry surface) SDK runner migration (kernel-only): - Migrated from T.dualTest()/T.domains to sw.testing.suite() - Stripped extension domains (belong in v0.7.2 package runners) - Kernel suites: misc, workflows, admin, packages, connections, deps, composition Runner registry surface (/s/test-runners): - Admin-only dashboard discovering test-runner packages - Run All / per-runner Run buttons, real-time results - Export Failures / Export Full Results (JSON download) - Dark mode styling using kernel CSS variables - Suite count polling for async runner script loading 141 passed, 0 failed on fresh minimal install. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!window.sw || !window.sw.testing) return;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
@@ -54,11 +55,13 @@
|
||||
throw new Error(label + ' — got HTTP ' + resp.status);
|
||||
}
|
||||
|
||||
// ─── Main Entry ───────────────────────────────────────────
|
||||
// ─── Suite ────────────────────────────────────────────────
|
||||
|
||||
T.runSecurity = async function () {
|
||||
sw.testing.suite('icd/security', async function (s) {
|
||||
if (!T.fixtures.ready) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first', 'warning');
|
||||
s.test('setup: fixtures required', async function (t) {
|
||||
t.skip('Provision test fixtures first');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,8 +71,9 @@
|
||||
var adminToken = await T.getAuthToken();
|
||||
|
||||
if (!testUser || !testUser.token || !adminToken) {
|
||||
T.results.push({ tier: 'security', domain: 'setup', name: 'SKIP — missing tokens', status: 'fail', duration: 0,
|
||||
detail: 'Fixture users must be provisioned with valid tokens' });
|
||||
s.test('setup: fixture tokens required', async function (t) {
|
||||
t.skip('Fixture users must be provisioned with valid tokens');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,98 +81,118 @@
|
||||
// 1. AUTH BOUNDARY
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] deactivated user JWT still valid', async function () {
|
||||
s.test('auth-boundary: [P0] deactivated user JWT still valid', async function (t) {
|
||||
var tag = 'sec-deact-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create sacrificial user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
var cleanup = [];
|
||||
try {
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create sacrificial user');
|
||||
cleanup.push(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.access_token, 'login failed');
|
||||
var victimToken = login.access_token;
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.access_token, 'login failed');
|
||||
var victimToken = login.access_token;
|
||||
|
||||
var pre = await T.authFetch(victimToken, 'GET', '/profile');
|
||||
T.assertStatus(pre, 200, 'pre-deactivate profile');
|
||||
var pre = await T.authFetch(victimToken, 'GET', '/profile');
|
||||
T.assertStatus(pre, 200, 'pre-deactivate profile');
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false });
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false });
|
||||
|
||||
var post = await T.authFetch(victimToken, 'GET', '/profile');
|
||||
T.assert(post._status === 401 || post._status === 403,
|
||||
'CRITICAL: deactivated user JWT still accepted! got ' + post._status +
|
||||
' — middleware does not check is_active in DB');
|
||||
var post = await T.authFetch(victimToken, 'GET', '/profile');
|
||||
T.assert(post._status === 401 || post._status === 403,
|
||||
'CRITICAL: deactivated user JWT still accepted! got ' + post._status +
|
||||
' — middleware does not check is_active in DB');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] demoted admin JWT retains admin access', async function () {
|
||||
s.test('auth-boundary: [P0] demoted admin JWT retains admin access', async function (t) {
|
||||
var tag = 'sec-demote-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'admin'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create admin user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
var cleanup = [];
|
||||
try {
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'admin'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create admin user');
|
||||
cleanup.push(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.access_token, 'login failed');
|
||||
var adminJwt = login.access_token;
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.access_token, 'login failed');
|
||||
var adminJwt = login.access_token;
|
||||
|
||||
var pre = await T.authFetch(adminJwt, 'GET', '/admin/stats');
|
||||
T.assertStatus(pre, 200, 'pre-demote admin stats');
|
||||
var pre = await T.authFetch(adminJwt, 'GET', '/admin/stats');
|
||||
T.assertStatus(pre, 200, 'pre-demote admin stats');
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/role', { role: 'user' });
|
||||
await T.apiPut('/admin/users/' + created.id + '/role', { role: 'user' });
|
||||
|
||||
var post = await T.authFetch(adminJwt, 'GET', '/admin/stats');
|
||||
T.assert(post._status === 403 || post._status === 401,
|
||||
'CRITICAL: demoted user JWT still has admin access! got ' + post._status +
|
||||
' — role is baked into JWT, not checked against DB');
|
||||
var post = await T.authFetch(adminJwt, 'GET', '/admin/stats');
|
||||
T.assert(post._status === 403 || post._status === 401,
|
||||
'CRITICAL: demoted user JWT still has admin access! got ' + post._status +
|
||||
' — role is baked into JWT, not checked against DB');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] refresh token reuse after logout', async function () {
|
||||
s.test('auth-boundary: [P1] refresh token reuse after logout', async function (t) {
|
||||
var tag = 'sec-refresh-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
var cleanup = [];
|
||||
try {
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
cleanup.push(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.refresh_token, 'no refresh token');
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.refresh_token, 'no refresh token');
|
||||
|
||||
await T.authFetch(login.access_token, 'POST', '/auth/logout', { refresh_token: login.refresh_token });
|
||||
await T.authFetch(login.access_token, 'POST', '/auth/logout', { refresh_token: login.refresh_token });
|
||||
|
||||
var reuse = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token });
|
||||
T.assert(reuse._status === 401 || reuse._status === 400,
|
||||
'revoked refresh token was accepted! got ' + reuse._status);
|
||||
var reuse = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token });
|
||||
T.assert(reuse._status === 401 || reuse._status === 400,
|
||||
'revoked refresh token was accepted! got ' + reuse._status);
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] refresh token rotation (old invalid after use)', async function () {
|
||||
s.test('auth-boundary: [P1] refresh token rotation (old invalid after use)', async function (t) {
|
||||
var tag = 'sec-rotate-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
var cleanup = [];
|
||||
try {
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
cleanup.push(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
var rt1 = login.refresh_token;
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
var rt1 = login.refresh_token;
|
||||
|
||||
var refreshed = await T.publicPost('/auth/refresh', { refresh_token: rt1 });
|
||||
T.assert(refreshed._status === 200 && refreshed.access_token, 'refresh failed');
|
||||
var refreshed = await T.publicPost('/auth/refresh', { refresh_token: rt1 });
|
||||
T.assert(refreshed._status === 200 && refreshed.access_token, 'refresh failed');
|
||||
|
||||
var replay = await T.publicPost('/auth/refresh', { refresh_token: rt1 });
|
||||
T.assert(replay._status === 401 || replay._status === 400,
|
||||
'rotated-out refresh token still valid! got ' + replay._status);
|
||||
var replay = await T.publicPost('/auth/refresh', { refresh_token: rt1 });
|
||||
T.assert(replay._status === 401 || replay._status === 400,
|
||||
'rotated-out refresh token still valid! got ' + replay._status);
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] tampered JWT payload accepted', async function () {
|
||||
s.test('auth-boundary: [P0] tampered JWT payload accepted', async function (t) {
|
||||
var parts = testUser.token.split('.');
|
||||
T.assert(parts.length === 3, 'token is not 3-part JWT');
|
||||
try {
|
||||
@@ -184,7 +208,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] JWT alg=none accepted', async function () {
|
||||
s.test('auth-boundary: [P0] JWT alg=none accepted', async function (t) {
|
||||
var header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
var payload = btoa(JSON.stringify({
|
||||
user_id: testUser.id, email: testUser.username + '@test.local', role: 'admin',
|
||||
@@ -196,27 +220,32 @@
|
||||
'CRITICAL: alg=none JWT was accepted! got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] requests with no user_id context', async function () {
|
||||
s.test('auth-boundary: [P1] requests with no user_id context', async function (t) {
|
||||
var d = await T.authFetch('eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiIn0.garbage', 'GET', '/profile');
|
||||
T.assert(d._status === 401, 'empty user_id token accepted, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] deactivated user cannot refresh', async function () {
|
||||
s.test('auth-boundary: [P1] deactivated user cannot refresh', async function (t) {
|
||||
var tag = 'sec-deact-ref-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
var cleanup = [];
|
||||
try {
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
cleanup.push(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false });
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false });
|
||||
|
||||
var d = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token });
|
||||
T.assert(d._status === 401, 'deactivated user was able to refresh! got ' + d._status);
|
||||
var d = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token });
|
||||
T.assert(d._status === 401, 'deactivated user was able to refresh! got ' + d._status);
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
@@ -226,23 +255,28 @@
|
||||
// ── Notes isolation ──
|
||||
|
||||
var adminNote = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s note by ID', async function () {
|
||||
var d = await T.apiPost('/notes', { title: 'sec-private-' + Date.now(), content: 'SECRET DATA' });
|
||||
T.assert(d && d.id, 'note creation failed');
|
||||
adminNote = d.id;
|
||||
T.registerCleanup(function () { if (adminNote) return T.safeDelete('/notes/' + adminNote); });
|
||||
s.test('cross-tenant: [P0] user → read another user\'s note by ID', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.apiPost('/notes', { title: 'sec-private-' + Date.now(), content: 'SECRET DATA' });
|
||||
T.assert(d && d.id, 'note creation failed');
|
||||
adminNote = d.id;
|
||||
cleanup.push(function () { return T.safeDelete('/notes/' + adminNote); });
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/notes/' + adminNote);
|
||||
assertDenied(steal._status, 'user can read another user\'s note');
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/notes/' + adminNote);
|
||||
assertDenied(steal._status, 'user can read another user\'s note');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → update another user\'s note', async function () {
|
||||
s.test('cross-tenant: [P0] user → update another user\'s note', async function (t) {
|
||||
if (!adminNote) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/notes/' + adminNote, { title: 'HACKED' });
|
||||
assertDenied(d._status, 'user can update another user\'s note');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s note', async function () {
|
||||
s.test('cross-tenant: [P0] user → delete another user\'s note', async function (t) {
|
||||
if (!adminNote) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/notes/' + adminNote);
|
||||
assertDenied(d._status, 'user can delete another user\'s note');
|
||||
@@ -250,17 +284,17 @@
|
||||
|
||||
// ── Memory isolation (no POST /memories — system-extracted only) ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → GET /admin/memories/pending (expect deny)', async function () {
|
||||
s.test('cross-tenant: [P1] user → GET /admin/memories/pending (expect deny)', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/admin/memories/pending');
|
||||
assertDenied(d._status, 'user can list admin pending memories');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → PUT /memories/:fakeId (expect deny)', async function () {
|
||||
s.test('cross-tenant: [P1] user → PUT /memories/:fakeId (expect deny)', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/memories/00000000-0000-0000-0000-000000000000', { value: 'HACKED' });
|
||||
assertDenied(d._status, 'user can hit PUT /memories with fabricated ID');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → DELETE /memories/:fakeId (expect deny)', async function () {
|
||||
s.test('cross-tenant: [P1] user → DELETE /memories/:fakeId (expect deny)', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/memories/00000000-0000-0000-0000-000000000000');
|
||||
assertDenied(d._status, 'user can hit DELETE /memories with fabricated ID');
|
||||
});
|
||||
@@ -268,21 +302,26 @@
|
||||
// ── Channel / message isolation ──
|
||||
|
||||
var adminChannel = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s channel messages', async function () {
|
||||
var ch = await T.apiPost('/channels', { title: 'sec-private-ch-' + Date.now(), type: 'direct' });
|
||||
T.assert(ch && ch.id, 'channel creation failed');
|
||||
adminChannel = ch.id;
|
||||
T.registerCleanup(function () { if (adminChannel) return T.safeDelete('/channels/' + adminChannel); });
|
||||
s.test('cross-tenant: [P0] user → read another user\'s channel messages', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var ch = await T.apiPost('/channels', { title: 'sec-private-ch-' + Date.now(), type: 'direct' });
|
||||
T.assert(ch && ch.id, 'channel creation failed');
|
||||
adminChannel = ch.id;
|
||||
cleanup.push(function () { return T.safeDelete('/channels/' + adminChannel); });
|
||||
|
||||
await T.apiPost('/channels/' + adminChannel + '/messages', {
|
||||
role: 'user', content: 'TOP SECRET MESSAGE'
|
||||
});
|
||||
await T.apiPost('/channels/' + adminChannel + '/messages', {
|
||||
role: 'user', content: 'TOP SECRET MESSAGE'
|
||||
});
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/channels/' + adminChannel + '/messages');
|
||||
assertDenied(steal._status, 'user can read another user\'s messages');
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/channels/' + adminChannel + '/messages');
|
||||
assertDenied(steal._status, 'user can read another user\'s messages');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → POST message to another user\'s channel', async function () {
|
||||
s.test('cross-tenant: [P0] user → POST message to another user\'s channel', async function (t) {
|
||||
if (!adminChannel) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels/' + adminChannel + '/messages', {
|
||||
role: 'user', content: 'INJECTED'
|
||||
@@ -290,13 +329,13 @@
|
||||
assertDenied(d._status, 'user can inject messages into another user\'s channel');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → update another user\'s channel', async function () {
|
||||
s.test('cross-tenant: [P0] user → update another user\'s channel', async function (t) {
|
||||
if (!adminChannel) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/channels/' + adminChannel, { title: 'HACKED' });
|
||||
assertDenied(d._status, 'user can rename another user\'s channel');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → archive another user\'s channel', async function () {
|
||||
s.test('cross-tenant: [P0] user → archive another user\'s channel', async function (t) {
|
||||
if (!adminChannel) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/channels/' + adminChannel, { is_archived: true });
|
||||
assertDenied(d._status, 'user can archive another user\'s channel');
|
||||
@@ -304,7 +343,7 @@
|
||||
|
||||
// ── BYOK config isolation ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → enumerate BYOK configs', async function () {
|
||||
s.test('cross-tenant: [P1] user → enumerate BYOK configs', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/api-configs');
|
||||
T.assertStatus(d, 200, 'api-configs');
|
||||
var arr = d.data || d.configs || [];
|
||||
@@ -321,27 +360,32 @@
|
||||
// ── Task isolation ──
|
||||
|
||||
var adminTask = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s task', async function () {
|
||||
s.test('cross-tenant: [P0] user → read another user\'s task', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: 'sec-task-' + Date.now(), task_type: 'scheduled',
|
||||
schedule: '0 0 * * *', scope: 'personal',
|
||||
prompt: 'test', model: 'test'
|
||||
});
|
||||
adminTask = (d && d.id) ? d.id : (d && d.task && d.task.id) ? d.task.id : null;
|
||||
if (adminTask) T.registerCleanup(function () { return T.safeDelete('/tasks/' + adminTask); });
|
||||
} catch (e) { /* may require permissions */ }
|
||||
try {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: 'sec-task-' + Date.now(), task_type: 'scheduled',
|
||||
schedule: '0 0 * * *', scope: 'personal',
|
||||
prompt: 'test', model: 'test'
|
||||
});
|
||||
adminTask = (d && d.id) ? d.id : (d && d.task && d.task.id) ? d.task.id : null;
|
||||
if (adminTask) cleanup.push(function () { return T.safeDelete('/tasks/' + adminTask); });
|
||||
} catch (e) { /* may require permissions */ }
|
||||
|
||||
if (!adminTask) {
|
||||
var adm = await T.authFetch(testUser.token, 'GET', '/admin/tasks');
|
||||
assertDenied(adm._status, 'user can list admin tasks');
|
||||
return;
|
||||
if (!adminTask) {
|
||||
var adm = await T.authFetch(testUser.token, 'GET', '/admin/tasks');
|
||||
assertDenied(adm._status, 'user can list admin tasks');
|
||||
return;
|
||||
}
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/tasks/' + adminTask);
|
||||
assertDenied(steal._status, 'user can read another user\'s task');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/tasks/' + adminTask);
|
||||
assertDenied(steal._status, 'user can read another user\'s task');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s task', async function () {
|
||||
s.test('cross-tenant: [P0] user → delete another user\'s task', async function (t) {
|
||||
if (!adminTask) {
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/tasks/00000000-0000-0000-0000-000000000000');
|
||||
assertDenied(d._status, 'user can hit DELETE /tasks with fabricated ID');
|
||||
@@ -354,78 +398,103 @@
|
||||
// ── Workspace isolation ──
|
||||
|
||||
var adminWs = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s workspace', async function () {
|
||||
s.test('cross-tenant: [P0] user → read another user\'s workspace', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.apiPost('/workspaces', {
|
||||
name: 'sec-ws-' + Date.now(), owner_type: 'user', owner_id: T.user.id
|
||||
});
|
||||
if (d && d.id) {
|
||||
adminWs = d.id;
|
||||
T.registerCleanup(function () { return T.safeDelete('/workspaces/' + adminWs); });
|
||||
}
|
||||
} catch (e) { /* may fail */ }
|
||||
if (!adminWs) return;
|
||||
try {
|
||||
var d = await T.apiPost('/workspaces', {
|
||||
name: 'sec-ws-' + Date.now(), owner_type: 'user', owner_id: T.user.id
|
||||
});
|
||||
if (d && d.id) {
|
||||
adminWs = d.id;
|
||||
cleanup.push(function () { return T.safeDelete('/workspaces/' + adminWs); });
|
||||
}
|
||||
} catch (e) { /* may fail */ }
|
||||
if (!adminWs) return;
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/workspaces/' + adminWs);
|
||||
assertDenied(steal._status, 'user can read another user\'s workspace');
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/workspaces/' + adminWs);
|
||||
assertDenied(steal._status, 'user can read another user\'s workspace');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cross-team access ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → access non-member team providers', async function () {
|
||||
var otherTeam = null;
|
||||
s.test('cross-tenant: [P1] user → access non-member team providers', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
var otherTeam = null;
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
cleanup.push(function () { return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/providers');
|
||||
assertDenied(steal._status, 'user can list providers for a team they are not in');
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/providers');
|
||||
assertDenied(steal._status, 'user can list providers for a team they are not in');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → access non-member team personas', async function () {
|
||||
var otherTeam = null;
|
||||
s.test('cross-tenant: [P1] user → access non-member team personas', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team2-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
var otherTeam = null;
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team2-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
cleanup.push(function () { return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/personas');
|
||||
assertDenied(steal._status, 'user can list personas for a team they are not in');
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/personas');
|
||||
assertDenied(steal._status, 'user can list personas for a team they are not in');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → access non-member team members list', async function () {
|
||||
var otherTeam = null;
|
||||
s.test('cross-tenant: [P1] user → access non-member team members list', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team3-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
var otherTeam = null;
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team3-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
cleanup.push(function () { return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/members');
|
||||
assertDenied(steal._status, 'user can list members of a team they are not in');
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/members');
|
||||
assertDenied(steal._status, 'user can list members of a team they are not in');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Project isolation ──
|
||||
|
||||
var adminProject = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s personal project', async function () {
|
||||
s.test('cross-tenant: [P0] user → read another user\'s personal project', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.apiPost('/projects', { name: 'sec-project-' + Date.now(), scope: 'personal' });
|
||||
if (d && d.id) {
|
||||
adminProject = d.id;
|
||||
T.registerCleanup(function () { return T.safeDelete('/projects/' + adminProject); });
|
||||
}
|
||||
} catch (e) { /* may fail */ }
|
||||
if (!adminProject) return;
|
||||
try {
|
||||
var d = await T.apiPost('/projects', { name: 'sec-project-' + Date.now(), scope: 'personal' });
|
||||
if (d && d.id) {
|
||||
adminProject = d.id;
|
||||
cleanup.push(function () { return T.safeDelete('/projects/' + adminProject); });
|
||||
}
|
||||
} catch (e) { /* may fail */ }
|
||||
if (!adminProject) return;
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/projects/' + adminProject);
|
||||
assertDenied(steal._status, 'user can read another user\'s project');
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/projects/' + adminProject);
|
||||
assertDenied(steal._status, 'user can read another user\'s project');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s project', async function () {
|
||||
s.test('cross-tenant: [P0] user → delete another user\'s project', async function (t) {
|
||||
if (!adminProject) return;
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/projects/' + adminProject);
|
||||
assertDenied(d._status, 'user can delete another user\'s project');
|
||||
@@ -433,7 +502,7 @@
|
||||
|
||||
// ── Notification isolation ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → list only sees own notifications', async function () {
|
||||
s.test('cross-tenant: [P1] user → list only sees own notifications', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/notifications');
|
||||
T.assertStatus(d, 200, 'notifications');
|
||||
});
|
||||
@@ -451,8 +520,8 @@
|
||||
];
|
||||
|
||||
for (var si = 0; si < sqliPayloads.length; si++) {
|
||||
await (async function (payload, idx) {
|
||||
await T.test('security', 'input-validation', '[P0] SQLi in notes search (' + (idx + 1) + '/' + sqliPayloads.length + ')', async function () {
|
||||
(function (payload, idx) {
|
||||
s.test('input-validation: [P0] SQLi in notes search (' + (idx + 1) + '/' + sqliPayloads.length + ')', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'GET',
|
||||
'/notes?query=' + encodeURIComponent(payload));
|
||||
T.assert(d._status !== 500,
|
||||
@@ -461,59 +530,80 @@
|
||||
})(sqliPayloads[si], si);
|
||||
}
|
||||
|
||||
await T.test('security', 'input-validation', '[P0] SQLi in channel search', async function () {
|
||||
s.test('input-validation: [P0] SQLi in channel search', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'GET',
|
||||
'/channels?query=' + encodeURIComponent("' UNION SELECT * FROM users--"));
|
||||
T.assert(d._status !== 500, 'SQL injection in channel search caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P0] SQLi in admin user search', async function () {
|
||||
s.test('input-validation: [P0] SQLi in admin user search', async function (t) {
|
||||
var d = await T.authFetch(adminToken, 'GET',
|
||||
'/admin/users?query=' + encodeURIComponent("' OR '1'='1"));
|
||||
T.assert(d._status !== 500, 'SQL injection in admin user search caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] XSS in channel title (stored)', async function () {
|
||||
var ch = await T.apiPost('/channels', { title: '<script>alert("xss")</script>', type: 'direct' });
|
||||
if (ch && ch.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/channels/' + ch.id); });
|
||||
var readBack = await T.apiGet('/channels/' + ch.id);
|
||||
T.assert(readBack._status !== 500, 'XSS channel title caused 500');
|
||||
s.test('input-validation: [P2] XSS in channel title (stored)', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var ch = await T.apiPost('/channels', { title: '<script>alert("xss")</script>', type: 'direct' });
|
||||
if (ch && ch.id) {
|
||||
cleanup.push(function () { return T.safeDelete('/channels/' + ch.id); });
|
||||
var readBack = await T.apiGet('/channels/' + ch.id);
|
||||
T.assert(readBack._status !== 500, 'XSS channel title caused 500');
|
||||
}
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] XSS in note content (stored)', async function () {
|
||||
var n = await T.apiPost('/notes', { title: 'xss-test', content: '<img src=x onerror=alert(document.cookie)>' });
|
||||
if (n && n.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/notes/' + n.id); });
|
||||
var readBack = await T.apiGet('/notes/' + n.id);
|
||||
T.assert(readBack._status !== 500, 'XSS note content caused 500');
|
||||
s.test('input-validation: [P2] XSS in note content (stored)', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var n = await T.apiPost('/notes', { title: 'xss-test', content: '<img src=x onerror=alert(document.cookie)>' });
|
||||
if (n && n.id) {
|
||||
cleanup.push(function () { return T.safeDelete('/notes/' + n.id); });
|
||||
var readBack = await T.apiGet('/notes/' + n.id);
|
||||
T.assert(readBack._status !== 500, 'XSS note content caused 500');
|
||||
}
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] XSS in persona name (stored)', async function () {
|
||||
s.test('input-validation: [P2] XSS in persona name (stored)', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var p = await T.apiPost('/personas', { name: '"><svg/onload=alert(1)>', scope: 'personal', model: 'test' });
|
||||
if (p && p.id) T.registerCleanup(function () { return T.safeDelete('/personas/' + p.id); });
|
||||
} catch (e) { /* server rejection is fine */ }
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] oversized channel title (10KB)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'A'.repeat(10240), type: 'direct' });
|
||||
if ((d._status === 200 || d._status === 201) && d.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
if (p && p.id) cleanup.push(function () { return T.safeDelete('/personas/' + p.id); });
|
||||
} catch (e) { /* server rejection is fine */ } finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
T.assert(d._status !== 500, 'oversized title caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] oversized note content (5MB)', async function () {
|
||||
s.test('input-validation: [P2] oversized channel title (10KB)', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'A'.repeat(10240), type: 'direct' });
|
||||
if ((d._status === 200 || d._status === 201) && d.id) {
|
||||
cleanup.push(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
}
|
||||
T.assert(d._status !== 500, 'oversized title caused 500');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
s.test('input-validation: [P2] oversized note content (5MB)', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/notes', { title: 'big', content: 'X'.repeat(5 * 1024 * 1024) });
|
||||
if (d && d.id) T.registerCleanup(function () { return T.safeDelete('/notes/' + d.id); });
|
||||
} catch (e) { /* rejection is fine */ }
|
||||
if (d && d.id) cleanup.push(function () { return T.safeDelete('/notes/' + d.id); });
|
||||
} catch (e) { /* rejection is fine */ } finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] malformed JSON body', async function () {
|
||||
s.test('input-validation: [P2] malformed JSON body', async function (t) {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/json' },
|
||||
@@ -526,7 +616,7 @@
|
||||
T.assert(resp.status === 400, 'malformed JSON should return 400, got ' + resp.status);
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] Content-Type mismatch (form data as JSON)', async function () {
|
||||
s.test('input-validation: [P2] Content-Type mismatch (form data as JSON)', async function (t) {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -540,43 +630,52 @@
|
||||
'form data accepted on JSON endpoint, got ' + resp.status);
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P0] path traversal in surface archive', async function () {
|
||||
var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' });
|
||||
s.test('input-validation: [P0] path traversal in surface archive', async function (t) {
|
||||
var cleanup = [];
|
||||
var d;
|
||||
try {
|
||||
d = await T.apiUpload('/admin/packages/install', blob, 'evil.surface');
|
||||
T.assert(d._status === 400 || d._status === 422,
|
||||
'path traversal surface accepted! got ' + d._status);
|
||||
} catch (e) {
|
||||
if (e.message.indexOf('502') !== -1) {
|
||||
throw new Error('INCONCLUSIVE (502): proxy returned 502 on surface upload');
|
||||
var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' });
|
||||
try {
|
||||
d = await T.apiUpload('/admin/packages/install', blob, 'evil.surface');
|
||||
T.assert(d._status === 400 || d._status === 422,
|
||||
'path traversal surface accepted! got ' + d._status);
|
||||
} catch (e) {
|
||||
if (e.message.indexOf('502') !== -1) {
|
||||
throw new Error('INCONCLUSIVE (502): proxy returned 502 on surface upload');
|
||||
}
|
||||
T.assert(e.message.indexOf('400') !== -1 || e.message.indexOf('422') !== -1 || e.message.indexOf('415') !== -1,
|
||||
'unexpected error: ' + e.message);
|
||||
}
|
||||
T.assert(e.message.indexOf('400') !== -1 || e.message.indexOf('422') !== -1 || e.message.indexOf('415') !== -1,
|
||||
'unexpected error: ' + e.message);
|
||||
} finally {
|
||||
// Cleanup: if the evil package was somehow installed, delete it
|
||||
if (d && (d._status === 200 || d._status === 201) && d.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/packages/' + d.id); });
|
||||
cleanup.push(function () { return T.safeDelete('/admin/packages/' + d.id); });
|
||||
}
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P1] null byte in channel ID param', async function () {
|
||||
s.test('input-validation: [P1] null byte in channel ID param', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/channels/abc%00def');
|
||||
T.assert(d._status !== 500, 'null byte in ID caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P1] extremely long ID param', async function () {
|
||||
s.test('input-validation: [P1] extremely long ID param', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/channels/' + 'a'.repeat(4096));
|
||||
T.assert(d._status !== 500, 'extremely long ID caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] unicode RTLO in channel title', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'normal\u202Eelif_gnol', type: 'direct' });
|
||||
if ((d._status === 200 || d._status === 201) && d.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
s.test('input-validation: [P2] unicode RTLO in channel title', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'normal\u202Eelif_gnol', type: 'direct' });
|
||||
if ((d._status === 200 || d._status === 201) && d.id) {
|
||||
cleanup.push(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
}
|
||||
T.assert(d._status !== 500, 'RTLO character caused 500');
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
T.assert(d._status !== 500, 'RTLO character caused 500');
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
@@ -585,22 +684,22 @@
|
||||
// (proxy may not forward unauthenticated requests).
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → GET /channels (expect 401)', async function () {
|
||||
s.test('session: [P0] unauthenticated → GET /channels (expect 401)', async function (t) {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', { method: 'GET', credentials: 'same-origin' });
|
||||
assertRawDenied(resp, 'unauthenticated access to /channels');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → GET /profile (expect 401)', async function () {
|
||||
s.test('session: [P0] unauthenticated → GET /profile (expect 401)', async function (t) {
|
||||
var resp = await fetch(T.base + '/api/v1/profile', { method: 'GET', credentials: 'same-origin' });
|
||||
assertRawDenied(resp, 'unauthenticated access to /profile');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → GET /admin/users (expect 401)', async function () {
|
||||
s.test('session: [P0] unauthenticated → GET /admin/users (expect 401)', async function (t) {
|
||||
var resp = await fetch(T.base + '/api/v1/admin/users', { method: 'GET', credentials: 'same-origin' });
|
||||
assertRawDenied(resp, 'unauthenticated access to admin');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → POST /channels (expect 401)', async function () {
|
||||
s.test('session: [P0] unauthenticated → POST /channels (expect 401)', async function (t) {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -610,13 +709,13 @@
|
||||
assertRawDenied(resp, 'unauthenticated channel creation');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P1] cookie-only → attempt JWT-protected endpoints', async function () {
|
||||
s.test('session: [P1] cookie-only → attempt JWT-protected endpoints', async function (t) {
|
||||
var d = await T.sessionGet('/notes');
|
||||
if (d._status === 502) throw new Error('INCONCLUSIVE (502): proxy returned 502 for cookie-only request');
|
||||
T.assert(d._status === 401, 'cookie-only access to /notes returned ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P1] cookie-only → attempt admin endpoints', async function () {
|
||||
s.test('session: [P1] cookie-only → attempt admin endpoints', async function (t) {
|
||||
var d = await T.sessionGet('/admin/stats');
|
||||
if (d._status === 502) throw new Error('INCONCLUSIVE (502): proxy returned 502 for cookie-only request');
|
||||
T.assert(d._status === 401, 'cookie-only access to /admin/stats returned ' + d._status);
|
||||
@@ -626,12 +725,12 @@
|
||||
// 5. PRIVILEGE ESCALATION ATTEMPTS
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user → PUT own role to admin', async function () {
|
||||
s.test('escalation: [P0] user → PUT own role to admin', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/admin/users/' + testUser.id + '/role', { role: 'admin' });
|
||||
assertDenied(d._status, 'CRITICAL: user was able to self-promote to admin');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user → POST /admin/users with admin role', async function () {
|
||||
s.test('escalation: [P0] user → POST /admin/users with admin role', async function (t) {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/users', {
|
||||
username: 'should-never-exist-' + Date.now(), password: 'Hack3d!xx',
|
||||
email: 'hack@evil.com', role: 'admin'
|
||||
@@ -640,44 +739,54 @@
|
||||
});
|
||||
|
||||
if (teamAdmin && teamAdmin.token) {
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → PUT /admin/settings', async function () {
|
||||
s.test('escalation: [P0] teamAdmin → PUT /admin/settings', async function (t) {
|
||||
var d = await T.authFetch(teamAdmin.token, 'PUT', '/admin/settings/allow_registration', { value: true });
|
||||
assertDenied(d._status, 'team admin can modify platform settings');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → DELETE /admin/users/:id', async function () {
|
||||
s.test('escalation: [P0] teamAdmin → DELETE /admin/users/:id', async function (t) {
|
||||
var d = await T.authFetch(teamAdmin.token, 'DELETE', '/admin/users/' + testUser.id);
|
||||
assertDenied(d._status, 'CRITICAL: team admin can delete users');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/packages/install', async function () {
|
||||
s.test('escalation: [P0] teamAdmin → POST /admin/packages/install', async function (t) {
|
||||
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/packages/install', {});
|
||||
if (d._status === 400) return; // bad upload format, not a security issue
|
||||
assertDenied(d._status, 'team admin can install surfaces');
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user_id substitution in channel creation', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', {
|
||||
title: 'impersonation-test-' + Date.now(), type: 'direct', user_id: T.user.id
|
||||
});
|
||||
if (d._status === 200 || d._status === 201) {
|
||||
if (d.id) T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
T.assert(!d.user_id || d.user_id === testUser.id,
|
||||
'CRITICAL: channel created as another user! owner=' + d.user_id);
|
||||
s.test('escalation: [P0] user_id substitution in channel creation', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', {
|
||||
title: 'impersonation-test-' + Date.now(), type: 'direct', user_id: T.user.id
|
||||
});
|
||||
if (d._status === 200 || d._status === 201) {
|
||||
if (d.id) cleanup.push(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
T.assert(!d.user_id || d.user_id === testUser.id,
|
||||
'CRITICAL: channel created as another user! owner=' + d.user_id);
|
||||
}
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user_id substitution in note creation', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/notes', {
|
||||
title: 'impersonation-' + Date.now(), content: 'test', user_id: T.user.id
|
||||
});
|
||||
if (d._status === 200 || d._status === 201) {
|
||||
if (d.id) T.registerCleanup(function () { return T.safeDelete('/notes/' + d.id); });
|
||||
if (d.user_id) {
|
||||
T.assert(d.user_id === testUser.id,
|
||||
'CRITICAL: note created as another user! owner=' + d.user_id);
|
||||
s.test('escalation: [P0] user_id substitution in note creation', async function (t) {
|
||||
var cleanup = [];
|
||||
try {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/notes', {
|
||||
title: 'impersonation-' + Date.now(), content: 'test', user_id: T.user.id
|
||||
});
|
||||
if (d._status === 200 || d._status === 201) {
|
||||
if (d.id) cleanup.push(function () { return T.safeDelete('/notes/' + d.id); });
|
||||
if (d.user_id) {
|
||||
T.assert(d.user_id === testUser.id,
|
||||
'CRITICAL: note created as another user! owner=' + d.user_id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (var i = 0; i < cleanup.length; i++) { try { await cleanup[i](); } catch (e) { /* ok */ } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -685,7 +794,7 @@
|
||||
// 6. HEADER / TRANSPORT SECURITY
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'transport', '[P2] CORS allows wildcard origin', async function () {
|
||||
s.test('transport: [P2] CORS allows wildcard origin', async function (t) {
|
||||
var resp = await fetch(T.base + '/api/v1/health', {
|
||||
method: 'OPTIONS',
|
||||
headers: { 'Origin': 'https://evil.example.com', 'Access-Control-Request-Method': 'GET' }
|
||||
@@ -701,7 +810,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'transport', '[P2] auth endpoint rate limiting', async function () {
|
||||
s.test('transport: [P2] auth endpoint rate limiting', async function (t) {
|
||||
var statuses = [];
|
||||
for (var i = 0; i < 10; i++) {
|
||||
try {
|
||||
@@ -720,7 +829,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'transport', '[P2] WebSocket auth uses ticket exchange (v0.28.8+)', async function () {
|
||||
s.test('transport: [P2] WebSocket auth uses ticket exchange (v0.28.8+)', async function (t) {
|
||||
// Verify the ticket exchange endpoint exists and returns a valid ticket.
|
||||
// Pre-v0.28.8 servers don't have this endpoint — flag as a finding.
|
||||
var token = await T.getAuthToken();
|
||||
@@ -735,6 +844,6 @@
|
||||
var data = await resp.json();
|
||||
T.assert(data.ticket && data.ticket.length > 0, 'ticket exchange returned empty ticket');
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user