Root cause
Injection & unsafe execution introduced
swarm wave 8: stage D phase 1 — Reflex skeleton (Gradio → Reflex migration)
How AI contributed: Direct introductionPublished Jun 26, 2026 in
mcp-tool-shop-org/backpropagate
A Claude-assisted Gradio-to-Reflex migration dropped the authentication the previous launch enforced while keeping the command's claim that it was enabled. The CLI printed Auth: enabled and exported BACKPROPAGATE_UI_AUTH, but backpropagate/ui_app never read the variable and registered no HTTP or WebSocket authentication, so any client reaching the bound port, including through --share, had full training control and could bypass safe_path() with user-supplied paths. The fix installs an ASGI transformer that authenticates HTTP requests and WebSocket upgrades before acceptance.
The cmd_ui path exports BACKPROPAGATE_UI_AUTH and reports authentication as enabled, but the Reflex app in backpropagate/ui_app never reads the variable and registers no authentication middleware, so the bound port accepts every client. The fix wraps the ASGI app in basic_auth_transformer, so HTTP requests and WebSocket upgrades are authenticated before they are accepted.
Root cause
swarm wave 8: stage D phase 1 — Reflex skeleton (Gradio → Reflex migration)
Fix
The fix installs an ASGI transformer on the Reflex app that authenticates HTTP requests and WebSocket upgrades before acceptance, with Host and Origin checks; the CLI now passes validated credentials and rejects unsafe public binds.
AI-assisted fix: Claude Code · mcp-tool-shop
Case facts
+ return EXIT_INTERRUPTED++ # --share has no first-class Reflex equivalent (no built-in tunneling).+ # Phase 1 of the migration keeps the flag in argparse but emits a warning+ # and continues without tunneling; operators should use Cloudflare Tunnel+ # / ngrok / SSH port-forwarding to expose the local Reflex port.+ if args.share:+ _print_warning(+ "--share is not yet supported by the Reflex UI. "+ "Use Cloudflare Tunnel / ngrok / SSH port-forwarding to expose "+ "the local port. Launching locally for now."+ )++ # Resolve the directory containing rxconfig.py. Reflex requires the+ # ``app_name`` package to be a direct subdirectory of the cwd, so we use+ # the package dir (``.../backpropagate/``) which ships+ # ``rxconfig.py`` + ``ui_app/`` side-by-side.+ package_dir = Path(__file__).resolve().parent+ rx_config = package_dir / "rxconfig.py"+ if not rx_config.exists():+ _print_error(+ f"rxconfig.py not found at {rx_config}. "+ "The Reflex UI requires the package-side rxconfig.py to be present."+ )+ _print_info(+ "Hint: reinstall the [ui] extra, or run "+ "`python -m backpropagate.ui_app.app` manually."+ )+ return EXIT_RUNTIME_ERROR++ # Set env vars that Reflex's state can pick up. ``BACKPROPAGATE_UI_AUTH``+ # is the agreed handoff for the Reflex side to enforce per-request auth+ # via FastAPI middleware once Phase 3 wires it. For Phase 1 the variable+ # is exported but Reflex doesn't read it yet.+ env = os.environ.copy()+ if auth:+ env["BACKPROPAGATE_UI_AUTH"] = f"{auth[0]}:{auth[1]}"+ env["BACKPROPAGATE_UI_PORT"] = str(args.port)++ # Reflex's port convention: the frontend serves on --frontend-port and+ # the backend on --backend-port. We map --port to the frontend (what+ # users hit in the browser) and the backend gets port+1.+ cmd = [+ sys.executable,+ "-m",+ "reflex",+ "run",+ "--frontend-port",+ str(args.port),+ "--backend-port",+ str(args.port + 1),+ ] try: print()- _print_info("Launching Gradio interface...")- launch(port=args.port, share=args.share, auth=auth)- return EXIT_OK+ _print_info("Launching Reflex interface...")+ result = subprocess.run(cmd, env=env, cwd=str(package_dir))+ return result.returncode if result.returncode is not None else EXIT_OK except KeyboardInterrupt: print() _print_info("UI stopped") return EXIT_OK+ except FileNotFoundError:+ _print_error(+ "Failed to launch Reflex — interpreter not found. "+ "This usually means the [ui] extra wasn't fully installed."+ )+ _print_info("Install with: pip install backpropagate[ui]")+ return EXIT_USER_ERROR except UserInputError as e: _print_error(f"{e.message}") if e.suggestion:++from __future__ import annotations++import reflex as rx++# Absolute imports so this module works whether Reflex loads it as+# ``ui_app.app`` (CLI subprocess from inside backpropagate/) or as+# ``backpropagate.ui_app.app`` (Python smoke tests).+from backpropagate.ui_theme import RADIX_THEME, STYLESHEETS, TOKENS_CSS++from .pages.dataset import dataset_page+from .pages.export import export_page+from .pages.multi_run import multi_run_page+from .pages.train import train_page+++def _with_tokens(page: rx.Component) -> rx.Component:+ """Wrap a page in a fragment that injects the THEME_TOKENS stylesheet.++ Reflex doesn't have a "root layout" concept; injecting the tokens via+ ``rx.el.style`` (which renders as a literal ``<style>`` tag) on every page+ is the cleanest way to ensure the CSS custom properties are present+ regardless of which route is loaded directly.+ """+ return rx.fragment(+ rx.el.style(TOKENS_CSS),+ page,+ )+++app = rx.App(+ theme=rx.theme(**RADIX_THEME),+ stylesheets=STYLESHEETS,+)+++# Register the 4 surfaces. ``add_page`` is preferred over ``@rx.page`` here so+# the page registry stays in one place and the imports remain explicit.+app.add_page(lambda: _with_tokens(train_page()), route="/", title="backpropagate · train")+app.add_page(lambda: _with_tokens(multi_run_page()), route="/multi-run", title="backpropagate · multi-run")+app.add_page(lambda: _with_tokens(export_page()), route="/export", title="backpropagate · export")+app.add_page(lambda: _with_tokens(dataset_page()), route="/dataset", title="backpropagate · dataset")@@ -62,47 +62,6 @@ EXIT_INTERRUPTED = 130 -# =============================================================================-# BRIDGE-LOCAL ERROR CODE DOCUMENTATION (BRIDGE-B-003 catalog drift mitigation)-# =============================================================================-# Codes emitted by export.py / cli.py that the canonical ERROR_CODES catalog in-# exceptions.py does NOT yet enumerate. The catalog lives in backend domain;-# rather than reach across the domain boundary, we document the bridge-side-# codes here so:-# (a) operators grepping cli.py for a code they saw in stderr have a-# central reference inside the bridge surface;-# (b) a future test ``test_error_code_catalog_completeness`` can scan BOTH-# exceptions.ERROR_CODES AND _BRIDGE_LOCAL_ERROR_CODES when checking-# that every emitted ``code=...`` literal is defined somewhere.-# These entries are CANDIDATES for promotion into exceptions.ERROR_CODES;-# the backend agent owns the actual catalog. The bridge audit (BRIDGE-B-003)-# flagged 8 missing codes total — the 4 below are bridge-domain; the other 4-# (SLAO_MERGE_DIVERGED, PEFT_API_INCOMPATIBLE, UI_OUTPUT_DIR_FORBIDDEN,-# INPUT_AUTH_INVALID_SHAPE) are flagged to backend / frontend agents.-_BRIDGE_LOCAL_ERROR_CODES: dict[str, dict[str, str]] = {- "HUB_PUSH_INVALID_REPO": {- "description": "Hugging Face repo_id failed pre-network validation (shape, control chars, '..' segment).",- "default_hint": "Pass --repo owner/name with only [A-Za-z0-9._-] in each segment.",- "retryable": "no",- },- "HUB_PUSH_NOT_FOUND": {- "description": "Hugging Face Hub returned 404 for the target repo.",- "default_hint": "Verify the repo_id spelling. Omit --create-repo=false to let backpropagate create it.",- "retryable": "no",- },- "HUB_PUSH_NETWORK": {- "description": "Hub upload failed due to network / 5xx / connection issue.",- "default_hint": "Retry — Hub 5xx clears in ~30s. Check connectivity if persistent.",- "retryable": "yes",- },- "HUB_PUSH_UNKNOWN": {- "description": "Hub upload failed for a reason backpropagate could not categorise.",- "default_hint": "Re-run with --verbose for the full traceback; check huggingface_hub version.",- "retryable": "sometimes",- },-}-- # ============================================================================= # ARGPARSE TYPE VALIDATORS # =============================================================================@@ -998,12 +957,146 @@ def _detect_installed_versions() -> dict[str, str]: return out +def _enumerate_env_vars() -> list[dict[str, str]]:+ """Walk the pydantic-settings models in :mod:`backpropagate.config` and+ return one dict per ``BACKPROPAGATE_*`` env var the runtime honours.++ Each dict has ``env_var`` / ``default`` / ``type`` / ``description``+ keys. The walk skips secret-flagged fields' default values (replaced+ with ``"<secret>"``) so a leaked ``BACKPROPAGATE_SECURITY__AUTH_PASSWORD``+ default never reaches stdout. When pydantic-settings is not installed+ we fall back to a small hand-curated list of the load-bearing env+ vars from the dataclass fallback in :mod:`backpropagate.config`.++ Returns:+ Sorted list of env-var descriptors, alphabetical by ``env_var``.+ Deterministic so snapshot-tests are stable across Python versions.+ """+ from .config import PYDANTIC_SETTINGS_AVAILABLE, Settings++ rows: list[dict[str, str]] = []++ if PYDANTIC_SETTINGS_AVAILABLE:+ # Walk every nested sub-config on Settings(). Each sub-config carries+ # an ``env_prefix`` in its model_config that defines the BACKPROPAGATE_*+ # namespace. We construct the env-var name by concatenating the prefix+ # with the field name UPPERCASED.+ for sub_field_name, sub_field_info in Settings.model_fields.items():+ sub_cls = sub_field_info.annotation+ # Only nested-config classes have model_fields; primitive fields+ # (e.g. Settings.version, Settings.name) are at the top level and+ # use BACKPROPAGATE_<NAME> directly.+ if not hasattr(sub_cls, "model_fields"):+ env_name = f"BACKPROPAGATE_{sub_field_name.upper()}"+ rows.append({+ "env_var": env_name,+ "default": _safe_default(sub_field_info),+ "type": _type_name(sub_field_info.annotation),+ "description": (sub_field_info.description or "").strip(),+ })+ continue@@ -1,20 +1,751 @@-"""Auth enforcement for the Reflex web UI.+"""Auth middleware for the Reflex web UI. -This module is the integration point for future HTTP/WS authentication-middleware. Until that middleware lands (tracked as a Wave 6 feature in the-dogfood swarm), `ENFORCEMENT_AVAILABLE` is False.+Implements the v1.2.0 DESIGN_BRIEF auth contract (Wave 6, Option B MVP): -When False, `cli.py:cmd_ui` refuses to start with `--auth` or `--share` and-returns `RUNTIME_UI_AUTH_NOT_ENFORCED` (see `backpropagate.exceptions`).-This is the patched behavior for the v1.1.0 / v1.1.1 false-promise issue-(GHSA-pending) where the CLI announced "Auth: enabled" but the runtime-ignored `BACKPROPAGATE_UI_AUTH` entirely.+- Three operator-facing modes:+ 1. ``no_auth_local_only`` — no flags; loopback bind; no enforcement+ (matches the v1.1.x "naked localhost" path — preserved for back-compat+ so smoke-importing the app at module load doesn't 401 on first hit).+ 2. ``token_auto`` — no ``--auth``; loopback bind; per-launch+ random token printed via the CLI startup banner (v1.3 polish; the+ middleware accepts ``?token=<hex>`` on the URL today and sets the+ session cookie on success).+ 3. ``explicit_creds`` — ``--auth user:pass``; loopback bind; HTTP+ Basic on first hit, HMAC-signed session cookie thereafter.+ 4. ``production`` — ``--share`` OR ``--host <non-loopback>``;+ requires ``--auth``; tunnel/LAN host added to the Host-header allowlist. -When the middleware lands, flip `ENFORCEMENT_AVAILABLE` to True and add-the FastAPI middleware registration to `ui_app/app.py`.+- Single ASGI middleware installed via ``rx.App(api_transformer=...)`` that+ gates BOTH HTTP routes AND the ``/_event`` WebSocket upgrade (the documented+ Reflex >=0.8 hook — see ``research/reflex-auth-middleware.md``).++- Cookie session: HMAC(``<user>:<exp>``) signed with SHA-256(``BACKPROPAGATE_UI_AUTH``)+ for explicit_creds mode, or the launch-token bytes for token_auto mode.+ ``HttpOnly`` + ``SameSite=Lax`` + ``Secure`` when non-loopback + 12h expiry.++- WS auth: cookie validated BEFORE ``websocket.accept()``; close code 4401 on+ failure (load-bearing — post-accept validation is a documented DoS vector+ per Peter Braden + Hexshift + dev.to consensus; see+ ``research/websocket-auth-failure-modes.md``).++- Defense-in-depth: Host-header allowlist (DNS-rebinding defense; CVE-2024-28224+ class), Origin allowlist on WS upgrade + state-changing HTTP methods (CSWSH+ defense; CWE-1385).++This MVP defers to v1.3:+- Footer auth-badge UI (FRONTEND-F-FOOTER-AUTH-BADGE)+- Jupyter-pattern startup banner (FRONTEND-F-STARTUP-BANNER-JUPYTER-PATTERN)+- Lock-file token at ``$XDG_RUNTIME_DIR/backpropagate/session-<port>.lock``+ (FRONTEND-F-LOCK-FILE-TOKEN)+- ``--auth-file <path>`` secure-variant flag (FRONTEND-F-AUTH-FILE-FLAG)+- Request-logging middleware (FRONTEND-F-MIDDLEWARE-REQUEST-LOGGING)+- Rate-limit middleware (FRONTEND-F-MIDDLEWARE-RATE-LIMIT) """ -ENFORCEMENT_AVAILABLE: bool = False+from __future__ import annotations++import base64+import enum+import hashlib+import hmac+import logging+import os+import secrets+import time+from collections.abc import Callable+from http import HTTPStatus+from urllib.parse import parse_qs, urlsplit++logger = logging.getLogger(__name__)++# ---------------------------------------------------------------------------+# Module-level flag — gates the Wave 3.5 belt-and-suspenders refuse-to-start+# checks in ``ui_app/app.py`` and ``rxconfig.py``. Flipped from False (v1.1.x)+# to True in Wave 6 of the v1.2.0 dogfood swarm. With the middleware below+# wired into ``rx.App(api_transformer=...)``, the CLI's --auth path now has a+# real enforcement layer — the belt-and-suspenders refuse-to-start becomes+# inert (the ``not ENFORCEMENT_AVAILABLE`` condition is False) but is left in+# place as a regression guard.+# ---------------------------------------------------------------------------+ENFORCEMENT_AVAILABLE: bool = True """True when the Reflex UI actually enforces ``BACKPROPAGATE_UI_AUTH``. -See module docstring for the rollout plan."""+Wave-6 flip checklist (so the next maintainer doesn't half-flip):++1. ``basic_auth_transformer`` is wired in ``ui_app/app.py`` via+ ``rx.App(api_transformer=basic_auth_transformer)``.@@ -19,10 +19,11 @@ # ``backpropagate.ui_app.app`` (Python smoke tests). from backpropagate.ui_theme import RADIX_THEME, STYLESHEETS, TOKENS_CSS -from .auth import ENFORCEMENT_AVAILABLE+from .auth import ENFORCEMENT_AVAILABLE, basic_auth_transformer from .pages.dataset import dataset_page from .pages.export import export_page from .pages.multi_run import multi_run_page+from .pages.runs import runs_page from .pages.train import train_page # FRONTEND-B-001 / GHSA-pending defense-in-depth:@@ -61,15 +62,29 @@ def _with_tokens(page: rx.Component) -> rx.Component: ) +# Wave 6: real auth middleware via Reflex's ``api_transformer`` hook (the+# documented Reflex >=0.8 surface — App.api was removed in 0.8.0). The+# transformer wraps the WHOLE ASGI app, so HTTP routes AND the /_event+# WebSocket upgrade go through the same gate. See ``ui_app/auth.py`` for+# the full mode matrix + cookie shape + pre-accept WS validation.+#+# Order matters: the auth middleware is the outermost wrapper, so it sees+# the raw scope before Reflex (or any other future api_transformer entry)+# touches it. v1.3 will chain a request-logging + rate-limit middleware in+# the same slot. app = rx.App( theme=rx.theme(**RADIX_THEME), stylesheets=STYLESHEETS,+ api_transformer=basic_auth_transformer, ) -# Register the 4 surfaces. ``add_page`` is preferred over ``@rx.page`` here so-# the page registry stays in one place and the imports remain explicit.+# Register the 5 surfaces. ``add_page`` is preferred over ``@rx.page`` here so+# the page registry stays in one place and the imports remain explicit. The+# ``/runs`` surface is the Wave 6 v1.2.0 add — it closes the CLI/UI parity+# gap (``backprop list-runs`` had no UI equivalent until now). app.add_page(lambda: _with_tokens(train_page()), route="/", title="backpropagate · train") app.add_page(lambda: _with_tokens(multi_run_page()), route="/multi-run", title="backpropagate · multi-run") app.add_page(lambda: _with_tokens(export_page()), route="/export", title="backpropagate · export") app.add_page(lambda: _with_tokens(dataset_page()), route="/dataset", title="backpropagate · dataset")+app.add_page(lambda: _with_tokens(runs_page()), route="/runs", title="backpropagate · runs")AI-assisted change 5931813949fb1cb5e0f2e0bd41f6ec96925619d0c04354a4bed599b97dd9ef0d · Fix 522d849737c671de67b29262c581bc57d30b5689b8d735d7319583b71c0c826c