diff --git a/docs/superpowers/plans/2026-06-17-banner-stream-inject-csp-r3-fix.md b/docs/superpowers/plans/2026-06-17-banner-stream-inject-csp-r3-fix.md new file mode 100644 index 00000000..73d8dc64 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-banner-stream-inject-csp-r3-fix.md @@ -0,0 +1,219 @@ +# Toolbox R3 banner — CSP fallback + bundle cache-key fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore the R2/R3 transparency banner on strict-CSP sites by falling back from the `stream_inject` loader to the CSP-tolerant legacy buffer path; and fix the bundle cache so wg/non-wg report_urls don't bleed. + +**Architecture:** Two tiny, isolated edits in `secubox-toolbox`: (1) `inject_banner.InjectBanner.responseheaders` returns early on strict CSP (reusing the existing `_detect_csp_strict`), so the legacy `response` buffer path — which injects an inline-CSS banner with no external script/fetch — handles CSP-strict pages; (2) `bundle.get_bundle` keys its cache by `(client_id, is_wg)`. + +**Tech Stack:** mitmproxy addon, Python. pytest + mitmproxy `tflow`. Issue #636. + +**Spec:** `docs/superpowers/specs/2026-06-17-banner-stream-inject-csp-r3-fix-design.md`. + +**Conventions:** worktree `secubox-deb-worktrees/636-…` branch `fix/636-…`; commits end `(ref #636)`. Paths relative to `packages/secubox-toolbox/`. + +**Verified facts:** `_detect_csp_strict(flow)->bool` already exists in `inject_banner.py` (True when `script-src`/`default-src` present without `'unsafe-inline'`/nonce). `responseheaders` sets `resp.stream = _LoaderInjector(...)` + `flow.metadata["sbx_streamed"]=True` after the `banner`-filter check; the `response` buffer path early-returns if `sbx_streamed`. `bundle.get_bundle(client_id, is_wg=False)` caches via `_cache.get(client_id or "")` / `_cache[client_id or ""]=(now,bundle)`; `_report_url(client_id, is_wg)` is already correct. + +--- + +### Task 1: CSP fallback in `responseheaders` (the real fix) + +**Files:** +- Modify: `packages/secubox-toolbox/mitmproxy_addons/inject_banner.py` (`InjectBanner.responseheaders`) +- Test: `packages/secubox-toolbox/tests/test_banner_csp.py` + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-toolbox/tests/test_banner_csp.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +import sys, pathlib, importlib, json +ADDON_DIR = pathlib.Path(__file__).resolve().parents[1] / "mitmproxy_addons" +sys.path.insert(0, str(ADDON_DIR)) + +from mitmproxy.test import tflow, tutils # noqa: E402 +from secubox_toolbox import filters # noqa: E402 + + +def _addon(monkeypatch, tmp_path): + fp = tmp_path / "filters.json" + fp.write_text(json.dumps({"banner": True, "stream_inject": True})) + monkeypatch.setattr(filters, "FILTERS_PATH", str(fp)) + filters.get_filters(force=True) + import inject_banner + importlib.reload(inject_banner) + # force the client to look like an R3 WG peer so the level gate passes + monkeypatch.setattr(inject_banner, "_client_level", lambda flow: "r3") + return inject_banner + + +def _html_resp(ib, csp=None): + f = tflow.tflow(resp=tutils.tresp()) + f.response.headers["content-type"] = "text/html; charset=utf-8" + f.response.status_code = 200 + if csp: + f.response.headers["content-security-policy"] = csp + return f + + +def test_strict_csp_does_not_stream(monkeypatch, tmp_path): + ib = _addon(monkeypatch, tmp_path) + f = _html_resp(ib, csp="script-src 'self'; object-src 'none'") # no unsafe-inline/nonce + ib.InjectBanner().responseheaders(f) + assert not f.metadata.get("sbx_streamed") # fell back to buffer path + + +def test_no_csp_streams(monkeypatch, tmp_path): + ib = _addon(monkeypatch, tmp_path) + f = _html_resp(ib, csp=None) + ib.InjectBanner().responseheaders(f) + assert f.metadata.get("sbx_streamed") is True # streamed loader as before +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-toolbox && python -m pytest tests/test_banner_csp.py -q` +Expected: `test_strict_csp_does_not_stream` FAILS (currently streams regardless of CSP → `sbx_streamed` True). `test_no_csp_streams` passes. + +- [ ] **Step 3: Add the CSP guard** + +In `inject_banner.py`, in `InjectBanner.responseheaders`, immediately BEFORE the +`try:` block that does `resp.stream = _LoaderInjector(...)` / sets +`flow.metadata["sbx_streamed"]` (i.e. after the `banner`-filter check), insert: +```python + # #636 — strict CSP would block the injected loader