diff --git a/CHANGELOG.md b/CHANGELOG.md index 473714c020..825a8d2788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Unreleased +### Security: the Werkzeug debugger no longer follows you onto the network (2026-09-10) +- **Why:** `_run_server` called `app.run(..., debug=True, ...)` unconditionally, and two defaults turned that into real exposure rather than a theoretical one. `--debug` is `store_true, default=True`, so dev mode is what you get unless you ask for `--no-debug`; and the startup banner advertises the LAN and public URLs, so reaching the dashboard from another machine means adding `--host 0.0.0.0`. Do both — which the product invites — and any unhandled exception serves Werkzeug's interactive traceback page, with source, local variables and a PIN-gated eval console, to whoever reached the port. This is bandit B201, HIGH. +- **What:** the debugger is enabled only on a bind proven to be loopback. `helpers/server.is_loopback_host` fails closed — anything it cannot positively prove is loopback reads as remote — and accepts `localhost`, IPv4/IPv6 loopback literals, a bracketed `[::1]` and a zone-suffixed form. `0.0.0.0` and `::` are wildcard binds covering every routable interface and are correctly NOT loopback. Hostnames are never resolved to decide this: a name that resolves to loopback today can resolve elsewhere tomorrow, and DNS is not a thing to trust when the answer decides whether to expose an eval console. +- **The reloader is not sacrificed.** `use_reloader=True` stays unconditional and is separately pinned by a test. Auto-reload is what dev mode is *for*, and a fix that quietly removed it would cost the feature it was protecting. +- **The guard did not cover the thing it was guarding.** The original 22 tests all exercised the helper in isolation and none exercised its use, so restoring `app.run(debug=True)` left the whole suite green. `dashboard.py` is now parsed and the `app.run()` call inside `_run_server` asserted structurally: `debug` is not a hardcoded `True`, it is a name bound from a loopback check in that same function, and `use_reloader` is unconditionally `True`. Both regressions proven red first — `debug=True` (the original bug) and `debug=False`, which passes a naive "not True" check while removing dev mode's debugger for everyone. +- **The status line is not the control.** Restoring `debug=True` left the note still printing "debugger off" while the debugger was on: the message and the flag are computed independently. That is now a recorded contract, so no future guard accepts the banner as evidence of the behaviour. +- **Verified live**, one fresh process per host, intercepting what `_run_server` actually hands `app.run`: `127.0.0.1`, `localhost` and `::1` keep `debug=True`; `0.0.0.0`, `192.168.1.50` and `::` get `debug=False`, all six with `use_reloader=True`. The same probe on the pre-fix tree returns `debug=True` for `0.0.0.0` and `192.168.1.50`. +- **Recorded in the product record.** The Local Observability Service blueprint now carries the constraint as five contracts plus an ADR on asserting the call site rather than only the helper; `helpers/server.py` points at it. Also dropped an `import ipaddress` in `dashboard.py` that the helper made redundant. +- **Verified:** 25 tests in `tests/test_debugger_loopback_only.py`, named in `ci.yml`. Carries #5382. + ### Fixed: every OpenAI session reported the wrong cache-hit rate (2026-09-10) - **Why:** the two providers count cached tokens differently and one denominator was applied to both. Anthropic reports cached tokens ON TOP OF uncached input, so the rate is `cr / (in_t + cr)`. OpenAI reports them ALREADY INSIDE `input_tokens`, so the rate is `cr / in_t`. Using the additive form for OpenAI understates every figure, and not by a rounding margin: on a session with 100,000 input and 80,000 cached, the true answer is **80.0%** and the old formula returned **44.4%**. That reads as "caching is barely working" for a session where it was working nearly perfectly, which is the kind of number a reader acts on. - **What:** `_session_cost_intel` resolves the provider from the model through `providers_pricing.provider_for_model`, the one place model-to-provider is decided, so this cannot drift from pricing. OpenAI takes the inclusive denominator, every other provider the additive one, and a provider that cannot be resolved degrades to the additive form, which is the majority case and the historical behaviour.