headroomlabs-ai/headroom · warning

comparator ccr not implemented (Phase 0)

Error message

comparator ccr not implemented (Phase 0)

What it means

HTTP 403 from require_same_origin (loopback_guard.py:248): the Origin header names a host that is not loopback. This is the CSRF gate for mutating routes — a remote web page's JavaScript POSTing directly to http://127.0.0.1:port is rejected even though the Host header is loopback, because the Origin betrays the cross-site caller.

Source

Thrown at crates/headroom-parity/src/lib.rs:173

// * `cache_aligner` — needs the volatile-content detector, which lives in
//   `headroom-proxy` while this crate depends only on `headroom-core`. Either
//   add the dependency or move the detector down into core.
// * `ccr` — the fixtures compare `ccr_retrieve` tool-definition injection
//   (`headroom/ccr/tool_injection.py`), which has no Rust port at all.

macro_rules! stub_comparator {
    ($ty:ident, $name:literal) => {
        pub struct $ty;
        impl TransformComparator for $ty {
            fn name(&self) -> &str {
                $name
            }
            fn run(
                &self,
                _input: &serde_json::Value,
                _config: &serde_json::Value,
            ) -> Result<serde_json::Value> {
                anyhow::bail!(concat!("comparator ", $name, " not implemented (Phase 0)"))
            }
        }
    };
}

stub_comparator!(CacheAlignerComparator, "cache_aligner");
stub_comparator!(CcrComparator, "ccr");

/// Real comparator for the `log_compressor` transform.
///
/// Two wrinkles beyond the usual adapter shape:
///
/// * **bias.** Python's signature is `compress(content, context="", bias=1.0)`
///   and the recorder captured only `content`, so every fixture was produced at
///   the default `bias = 1.0`. Rust takes `bias` positionally — pass 1.0.
/// * **CCR store.** The Python compressor owns its store internally, while Rust
///   mints a `cache_key` only when one is handed to `compress_with_store`.
///   Without a store the CCR branch bails out with `"no store provided"` and

View on GitHub (pinned to 322425c43b)

Solutions

  1. Serve your dashboard/control page from the same loopback origin as the proxy
  2. Add the dashboard's domain to the trusted-dashboard-client allowlist the server composes around these dependencies (server.py wraps them with an escape hatch)
  3. Use a non-browser client for automation — it sends no Origin and is not affected

Example fix

// before: page served from https://dashboard.example
fetch('http://127.0.0.1:8080/settings', {method: 'POST', ...})

// after: serve the control page from the proxy origin and use a relative URL
fetch('/settings', {method: 'POST', ...})
Defensive patterns

Strategy: validation

Validate before calling

// Only call mutating endpoints from the proxy's own origin
if (location.hostname !== "127.0.0.1" && location.hostname !== "localhost") {
  throw new Error("cross-origin calls are rejected by the same-origin guard");
}

Try / catch

if (resp.status === 403) { /* re-serve the control page from the proxy origin */ }

Prevention

When it happens

Trigger: JavaScript on https://evil.example running fetch('http://127.0.0.1:8080/settings', {method:'POST', body:...}) with a simple Content-Type; any browser page on a non-loopback origin targeting the proxy's mutating endpoints.

Common situations: Drive-by localhost CSRF attempts (the reason the guard exists); a legitimately hosted dashboard on another domain trying to drive the local proxy; browser extensions injecting requests from their own origin.

Related errors


AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15). Data as JSON: /api/errors/b3ac0b05c06c0a49. Report an issue: GitHub.