mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
3eb9ccc461
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>
142 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|