264 lines
8.6 KiB
JavaScript
264 lines
8.6 KiB
JavaScript
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
|
|
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
|
|
import { createClient } from '../helpers/client.js';
|
|
|
|
describe('Security', () => {
|
|
let serverUrl;
|
|
let testSiteUrl;
|
|
|
|
beforeAll(async () => {
|
|
await startServer();
|
|
serverUrl = getServerUrl();
|
|
const testPort = await startTestSite();
|
|
testSiteUrl = getTestSiteUrl();
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
await stopTestSite();
|
|
await stopServer();
|
|
}, 30000);
|
|
|
|
describe('URL scheme validation', () => {
|
|
test('blocks file:// URLs on tab creation', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
await client.createTab('file:///etc/passwd');
|
|
fail('Should have rejected file:// URL');
|
|
} catch (err) {
|
|
expect(err.status).toBe(400);
|
|
expect(err.data.error).toContain('Blocked URL scheme');
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
});
|
|
|
|
test('blocks javascript: URLs on tab creation', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
await client.createTab('javascript:alert(1)');
|
|
fail('Should have rejected javascript: URL');
|
|
} catch (err) {
|
|
expect(err.status).toBe(400);
|
|
expect(err.data.error).toContain('Blocked URL scheme');
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
});
|
|
|
|
test('blocks data: URLs on tab creation', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
await client.createTab('data:text/html,<h1>hello</h1>');
|
|
fail('Should have rejected data: URL');
|
|
} catch (err) {
|
|
expect(err.status).toBe(400);
|
|
expect(err.data.error).toContain('Blocked URL scheme');
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
});
|
|
|
|
test('blocks file:// URLs on navigate', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
|
|
await client.navigate(tabId, 'file:///etc/shadow');
|
|
fail('Should have rejected file:// URL');
|
|
} catch (err) {
|
|
expect(err.status).toBe(400);
|
|
expect(err.data.error).toContain('Blocked URL scheme');
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
});
|
|
|
|
test('allows http:// URLs', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
const result = await client.createTab(`${testSiteUrl}/pageA`);
|
|
expect(result.tabId).toBeDefined();
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
});
|
|
|
|
test('allows https:// URLs', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
// This will fail to connect but should not be blocked by scheme validation
|
|
await client.createTab('https://localhost:99999/nope');
|
|
} catch (err) {
|
|
// Connection error is fine -- the point is it wasn't a 400 scheme block
|
|
expect(err.data?.error || '').not.toContain('Blocked URL scheme');
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rejects invalid URLs', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
await client.createTab('not-a-url');
|
|
fail('Should have rejected invalid URL');
|
|
} catch (err) {
|
|
expect(err.status).toBe(400);
|
|
expect(err.data.error).toContain('Invalid URL');
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Resource limits', () => {
|
|
test('recycles oldest tab when session limit reached', async () => {
|
|
const client = createClient(serverUrl);
|
|
const tabs = [];
|
|
try {
|
|
for (let i = 0; i < 10; i++) {
|
|
const result = await client.createTab(`${testSiteUrl}/pageA`);
|
|
tabs.push(result.tabId);
|
|
}
|
|
// 11th tab should succeed by recycling the oldest
|
|
const result = await client.createTab(`${testSiteUrl}/pageA`);
|
|
expect(result.tabId).toBeDefined();
|
|
// The recycled (oldest) tab should no longer be accessible
|
|
try {
|
|
await client.getSnapshot(tabs[0]);
|
|
// If it doesn't throw, the tab still exists -- that's unexpected but not fatal
|
|
} catch (err) {
|
|
// Expected: oldest tab was recycled
|
|
expect(err.status).toBe(410);
|
|
}
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
}, 120000);
|
|
|
|
test('can create tabs after closing some', async () => {
|
|
const client = createClient(serverUrl);
|
|
try {
|
|
const tabs = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const result = await client.createTab(`${testSiteUrl}/pageA`);
|
|
tabs.push(result.tabId);
|
|
}
|
|
// Close one
|
|
await client.closeTab(tabs[0]);
|
|
// Should now be able to create another
|
|
const result = await client.createTab(`${testSiteUrl}/pageA`);
|
|
expect(result.tabId).toBeDefined();
|
|
} finally {
|
|
await client.cleanup();
|
|
}
|
|
}, 120000);
|
|
});
|
|
|
|
describe('Health endpoint info disclosure', () => {
|
|
test('health does not expose session count', async () => {
|
|
const res = await fetch(`${serverUrl}/health`);
|
|
const data = await res.json();
|
|
expect(data.sessions).toBeUndefined();
|
|
expect(data.ok).toBe(true);
|
|
expect(data.engine).toBe('camoufox');
|
|
});
|
|
|
|
test('root does not expose session count', async () => {
|
|
const res = await fetch(`${serverUrl}/`);
|
|
const data = await res.json();
|
|
expect(data.sessions).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /stop requires admin key', () => {
|
|
test('rejects stop without admin key', async () => {
|
|
const res = await fetch(`${serverUrl}/stop`, { method: 'POST' });
|
|
expect(res.status).toBe(403);
|
|
const data = await res.json();
|
|
expect(data.error).toBe('Forbidden');
|
|
});
|
|
|
|
test('rejects stop with wrong admin key', async () => {
|
|
const res = await fetch(`${serverUrl}/stop`, {
|
|
method: 'POST',
|
|
headers: { 'x-admin-key': 'wrong-key' },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('OpenClaw endpoints require userId', () => {
|
|
test('POST /tabs/open rejects without userId', async () => {
|
|
const res = await fetch(`${serverUrl}/tabs/open`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: `${testSiteUrl}/pageA` }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.error).toContain('userId');
|
|
});
|
|
|
|
test('POST /navigate rejects without userId', async () => {
|
|
const res = await fetch(`${serverUrl}/navigate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetId: 'fake', url: `${testSiteUrl}/pageA` }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.error).toContain('userId');
|
|
});
|
|
|
|
test('GET /snapshot rejects without userId', async () => {
|
|
const res = await fetch(`${serverUrl}/snapshot?targetId=fake`);
|
|
expect(res.status).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.error).toContain('userId');
|
|
});
|
|
|
|
test('POST /act rejects without userId', async () => {
|
|
const res = await fetch(`${serverUrl}/act`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ kind: 'click', targetId: 'fake', ref: 'e1' }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.error).toContain('userId');
|
|
});
|
|
});
|
|
|
|
describe('Session isolation', () => {
|
|
test('users cannot access each other tabs', async () => {
|
|
const client1 = createClient(serverUrl);
|
|
const client2 = createClient(serverUrl);
|
|
try {
|
|
const { tabId } = await client1.createTab(`${testSiteUrl}/pageA`);
|
|
|
|
// client2 trying to snapshot client1's tab should 404
|
|
try {
|
|
await client2.getSnapshot(tabId);
|
|
fail('Should not be able to access another user tab');
|
|
} catch (err) {
|
|
expect(err.status).toBe(410);
|
|
}
|
|
} finally {
|
|
await client1.cleanup();
|
|
await client2.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('JSON body size limit', () => {
|
|
test('rejects oversized request bodies', async () => {
|
|
const largeBody = JSON.stringify({ data: 'x'.repeat(200000) });
|
|
const res = await fetch(`${serverUrl}/tabs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: largeBody,
|
|
});
|
|
expect(res.status).toBe(413);
|
|
});
|
|
});
|
|
});
|