Repository navigation
[P2][security][needs-validation] Bind gate validates --config but the served API loads CONFIG_PATH or ./config.yaml (fail-open default config) #2091
Description
Source: security-audit skill, run data-boar-run-1 (profile quick, source-only: no target code was executed), snapshot origin/main @ 7f354a34, 2026-10-18. Each trace below was found by a hunter agent and then re-read in the snapshot by an independent verifier. Status: needs validation — the source trace holds, the runtime result is not observed. Please start with the bounded local test in the plan; if it reproduces, turn it into a regression test and fix, otherwise close with the evidence.
Non-loopback bind gate validates the --config file, but the served API loads CONFIG_PATH or ./config.yaml and may run with no API key
Source re-read confirms the split. main.py --web loads args.config (default config.yaml) at line 1499 and enforces the non-loopback startup rule (require_api_key plus a resolved key) at line 1943. It then imports api.routes (line 1913) and calls uvicorn.run(app) (line 2076) without exporting CONFIG_PATH or injecting the config. No non-test code assigns api.routes._config_path or sets CONFIG_PATH, except the --demo path (core/demo/runtime.py:118), which sets CONFIG_PATH explicitly. api/routes.py reads _config_path from CONFIG_PATH, falling back to ./config.yaml (lines 583-585). _get_config() loads that file, or builds a default dict with api={'port': 8088} and no require_api_key (lines 688-700). optional_api_key_middleware returns call_next when require_api_key is falsy (line 1212). So an invocation like main.py --config /etc/x/prod.yaml --web --host 0.0.0.0 (or api.host or API_HOST set to a non-loopback address) with CONFIG_PATH unset passes the gate on prod.yaml. It then serves ./config.yaml, or the built-in default if that file is absent. The API-key gate is then off on the exposed listener, including POST /{locale}/config (writes the served YAML) and the scan and report routes. RBAC is inactive in the default licence mode.
Preventing layers found. (1) TrustedHostMiddleware is built from the served config (routes.py:1333, core/host_resolution.py:60-78). With the fallback config only 127.0.0.1, localhost and testserver are accepted, so a request carrying the real server name or IP in Host gets 400. This is DNS-rebinding protection, not authentication. A direct network client can send Host: localhost, so it narrows ordinary browser or proxy access but does not stop a deliberate client. (2) If neither CONFIG_PATH nor ./config.yaml exists, the fallback config has no targets. Impact is then limited to whatever audit_results.db exists in the working directory, plus unauthenticated config writes. (3) If ./config.yaml exists and itself sets require_api_key with a different key, the result is a key mismatch, not an open API.
Shipped forms. The Dockerfile is consistent: CONFIG_PATH=/data/config.yaml (line 136) and CMD --config /data/config.yaml (line 157). The systemd unit sets only CONFIG_PATH (line 13) and runs -m data_boar --web (line 14) with no --config. The runit script does the same (run:7). In those units main.py defaults to ./config.yaml, which is normally absent, so main.py exits 1 (main.py:1500-1510) and the form fails closed. It becomes the divergence only if a stray cwd config.yaml exists. Exploitation therefore needs an operator invocation with --config pointing elsewhere, CONFIG_PATH unset and a non-loopback bind. docs/USAGE.md:170 documents that the API process loads its own config from CONFIG_PATH, but main.py neither warns nor reconciles when --config and CONFIG_PATH differ, and the gate gives false assurance. Not a duplicate of #2077, #2078, #2079, #2086, #2087, #2088 or #2089, whose root causes differ. It overlaps in subject only with the bind-gate design (ADR-0082). Fail-open on a misconfiguration the docs already mention keeps this at needs_validation rather than a clear defect.
Root cause. Two independent configuration sources: main.py validates load_config(args.config) while api.routes._config_path is read from CONFIG_PATH at import, and neither passes its choice to the other. The security gate therefore authorises a configuration that the enforcing middleware may not load, and the fallback default config (no require_api_key) is fail-open.
Trace.
main.py:1499(entrypoint) — Operator CLI: config = load_config(args.config), with --config defaulting to config.yaml (main.py:597-599). This is the only config the gate sees.main.py:1943(propagation) — Gate evaluated on the CLI-loaded config. It passes when that config has require_api_key and a resolved key. api.routes is imported at line 1913 and uvicorn.run(app) is called at line 2076, with no CONFIG_PATH export and no config injection.api/routes.py:583(propagation) — _config_path comes from env CONFIG_PATH, falling back to ./config.yaml (lines 583-585), independent of args.config.api/routes.py:688(propagation) — Loads _config_path if it exists. Otherwise it returns a built-in default dict (api={'port': 8088}, line 700) with no api.require_api_key. TrustedHostMiddleware is also built from this config (line 1333).api/routes.py:1212(sink) — When the loaded config lacks require_api_key the middleware returns call_next, leaving the JSON routes and POST /{locale}/config without the API-key check on the non-loopback listener. A client must send an accepted Host such as localhost to pass TrustedHost.
What blocks confirmation.
- Source-only run: no execution is approved, so the fail-open behaviour cannot be observed locally.
- The decisive fact is the operator's actual start command, CONFIG_PATH, working-directory contents and bind address. These are deployment-specific and not visible in the repository.
Bounded local test (dummy data, sandbox, no network). In a sandbox with no external network and a scratch working directory, create dummy prod.yaml (api.require_api_key true, api_key_from_env naming a dummy variable, api.host 127.0.0.1) and an empty cwd with no config.yaml. Run python main.py --config prod.yaml --web --port 8099 --allow-insecure-http with CONFIG_PATH unset. Over loopback only, request GET /status with Host: localhost and no key, and confirm 200 where prod.yaml would return 401. Repeat with CONFIG_PATH=prod.yaml and confirm 401. Optionally repeat with a Host header other than localhost and confirm 400 from TrustedHost. Use dummy data only and do not exercise POST /config.
Owner-observed check. Owner reviews the exact start command, environment and working directory of each non-container deployment (systemd, runit, nfpm, Homebrew, Ansible, compose overrides). Confirm that --config and CONFIG_PATH name the same file, and that no stray config.yaml sits in the working directory. Check from the host that the running process's /proc//environ and cwd match the intended config. Send no traffic to a live deployment.
Suggested direction (recommendation). Make main.py the single source of the served config: export CONFIG_PATH (or inject the loaded config) before importing api.routes, warn or refuse when --config and CONFIG_PATH differ, and make the built-in fallback config fail closed for non-loopback binds. Also fix the systemd/runit units (they set CONFIG_PATH but pass no --config). Related: #1714 (bind gate), ADR-0082.
Activity
FabioLeitao commentedon Oct 20, 2026
Resolvida pela PR #2114 (merge 87f1fe0). Commit 4f040a9. Testes em tests/test_served_config_path.py: test_non_loopback_mismatch_fails_closed e test_served_path_is_the_cli_file_on_loopback.