mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-22 16:11:23 +08:00
fix(security): add host/origin allowlist + validate git refs + quote workflow input (#2185)
Three defense-in-depth fixes around untrusted input flowing to subprocess execution:
1. **Control-pane HTTP server (scripts/lib/control-pane/server.js)**
The local control-pane API binds to 127.0.0.1 but had no Host or Origin
validation, so a DNS-rebinding attack from a malicious website could pivot
into the loopback endpoints — including POST /api/actions/:id, which spawns
'cargo run -- graph ...' with caller-supplied query strings. Add a hostname
allowlist (loopback variants plus the explicitly configured --host) and
reject mismatched Host (421) or non-loopback Origin (403) before any route
handler runs.
2. **OpenCode git-summary tool (.opencode/tools/git-summary.ts)**
The tool was building 'git diff ${baseBranch}...HEAD --stat' with execSync
and a raw model-supplied baseBranch string. Switch run() to execFileSync
with an args array (no shell), validate baseBranch against a conservative
git-ref allowlist (rejects shell metacharacters, leading -, embedded ..),
and clamp the depth arg to a small positive integer before interpolating
into 'git log --oneline -<N>'.
3. **Reusable test workflow (.github/workflows/reusable-test.yml)**
The 'Install dependencies' step interpolated ${{ inputs.package-manager }}
directly into a bash 'case' and into an echo, so a downstream caller that
forwarded attacker-controllable input could inject into the runner. Move
the input into a PACKAGE_MANAGER env var and reference $PACKAGE_MANAGER
inside the script per the GitHub script-injection guidance.
Detected by Aeon + semgrep p/security-audit (host check via threat-model
manual-review axis; git-summary via detect-child-process; workflow via
run-shell-injection).
Verification: node tests/run-all.js — 2686/2687 pre-existing tests pass; the
one failure (observe.sh legacy output fallback) reproduces on main without
this branch applied. Added 2 new control-pane tests covering the allowlist
classifier and the DNS-rebinding-gate behavior end-to-end.
---
Filed by [Aeon](https://github.com/aaronjmars/aeon-aaron).
Co-authored-by: aeonframework <aeon@aaronjmars.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
@@ -14,6 +15,9 @@ const {
|
||||
createControlPaneServer,
|
||||
parseArgs,
|
||||
runAction,
|
||||
isAllowedHostHeader,
|
||||
isAllowedOrigin,
|
||||
buildAllowedHostnames,
|
||||
} = require('../../scripts/lib/control-pane/server');
|
||||
const {
|
||||
main: runControlPaneCli,
|
||||
@@ -326,6 +330,92 @@ async function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('classifies Host and Origin headers against the loopback allowlist', async () => {
|
||||
const allowed = buildAllowedHostnames('127.0.0.1');
|
||||
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('LOCALHOST:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('[::1]:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', allowed), false);
|
||||
assert.strictEqual(isAllowedHostHeader('rebind.dnsbin.io', allowed), false);
|
||||
assert.strictEqual(isAllowedHostHeader('', allowed), false);
|
||||
assert.strictEqual(isAllowedHostHeader(undefined, allowed), false);
|
||||
|
||||
// Origin is optional; absence is allowed for non-browser clients.
|
||||
assert.strictEqual(isAllowedOrigin(undefined, allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('', allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('http://127.0.0.1:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('http://localhost', allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('http://attacker.example.com', allowed), false);
|
||||
assert.strictEqual(isAllowedOrigin('not-a-url', allowed), false);
|
||||
|
||||
// A non-default configured host should still admit loopback variants.
|
||||
const lan = buildAllowedHostnames('192.168.1.10');
|
||||
assert.strictEqual(isAllowedHostHeader('192.168.1.10:8765', lan), true);
|
||||
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', lan), true);
|
||||
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', lan), false);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('rejects requests forged with a non-loopback Host header (DNS rebinding gate)', async () => {
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: true,
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const address = app.server.address();
|
||||
const actualPort = address && typeof address === 'object' ? address.port : 0;
|
||||
|
||||
const sendWithHeaders = (method, pathname, headers, body) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ host: '127.0.0.1', port: actualPort, method, path: pathname, headers },
|
||||
response => {
|
||||
let chunks = '';
|
||||
response.on('data', chunk => {
|
||||
chunks += chunk.toString('utf8');
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({ status: response.statusCode, body: chunks });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
const forgedHost = await sendWithHeaders('GET', '/api/health', { Host: 'attacker.example.com:1234' });
|
||||
assert.strictEqual(forgedHost.status, 421);
|
||||
assert.match(forgedHost.body, /Misdirected request/);
|
||||
|
||||
const forgedActionHost = await sendWithHeaders(
|
||||
'POST',
|
||||
'/api/actions/sync-knowledge',
|
||||
{ Host: 'attacker.example.com:1234', 'content-type': 'application/json' },
|
||||
JSON.stringify({ query: 'rebound' })
|
||||
);
|
||||
assert.strictEqual(forgedActionHost.status, 421);
|
||||
|
||||
const forgedOrigin = await sendWithHeaders('GET', '/api/health', {
|
||||
Host: '127.0.0.1:' + actualPort,
|
||||
Origin: 'http://attacker.example.com',
|
||||
});
|
||||
assert.strictEqual(forgedOrigin.status, 403);
|
||||
assert.match(forgedOrigin.body, /Forbidden origin/);
|
||||
|
||||
const okHost = await sendWithHeaders('GET', '/api/health', { Host: '127.0.0.1:' + actualPort });
|
||||
assert.strictEqual(okHost.status, 200);
|
||||
const okBody = JSON.parse(okHost.body);
|
||||
assert.strictEqual(okBody.ok, true);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('runAction captures success, failure, and bounded output', async () => {
|
||||
const repoRoot = REPO_ROOT;
|
||||
const success = await runAction({
|
||||
|
||||
Reference in New Issue
Block a user