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:
@aaronjmars
2026-06-15 13:49:40 -04:00
committed by GitHub
parent 41065bc0b2
commit 1c3280dc0d
4 changed files with 172 additions and 11 deletions
+50
View File
@@ -9,6 +9,43 @@ const { buildControlPaneAction } = require('./actions');
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
const { renderControlPaneHtml } = require('./ui');
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
// Extract the hostname portion of an HTTP Host header value, stripping any
// port. Returns null when the header is missing or malformed. Used to gate
// requests against a local-only allowlist so DNS-rebinding cannot pivot a
// browser tab into the loopback control-pane API.
function parseHostHeader(value) {
if (!value || typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
if (!match) return null;
return match[1].toLowerCase();
}
function buildAllowedHostnames(configuredHost) {
const set = new Set(LOOPBACK_HOSTNAMES);
if (configuredHost) set.add(String(configuredHost).toLowerCase());
return set;
}
function isAllowedHostHeader(hostHeader, allowedHostnames) {
const hostname = parseHostHeader(hostHeader);
if (!hostname) return false;
return allowedHostnames.has(hostname);
}
function isAllowedOrigin(originHeader, allowedHostnames) {
if (!originHeader || typeof originHeader !== 'string') return true;
try {
const url = new URL(originHeader);
return allowedHostnames.has(url.hostname.toLowerCase());
} catch {
return false;
}
}
function usage() {
return [
'Usage:',
@@ -159,9 +196,19 @@ function createControlPaneServer(options = {}) {
env: options.env || process.env,
});
const baseQuery = options.query || '';
const allowedHostnames = buildAllowedHostnames(host);
const server = http.createServer(async (req, res) => {
try {
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
sendJson(res, 421, { ok: false, error: 'Misdirected request' });
return;
}
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
sendJson(res, 403, { ok: false, error: 'Forbidden origin' });
return;
}
const requestUrl = new URL(req.url, `http://${host}:${port || 0}`);
if (req.method === 'GET' && requestUrl.pathname === '/') {
@@ -280,5 +327,8 @@ module.exports = {
createControlPaneServer,
parseArgs,
runAction,
isAllowedHostHeader,
isAllowedOrigin,
buildAllowedHostnames,
usage,
};