126 lines
6.9 KiB
JavaScript
126 lines
6.9 KiB
JavaScript
// P4: real HTTP boundary; mutant: proxy route moved ahead of granted-user fence.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { consoleBus } from '../src/console-bus.mjs';
|
|
import { consoleAccess } from '../src/access.mjs';
|
|
import { createSsoSession } from './helpers/sso-session.mjs';
|
|
const routes = [
|
|
['accounts', 'GET', 'proxy.accounts', {}], ['keys', 'GET', 'proxy.keys', {}],
|
|
['login/start', 'POST', 'proxy.login.start', { provider: 'claude' }],
|
|
['login/submit', 'POST', 'proxy.login.submit', { redirect_url: 'http://localhost/callback?state=fixture&code=fake' }],
|
|
['login/status', 'POST', 'proxy.login.status', { state: 'fixture' }],
|
|
['login/cancel', 'POST', 'proxy.login.cancel', { state: 'fixture' }],
|
|
['apikey/add', 'POST', 'proxy.apikey.add', { provider: 'z.ai', key: 'p4-canary-key' }],
|
|
['priority/set', 'POST', 'proxy.priority.set', { accountId: 'a'.repeat(12), priority: 5 }],
|
|
];
|
|
const call = (s, path, method = 'GET', value, hs = s.headers) => new Promise((resolve, reject) => {
|
|
const req = request({ hostname: '127.0.0.1', port: s.address().port, path, method, headers: hs }, res => {
|
|
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text }));
|
|
});
|
|
req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value));
|
|
});
|
|
async function setup(t, options = {}) {
|
|
const calls = [], logs = [];
|
|
const auth = await createSsoSession(t, { host: 'console.example', owner: { sub: 'owner-sub', name: 'owner' } });
|
|
const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}),
|
|
proxyCall: async (verb, args) => { calls.push([verb, args]); return { ok: true }; },
|
|
log: l => logs.push(l), ...options });
|
|
t.after(() => new Promise(r => s.close(r)));
|
|
const session = await auth.signIn(s.address().port);
|
|
s.headers = auth.writeHeaders(session);
|
|
return { s, calls, logs };
|
|
}
|
|
test('owner proxy routes each make exactly one allowlisted call', async t => {
|
|
const { s, calls, logs } = await setup(t);
|
|
for (const [path, method, verb, args] of routes) {
|
|
const r = await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined);
|
|
assert.equal(r.status, 200, path);
|
|
assert.deepEqual(calls.at(-1), [verb, args]);
|
|
}
|
|
assert.equal(calls.length, routes.length);
|
|
for (const value of ['p4-canary-key', 'fixture', 'redirect_url']) assert.equal(logs.join('\n').includes(value), false);
|
|
});
|
|
for (const [name, change, code] of [
|
|
['signed-out', hs => { delete hs.cookie; }, 401],
|
|
['loopback', hs => { hs.host = '127.0.0.1'; }, 403],
|
|
['cross-origin', hs => { hs.origin = 'https://other.example'; }, 403],
|
|
['missing Fetch Metadata', hs => { delete hs['sec-fetch-site']; }, 403],
|
|
]) test(`proxy write refuses ${name}`, async t => {
|
|
const { s, calls } = await setup(t);
|
|
const hs = { ...s.headers }; change(hs);
|
|
assert.equal((await call(s, '/api/proxy/apikey/add', 'POST', { provider: 'z.ai', key: 'fixture-key' }, hs)).status, code);
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
test('all proxy writes are read-only without --send', async t => {
|
|
const { s, calls } = await setup(t, { send: null });
|
|
for (const [path, method, , args] of routes.filter(r => r[1] === 'POST'))
|
|
assert.equal((await call(s, '/api/proxy/' + path, method, args)).status, 403, path);
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
test('granted user never reaches owner page or any proxy route', async t => {
|
|
const access = { accessVersion: 1, revision: 1,
|
|
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
|
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] };
|
|
const users = consoleAccess({ load: () => access, business: () => 'demo' });
|
|
const auth = await createSsoSession(t, { host: 'console.example', users });
|
|
const calls = [];
|
|
const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), proxyCall: async (...args) => { calls.push(args); return {}; }, userOptions: () => { throw Error('must not bind'); } });
|
|
t.after(() => new Promise(resolve => s.close(resolve)));
|
|
const session = await auth.signIn(s.address().port, { sub: 'alice-sub' });
|
|
s.headers = auth.writeHeaders(session);
|
|
assert.equal((await call(s, '/proxy')).status, 403);
|
|
for (const [path, method, , args] of routes)
|
|
assert.equal((await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined)).status, 403, path);
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
test('native page offers write-only key input and no panel script', async t => {
|
|
const { s } = await setup(t);
|
|
const r = await call(s, '/proxy');
|
|
assert.equal(r.status, 200);
|
|
assert.match(r.text, /type="password"/);
|
|
assert.match(r.text, /autocomplete="off"/);
|
|
assert.doesNotMatch(r.text, /management\.html|iframe/);
|
|
});
|
|
test('Console caller rejects every non-proxy verb', async () => {
|
|
const calls = [];
|
|
const o = consoleBus(async (...a) => { calls.push(a); return {}; }, { send: true });
|
|
assert.equal(typeof o.proxyCall, 'function');
|
|
await assert.rejects(o.proxyCall('message.send', {}));
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
test('tester socket refuses proxy page, reads and writes before any owner call', async t => {
|
|
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
const { tmpdir } = await import('node:os');
|
|
const { join } = await import('node:path');
|
|
const { startTesterSocket } = await import('../src/tester-socket.mjs');
|
|
const { testerRequest } = await import('../../cli/src/tester-http.mjs');
|
|
const root = mkdtempSync(join(tmpdir(), 'p4-tester-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
let calls = 0;
|
|
const s = await startTesterSocket({ dataRoot: root, proxyCall: async () => { calls++; return {}; }, log: () => {} });
|
|
t.after(() => new Promise(r => s.close(r)));
|
|
const page = await testerRequest({ dataRoot: root, path: '/proxy' });
|
|
assert.equal(page.status, 403);
|
|
for (const [path, method, , args] of routes) {
|
|
const r = await testerRequest({ dataRoot: root, path: '/api/proxy/' + path, method, body: method === 'POST' ? args : undefined });
|
|
assert.equal(r.status, 403, path);
|
|
}
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
test('malformed API-key JSON never appears in errors or logs', async t => {
|
|
const { s, calls, logs } = await setup(t);
|
|
const canary = 'p4-canary-key';
|
|
const r = await new Promise((resolve, reject) => {
|
|
const req = request({ hostname: '127.0.0.1', port: s.address().port, path: '/api/proxy/apikey/add', method: 'POST', headers: s.headers }, res => {
|
|
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text }));
|
|
}); req.on('error', reject); req.end('{"key":"' + canary);
|
|
});
|
|
assert.equal(r.status, 400);
|
|
assert.equal(r.text.includes(canary), false); assert.equal(logs.join('\n').includes(canary), false);
|
|
assert.deepEqual(calls, []);
|
|
});
|