/** * ICD Test Runner — Security Tier * Adversarial red-team tests: auth boundary, cross-tenant isolation, * input validation, session security. * * REQUIRES: fixtures provisioned (3 users + team). * * Many of these tests are EXPECTED TO FIND REAL BUGS. The test names * include severity tags: [P0] = must-fix before 1.0, * [P1] = should-fix, [P2] = harden. * * 502 handling: a 502 means the request never reached the Go backend * (Traefik/nginx returned it). This is NOT evidence of a security * pass or fail — it is INCONCLUSIVE. Tests that receive 502 throw * with a clear "INCONCLUSIVE (502)" prefix so they show as failures * without being misread as security breaches. */ (function () { 'use strict'; var T = window.ICD; if (!T) return; // ─── Helpers ────────────────────────────────────────────── function randomString(len) { var chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; var s = ''; for (var i = 0; i < (len || 8); i++) s += chars.charAt(Math.floor(Math.random() * chars.length)); return s; } /** * Assert a status code indicates denial (401, 403, 404). * 502 = INCONCLUSIVE (proxy ate the request, backend never saw it). * Anything else (especially 200/201) = real security failure. */ function assertDenied(status, label) { if (status === 502) { throw new Error('INCONCLUSIVE (502): proxy returned 502 before request reached backend — ' + label); } if (status === 401 || status === 403 || status === 404) return; throw new Error(label + ' — got HTTP ' + status); } /** * Assert a raw fetch() Response is a denial. */ function assertRawDenied(resp, label) { if (resp.status === 502) { throw new Error('INCONCLUSIVE (502): proxy returned 502 — ' + label + '. Verify Traefik/nginx forwards unauthenticated requests to the Go backend.'); } if (resp.status === 401 || resp.status === 403 || resp.status === 404) return; throw new Error(label + ' — got HTTP ' + resp.status); } // ─── Main Entry ─────────────────────────────────────────── T.runSecurity = async function () { if (!T.fixtures.ready) { if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first', 'warning'); return; } var testUser = T.getFixtureUser('-user'); var teamAdmin = T.getFixtureUser('-tadmin'); var testAdmin = T.getFixtureUser('-admin2'); 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' }); return; } // ════════════════════════════════════════════════════════ // 1. AUTH BOUNDARY // ════════════════════════════════════════════════════════ await T.test('security', 'auth-boundary', '[P0] deactivated user JWT still valid', async function () { 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); }); 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'); 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'); }); await T.test('security', 'auth-boundary', '[P0] demoted admin JWT retains admin access', async function () { 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); }); 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'); 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'); }); await T.test('security', 'auth-boundary', '[P1] refresh token reuse after logout', async function () { 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); }); 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 }); 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); }); await T.test('security', 'auth-boundary', '[P1] refresh token rotation (old invalid after use)', async function () { 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); }); 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 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); }); await T.test('security', 'auth-boundary', '[P0] tampered JWT payload accepted', async function () { var parts = testUser.token.split('.'); T.assert(parts.length === 3, 'token is not 3-part JWT'); try { var payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); payload.role = 'admin'; var faked = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); var forgedToken = parts[0] + '.' + faked + '.' + parts[2]; var d = await T.authFetch(forgedToken, 'GET', '/admin/stats'); T.assert(d._status === 401, 'CRITICAL: tampered JWT was accepted! got ' + d._status); } catch (e) { if (e.message && e.message.indexOf('CRITICAL') !== -1) throw e; } }); await T.test('security', 'auth-boundary', '[P0] JWT alg=none accepted', async function () { 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', exp: Math.floor(Date.now() / 1000) + 3600 })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); var forged = header + '.' + payload + '.'; var d = await T.authFetch(forged, 'GET', '/admin/stats'); T.assert(d._status === 401, 'CRITICAL: alg=none JWT was accepted! got ' + d._status); }); await T.test('security', 'auth-boundary', '[P1] requests with no user_id context', async function () { 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 () { 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); }); 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 }); 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); }); // ════════════════════════════════════════════════════════ // 2. CROSS-TENANT DATA ACCESS // ════════════════════════════════════════════════════════ // ── 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); }); var steal = await T.authFetch(testUser.token, 'GET', '/notes/' + adminNote); assertDenied(steal._status, 'user can read another user\'s note'); }); await T.test('security', 'cross-tenant', '[P0] user → update another user\'s note', async function () { 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 () { 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'); }); // ── Memory isolation (no POST /memories — system-extracted only) ── await T.test('security', 'cross-tenant', '[P1] user → GET /admin/memories/pending (expect deny)', async function () { 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 () { 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 () { 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'); }); // ── 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); }); 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'); }); await T.test('security', 'cross-tenant', '[P0] user → POST message to another user\'s channel', async function () { 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' }); 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 () { 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 () { 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'); }); // ── BYOK config isolation ── await T.test('security', 'cross-tenant', '[P1] user → enumerate BYOK configs', async function () { var d = await T.authFetch(testUser.token, 'GET', '/api-configs'); T.assertStatus(d, 200, 'api-configs'); var arr = d.data || d.configs || []; if (Array.isArray(arr)) { arr.forEach(function (cfg) { if (cfg.user_id) { T.assert(cfg.user_id === testUser.id, 'BYOK config user_id mismatch — leaking other user\'s config'); } }); } }); // ── Task isolation ── var adminTask = null; await T.test('security', 'cross-tenant', '[P0] user → read another user\'s task', async function () { 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 */ } 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'); }); await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s task', async function () { 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'); return; } var d = await T.authFetch(testUser.token, 'DELETE', '/tasks/' + adminTask); assertDenied(d._status, 'user can delete another user\'s task'); }); // ── Workspace isolation ── var adminWs = null; await T.test('security', 'cross-tenant', '[P0] user → read another user\'s workspace', async function () { 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; var steal = await T.authFetch(testUser.token, 'GET', '/workspaces/' + adminWs); assertDenied(steal._status, 'user can read another user\'s workspace'); }); // ── Cross-team access ── await T.test('security', 'cross-tenant', '[P1] user → access non-member team providers', async function () { var otherTeam = null; 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 steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/providers'); assertDenied(steal._status, 'user can list providers for a team they are not in'); }); await T.test('security', 'cross-tenant', '[P1] user → access non-member team personas', async function () { var otherTeam = null; 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 steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/personas'); assertDenied(steal._status, 'user can list personas for a team they are not in'); }); await T.test('security', 'cross-tenant', '[P1] user → access non-member team members list', async function () { var otherTeam = null; 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 steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/members'); assertDenied(steal._status, 'user can list members of a team they are not in'); }); // ── Project isolation ── var adminProject = null; await T.test('security', 'cross-tenant', '[P0] user → read another user\'s personal project', async function () { 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; var steal = await T.authFetch(testUser.token, 'GET', '/projects/' + adminProject); assertDenied(steal._status, 'user can read another user\'s project'); }); await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s project', async function () { if (!adminProject) return; var d = await T.authFetch(testUser.token, 'DELETE', '/projects/' + adminProject); assertDenied(d._status, 'user can delete another user\'s project'); }); // ── Notification isolation ── await T.test('security', 'cross-tenant', '[P1] user → list only sees own notifications', async function () { var d = await T.authFetch(testUser.token, 'GET', '/notifications'); T.assertStatus(d, 200, 'notifications'); }); // ════════════════════════════════════════════════════════ // 3. INPUT VALIDATION // ════════════════════════════════════════════════════════ var sqliPayloads = [ "' OR '1'='1", "'; DROP TABLE users; --", "' UNION SELECT id, password_hash, email, role, 'x', 'x' FROM users --", "1; SELECT pg_sleep(5)--", "' OR 1=1--" ]; 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 () { var d = await T.authFetch(testUser.token, 'GET', '/notes?query=' + encodeURIComponent(payload)); T.assert(d._status !== 500, 'SQL injection caused server error (500)! Payload: ' + payload); }); })(sqliPayloads[si], si); } await T.test('security', 'input-validation', '[P0] SQLi in channel search', async function () { 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 () { 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: '', 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'); } }); await T.test('security', 'input-validation', '[P2] XSS in note content (stored)', async function () { var n = await T.apiPost('/notes', { title: 'xss-test', content: '' }); 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'); } }); await T.test('security', 'input-validation', '[P2] XSS in persona name (stored)', async function () { try { var p = await T.apiPost('/personas', { name: '">', 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); }); } T.assert(d._status !== 500, 'oversized title caused 500'); }); await T.test('security', 'input-validation', '[P2] oversized note content (5MB)', async function () { 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 */ } }); await T.test('security', 'input-validation', '[P2] malformed JSON body', async function () { var resp = await fetch(T.base + '/api/v1/channels', { method: 'POST', headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/json' }, credentials: 'same-origin', body: '{title: invalid, no-quotes}' }); if (resp.status === 502) { throw new Error('INCONCLUSIVE (502): proxy returned 502 — may not forward malformed bodies to backend'); } 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 () { var resp = await fetch(T.base + '/api/v1/channels', { method: 'POST', headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'same-origin', body: 'title=test&type=direct' }); if (resp.status === 502) { throw new Error('INCONCLUSIVE (502): proxy returned 502 on content-type mismatch'); } T.assert(resp.status === 400 || resp.status === 415, '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' }); 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'); } 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); }); } } }); await T.test('security', 'input-validation', '[P1] null byte in channel ID param', async function () { 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 () { 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); }); } T.assert(d._status !== 500, 'RTLO character caused 500'); }); // ════════════════════════════════════════════════════════ // 4. SESSION SECURITY (unauthenticated access) // Raw fetch() without auth headers. 502 = INCONCLUSIVE // (proxy may not forward unauthenticated requests). // ════════════════════════════════════════════════════════ await T.test('security', 'session', '[P0] unauthenticated → GET /channels (expect 401)', async function () { 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 () { 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 () { 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 () { var resp = await fetch(T.base + '/api/v1/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ title: 'unauth-test', type: 'direct' }) }); assertRawDenied(resp, 'unauthenticated channel creation'); }); await T.test('security', 'session', '[P1] cookie-only → attempt JWT-protected endpoints', async function () { 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 () { 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); }); // ════════════════════════════════════════════════════════ // 5. PRIVILEGE ESCALATION ATTEMPTS // ════════════════════════════════════════════════════════ await T.test('security', 'escalation', '[P0] user → PUT own role to admin', async function () { 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 () { 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' }); assertDenied(d._status, 'CRITICAL: non-admin created an admin user'); }); if (teamAdmin && teamAdmin.token) { await T.test('security', 'escalation', '[P0] teamAdmin → PUT /admin/settings', async function () { 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 () { 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 () { 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); } }); 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); } } }); // ════════════════════════════════════════════════════════ // 6. HEADER / TRANSPORT SECURITY // ════════════════════════════════════════════════════════ await T.test('security', 'transport', '[P2] CORS allows wildcard origin', async function () { var resp = await fetch(T.base + '/api/v1/health', { method: 'OPTIONS', headers: { 'Origin': 'https://evil.example.com', 'Access-Control-Request-Method': 'GET' } }); var acao = resp.headers.get('Access-Control-Allow-Origin'); if (acao === '*') { // Wildcard CORS is expected in dev/test deployments (non-root base path). // Only flag as a failure for root-path (production) deployments. var isDevOrTest = T.base && (T.base.indexOf('/dev') !== -1 || T.base.indexOf('/test') !== -1); if (!isDevOrTest) { T.assert(false, 'CORS returns Access-Control-Allow-Origin: * in production — must restrict'); } } }); await T.test('security', 'transport', '[P2] auth endpoint rate limiting', async function () { var statuses = []; for (var i = 0; i < 10; i++) { try { var d = await T.publicPost('/auth/login', { login: 'nonexistent', password: 'wrong' }); statuses.push(d._status); } catch (e) { statuses.push('err'); } } var backendStatuses = statuses.filter(function (s) { return s !== 502; }); if (backendStatuses.length === 0) { throw new Error('INCONCLUSIVE (502): all login attempts returned 502 — cannot test rate limiting'); } var rateLimited = backendStatuses.some(function (s) { return s === 429; }); if (!rateLimited) { T.assert(false, 'no rate limiting on /auth/login after ' + backendStatuses.length + ' attempts reaching backend — statuses: [' + backendStatuses.join(',') + ']'); } }); await T.test('security', 'transport', '[P2] WebSocket auth uses ticket exchange (v0.28.8+)', async function () { // 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(); var resp = await fetch(T.base + '/api/v1/ws/ticket', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, credentials: 'same-origin', body: '{}' }); T.assert(resp.ok, 'POST /api/v1/ws/ticket returned HTTP ' + resp.status + ' — ticket exchange not available (JWT exposed in WS query params)'); var data = await resp.json(); T.assert(data.ticket && data.ticket.length > 0, 'ticket exchange returned empty ticket'); }); }; })();