207 lines
12 KiB
JavaScript
207 lines
12 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', {}],
|
|
['plugins', 'GET', 'plugins', {}],
|
|
['plugins/set', 'POST', 'plugins.set', { revision: 'a'.repeat(64), plugins: { 'model-provider': ['cliproxy'] } }],
|
|
['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 }],
|
|
['group/models/set', 'POST', 'proxy.group.models.set', { accountId: 'a'.repeat(12), models: ['fixture-model'] }],
|
|
];
|
|
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, headers: res.headers, 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 entry paths redirect GET and HEAD to the root hash route', async t => {
|
|
const { s } = await setup(t);
|
|
for (const path of ['/proxy', '/settings/model-proxy']) for (const method of ['GET', 'HEAD']) {
|
|
const r = await call(s, path, method);
|
|
assert.equal(r.status, 303, `${method} ${path}`);
|
|
assert.equal(r.headers.location, '/#/settings/model-proxy', `${method} ${path}`);
|
|
assert.equal(r.headers['cache-control'], 'no-store');
|
|
}
|
|
});
|
|
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);
|
|
for (const path of ['/proxy', '/settings/model-proxy', '/proxy.js']) for (const method of ['GET', 'HEAD']) assert.equal((await call(s, path, method)).status, 403, `${method} ${path}`);
|
|
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 old = await call(s, '/proxy');
|
|
assert.equal(old.status, 303);
|
|
assert.equal(old.headers.location, '/#/settings/model-proxy');
|
|
const r = await call(s, '/');
|
|
assert.equal(r.status, 200);
|
|
const script = await call(s, '/display.js');
|
|
assert.equal(script.status, 200);
|
|
assert.match(script.text, /type="password"/);
|
|
assert.match(script.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, testerSocketPath, TESTER_ORIGIN } = 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)));
|
|
for (const path of ['/proxy', '/settings/model-proxy', '/proxy.js']) {
|
|
const page = await testerRequest({ dataRoot: root, path });
|
|
assert.equal(page.status, 403, `GET ${path}`);
|
|
const head = await new Promise((resolve, reject) => {
|
|
const req = request({ socketPath: testerSocketPath(root), path, method: 'HEAD',
|
|
headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => {
|
|
res.resume(); res.on('end', () => resolve(res.statusCode));
|
|
});
|
|
req.on('error', reject); req.end();
|
|
});
|
|
assert.equal(head, 403, `HEAD ${path}`);
|
|
}
|
|
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, headers: res.headers, 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, []);
|
|
});
|
|
|
|
|
|
test('proxy pages require sign-in and named host before redirect or content', async t => {
|
|
const { s } = await setup(t);
|
|
for (const path of ['/proxy', '/settings/model-proxy', '/proxy.js']) {
|
|
const signedOut = { ...s.headers }; delete signedOut.cookie;
|
|
const response = await call(s, path, 'GET', undefined, signedOut);
|
|
assert.equal(response.status, 303, path);
|
|
assert.match(response.headers.location, /^\/auth\/login\?/);
|
|
const loopback = { ...s.headers, host: '127.0.0.1' };
|
|
for (const method of ['GET', 'HEAD']) assert.equal((await call(s, path, method, undefined, loopback)).status, 403, `${method} ${path}`);
|
|
}
|
|
});
|
|
|
|
test('proxy refuses route and body mismatch, unavailable transport, and bus refusal', async t => {
|
|
const { s, calls } = await setup(t);
|
|
assert.equal((await call(s, '/api/proxy/unknown')).status, 404);
|
|
assert.equal((await call(s, '/api/proxy/accounts', 'POST', {})).status, 405);
|
|
assert.equal((await call(s, '/api/proxy/apikey/add', 'POST', { provider: 'z.ai', key: 'fixture', extra: true })).status, 400);
|
|
assert.deepEqual(calls, []);
|
|
const absent = await setup(t, { proxyCall: null });
|
|
assert.equal((await call(absent.s, '/api/proxy/accounts')).status, 503);
|
|
const refused = await setup(t, { proxyCall: async () => { throw Object.assign(new Error('private'), { code: 'not-configured' }); } });
|
|
for (const [path, method, , args] of routes) {
|
|
const r = await call(refused.s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined);
|
|
assert.equal(r.status, 503, path);
|
|
assert.deepEqual(JSON.parse(r.text), { error: 'not-configured' });
|
|
}
|
|
});
|
|
|
|
test('ambiguous model mappings pass their named refusal to the owner', async t => {
|
|
const {s}=await setup(t,{proxyCall:async()=>{throw Object.assign(new Error('private'),{code:'models-ambiguous'});}});
|
|
const response=await call(s,'/api/proxy/group/models/set','POST',{accountId:'a'.repeat(12),models:['fixture']});
|
|
assert.equal(response.status,400);assert.deepEqual(JSON.parse(response.text),{error:'models-ambiguous'});
|
|
});
|
|
|
|
test('plugin switch accepts only one allowed kind with string names', async t => {
|
|
const {s,calls}=await setup(t);
|
|
const revision='a'.repeat(64);
|
|
for (const plugins of [{harness:['pi','claude-code']},{'model-provider':['cliproxy']}]) {
|
|
assert.equal((await call(s,'/api/proxy/plugins/set','POST',{revision,plugins})).status,200);
|
|
assert.deepEqual(calls.at(-1),['plugins.set',{revision,plugins}]);
|
|
}
|
|
const count=calls.length;
|
|
for (const plugins of [{harness:[1]},{harness:'pi'},{harness:['pi'], 'model-provider':[]},{channel:[]},{}])
|
|
assert.equal((await call(s,'/api/proxy/plugins/set','POST',{revision,plugins})).status,400);
|
|
assert.equal(calls.length,count);
|
|
});
|
|
|
|
test('plugin refusal code is returned to the owner', async t => {
|
|
const {s}=await setup(t,{proxyCall:async()=>{throw Object.assign(new Error('private'),{code:'revision-conflict'});}});
|
|
const response=await call(s,'/api/proxy/plugins/set','POST',{revision:'a'.repeat(64),plugins:{harness:['pi']}});
|
|
assert.equal(response.status,400);assert.deepEqual(JSON.parse(response.text),{error:'revision-conflict'});
|
|
});
|