Files
proffesor-for-testing__agen…/tests/unit/scripts/qe-browser-fixtures-server.test.ts
Dragan Spiridonov 3eb9ccc461 fix(qe-browser): Phase 4 — all 9 medium devil's-advocate findings
M1 visual-diff threshold: documented "max difference fraction" convention
matching Playwright maxDiffPixelRatio (was inconsistent in docs/code).

M2 fixture server bind: default to 127.0.0.1 instead of 0.0.0.0;
QE_BROWSER_FIXTURE_HOST=0.0.0.0 opt-in for explicit external access.
Codespaces auto-forwards 0.0.0.0 ports — this was an unintentional open
server every time the eval ran.

M3 path-traversal guard: replaced startsWith() with path.relative() +
'..'/absolute check. The old guard false-passed sibling-prefix dirs
(/foo vs /foo2) and was fragile on Windows mixed separators.

M4 ANSI escape injection: added sanitizeSnippet() that strips C0 controls
(0x00-0x1F except \t\n) and DEL (0x7F) from every check-injection
finding snippet. Prevents `cat findings.json` from triggering terminal
control sequences embedded in malicious page content.

M5 parseArgs --key=value: split on first '=' so --threshold=0.05 works
alongside --threshold 0.05. Only the first '=' is split so URL/base64
values survive intact.

M6 batch.js pre-validation: added validateAllSteps() that walks every
step's required fields BEFORE the first vibium call. Step 17 typo now
aborts before steps 1-16 execute their side effects.

M7 intent-score bare-x regex: anchored \bx\b in close_dialog scorer so
"fix", "exit", "extra", "sixteen" no longer match. Unicode × and ✕
unchanged.

M8 check-injection --exclude-selector: new flag drops subtrees from
the scan. Page text is read from a CLONE of <body> with matching
elements removed — live page unchanged. Lets docs sites scan their
own pages without self-flagging documentation about prompt injection.

M9 09-assets vibium catch: wrapped raw error with actionable recovery
guidance (`npm install -g vibium`, then re-run `aqe init`).

Hidden bug found and fixed during M2/M3 testing: fixtures/serve-skills.js
existed but had never been run because the repo root is ESM and the file
uses CommonJS require(). Added scoped fixtures/package.json with
"type": "commonjs" matching the existing scripts/package.json pattern.

Tests: 86 unit tests across 7 files (was 50). All pass.
Smoke test: 9/9 still passing against real Vibium v26.3.18.
Mirrored .claude/skills/qe-browser → assets/skills/qe-browser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:13:40 +00:00

142 lines
5.2 KiB
TypeScript

/**
* qe-browser: fixtures/serve-skills.js — host binding + path-traversal
*
* Boots the fixture HTTP server in a child process, hits it via http.get,
* and verifies M2 (defaults to loopback) and M3 (path.relative-based
* traversal guard).
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { spawn, ChildProcess } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { resolve } from 'node:path';
const SERVE_SCRIPT = resolve(
__dirname,
'../../../.claude/skills/qe-browser/fixtures/serve-skills.js'
);
let proc: ChildProcess | null = null;
function waitFor(stream: NodeJS.ReadableStream, needle: string, timeoutMs: number): Promise<string> {
return new Promise((resolveP, reject) => {
let buf = '';
const onData = (chunk: Buffer | string) => {
buf += chunk.toString();
if (buf.includes(needle)) {
stream.off('data', onData);
resolveP(buf);
}
};
stream.on('data', onData);
setTimeout(() => {
stream.off('data', onData);
reject(new Error(`timed out waiting for "${needle}"; saw: ${buf}`));
}, timeoutMs);
});
}
function startServer(env: NodeJS.ProcessEnv): Promise<{ host: string; port: number }> {
return new Promise((resolveP, reject) => {
const child = spawn(process.execPath, [SERVE_SCRIPT], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
proc = child;
child.on('error', reject);
waitFor(child.stdout!, 'qe-browser fixtures listening on', 5000)
.then((banner) => {
const m = banner.match(/listening on http:\/\/([^:]+):(\d+)/);
if (!m) {
reject(new Error(`could not parse listen banner: ${banner}`));
return;
}
resolveP({ host: m[1], port: Number(m[2]) });
})
.catch(reject);
});
}
function fetch(host: string, port: number, path: string): Promise<{ status: number; body: string }> {
return new Promise((resolveP, reject) => {
const req = httpRequest({ host, port, path, method: 'GET' }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => resolveP({ status: res.statusCode || 0, body }));
});
req.on('error', reject);
req.setTimeout(3000, () => req.destroy(new Error('client timeout')));
req.end();
});
}
describe('qe-browser fixtures/serve-skills', () => {
beforeEach(() => {
proc = null;
});
afterEach(() => {
if (proc && !proc.killed) {
proc.kill('SIGTERM');
proc = null;
}
});
// M2 regression: server must NOT bind to 0.0.0.0 by default. Codespaces
// and shared dev hosts auto-forward 0.0.0.0 ports to the public preview
// URL — exposing the entire skills tree as an open server.
describe('M2: default host binding', () => {
it('binds to 127.0.0.1 by default', async () => {
const { host, port } = await startServer({ QE_BROWSER_FIXTURE_PORT: '18801' });
expect(host).toBe('127.0.0.1');
const res = await fetch('127.0.0.1', port, '/');
expect(res.status).toBe(200);
expect(res.body).toContain('qe-browser fixtures');
}, 10000);
it('respects QE_BROWSER_FIXTURE_HOST=0.0.0.0 explicit opt-in', async () => {
const { host } = await startServer({
QE_BROWSER_FIXTURE_PORT: '18802',
QE_BROWSER_FIXTURE_HOST: '0.0.0.0',
});
expect(host).toBe('0.0.0.0');
}, 10000);
});
// M3 regression: the previous startsWith() check could false-pass on
// sibling directories with shared prefix. path.relative() is the
// canonical traversal guard.
describe('M3: path-traversal guard', () => {
it('serves a real markdown file from inside the skills root', async () => {
const { port } = await startServer({ QE_BROWSER_FIXTURE_PORT: '18803' });
const res = await fetch('127.0.0.1', port, '/qe-browser/SKILL.md.html');
expect(res.status).toBe(200);
expect(res.body).toContain('qe-browser');
}, 10000);
it('blocks ../../etc/passwd traversal', async () => {
const { port } = await startServer({ QE_BROWSER_FIXTURE_PORT: '18804' });
const res = await fetch('127.0.0.1', port, '/../../etc/passwd.html');
// Either the URL parser normalises and serves a 404 or our guard
// rejects it explicitly. Both are acceptable; what's NOT acceptable
// is leaking /etc/passwd content.
expect(res.body).not.toContain('root:x:0:0');
}, 10000);
it('blocks sibling-prefix escape (foo vs foo2)', async () => {
// The previous startsWith() guard would false-pass `/skills/qe-browser2`
// when the root was `/skills/qe-browser`. We can't easily fabricate
// a sibling on disk in CI, but we can verify that .. notation gets
// rejected through path.relative(). Use an empirical .. path:
const { port } = await startServer({ QE_BROWSER_FIXTURE_PORT: '18805' });
const res = await fetch('127.0.0.1', port, '/qe-browser/../../../package.json.html');
// The guard should refuse to serve outside SKILLS_ROOT — package.json
// lives in the repo root, NOT inside the skills tree.
expect(res.status).toBe(404);
}, 10000);
});
});