diff --git a/.env.example b/.env.example index 1d82f180..20616a7e 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,15 @@ RELAY_PORT=8765 # Exact browser origin allowed for cookie-authenticated WebSockets. Plain HTTP # is accepted only on loopback; production must use https://. PUBLIC_ORIGIN=http://127.0.0.1:8765 +# Extra peers allowed to set X-Forwarded-Proto / X-Forwarded-For, on top of the +# built-in 127.0.0.1 and ::1. Needed only when your reverse proxy reaches the +# relay from a non-loopback address (Tailscale, LAN, Docker bridge gateway); +# otherwise uvicorn ignores the proxy headers, the relay computes an http +# request target while the browser Origin is https, and every WebSocket is +# rejected with 403 even though the page and /api/* work. List ONLY the +# addresses that proxy connects from: a trusted peer may claim any client IP +# and any scheme, so keep direct access to the relay restricted. Never "*". +# FORWARDED_ALLOW_IPS=100.64.0.9,fd7a:115c:a1e0::1 # Optional direct browser access through literal private/loopback IPs on # RELAY_PORT. Covers 127/8, 10/8, 172.16/12, 192.168/16, Tailscale's # 100.64/10, IPv6 loopback and ULA. Hostnames, public IPs and other ports stay diff --git a/AGENTS.md b/AGENTS.md index fb7e88ef..cc5a6b9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,11 @@ attachment and optional App-control MCP tools are separate user choices. private/loopback IPs on `RELAY_PORT`, and their scheme/host/port must match the effective request target. Cookie `Secure` follows that trusted request transport, never the caller's Origin. Uvicorn trusts forwarded transport - metadata only from loopback Caddy. Never put tokens in URLs or protocol + metadata only from loopback Caddy, plus any address listed in + `FORWARDED_ALLOW_IPS` (comma-separated IPs/CIDRs appended to the loopback + defaults) — only for a proxy that cannot reach the relay over loopback; a + trusted peer can claim any client IP and scheme, so never use `*`/`/0` and + keep direct relay access restricted. Never put tokens in URLs or protocol message bodies; logging redacts token/password fields. - **Protocol version gate**: current wire protocol v66 is declared by `PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f74bd27..efe44dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## Unreleased +- Allow a reverse proxy that reaches the relay from a non-loopback address (a + Tailscale IP, a LAN address, the Docker bridge gateway) to be trusted through + `FORWARDED_ALLOW_IPS`, which appends IPs or CIDR networks to the built-in + loopback defaults. Previously only a loopback proxy was trusted, so uvicorn + ignored `X-Forwarded-Proto` and every WebSocket was rejected with 403 while + page loads and `/api/*` kept working. Wildcard and malformed entries are + rejected at startup instead of silently never matching. - Backport shared improvements from the DSH branch without adding a third engine (protocol v66): rounded Claude/Codex Goal dialogs with native save confirmation and mobile keyboard recovery; directory links open `/open`, and diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 686e2cfc..221089a7 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -4,6 +4,11 @@ ## 未发布 +- 反向代理若从非 loopback 地址访问 relay(Tailscale、内网地址、Docker 网桥 + 网关),可通过 `FORWARDED_ALLOW_IPS` 追加信任的 IP 或 CIDR 网段(内置的 + loopback 默认值始终保留)。此前只信任 loopback 代理,uvicorn 会忽略 + `X-Forwarded-Proto`,导致页面和 `/api/*` 正常、WebSocket 全部返回 403。 + 通配符和非法条目在启动时直接拒绝,而不是静默不生效。 - 整合 DSH 分支中的通用改进,保留 Claude/Codex 双引擎(protocol v66):圆角 Goal 小窗等待原生保存确认,恢复手机键盘收起后的布局;目录链接接入 `/open`, XLSX 可预览已保存的单元格、切换工作表并下载原文件。 diff --git a/CLAUDE.md b/CLAUDE.md index a6f3c61a..a9ce54c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,11 @@ a separate choice; sharing alone does not authorize them. private/loopback IPs on `RELAY_PORT`, and their scheme/host/port must match the effective request target. Cookie `Secure` follows that trusted request transport, never the caller's Origin. Uvicorn trusts forwarded transport - metadata only from loopback Caddy. Never put tokens in URLs or protocol + metadata only from loopback Caddy, plus any address listed in + `FORWARDED_ALLOW_IPS` (comma-separated IPs/CIDRs appended to the loopback + defaults) — only for a proxy that cannot reach the relay over loopback; a + trusted peer can claim any client IP and scheme, so never use `*`/`/0` and + keep direct relay access restricted. Never put tokens in URLs or protocol message bodies; logging redacts token/password fields. - **History scroll anchoring lives in `@tanstack/virtual-core`, not in `react-virtual`**: `web/package.json` pins `@tanstack/react-virtual`, but diff --git a/cc_remote/config.py b/cc_remote/config.py index c2586b23..2ef8bf16 100644 --- a/cc_remote/config.py +++ b/cc_remote/config.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import ipaddress import math import json import os @@ -56,6 +57,29 @@ def _bool(key: str, default: bool = False) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +# Loopback peers are always trusted to supply X-Forwarded-*: the bundled relay +# binds loopback behind a same-host Caddy/nginx. Extra entries are opt-in. +LOOPBACK_PROXY_IPS = ("127.0.0.1", "::1") + + +def _forwarded_allow_ips() -> str: + """Trusted proxy allowlist for uvicorn's proxy-headers middleware. + + Keeps the loopback defaults and appends FORWARDED_ALLOW_IPS entries. An + entry may be a single IP or a CIDR network. Order is preserved and + duplicates (including the loopback defaults) are dropped so an operator + cannot accidentally widen or reorder what is already trusted. + """ + entries: list[str] = list(LOOPBACK_PROXY_IPS) + seen = set(entries) + for raw in _env("FORWARDED_ALLOW_IPS", "").split(","): + entry = raw.strip() + if entry and entry not in seen: + seen.add(entry) + entries.append(entry) + return ",".join(entries) + + def device_config_path() -> Path: return Path(_env( "CC_REMOTE_DEVICE_CONFIG", @@ -196,6 +220,15 @@ class RelayConfig: # their mandatory first Hello frame. max_clients: int = field(default_factory=lambda: _int("MAX_CLIENTS", 8)) client_hello_timeout: float = field(default_factory=lambda: _float("CLIENT_HELLO_TIMEOUT", 10.0)) + # Extra peers allowed to supply X-Forwarded-Proto / X-Forwarded-For, appended + # to the built-in loopback defaults (comma-separated IPs or CIDR networks). + # Only for a same-host proxy the relay is not reachable without, and only + # because uvicorn otherwise ignores the headers when that proxy connects + # over a non-loopback address (Tailscale, LAN, Docker bridge gateway). A + # trusted peer can name itself as any client and claim any scheme, so direct + # relay access must stay restricted and this list must never widen to a + # network the relay is publicly reachable from. Never "*". + forwarded_allow_ips: str = field(default_factory=_forwarded_allow_ips) # Exact browser Origin accepted for cookie-authenticated WebSockets, for # example https://remote.example.com (no path or trailing slash). public_origin: str = field(default_factory=lambda: _env("PUBLIC_ORIGIN", "")) @@ -462,6 +495,43 @@ def validate_relay_config(cfg: RelayConfig) -> None: if cfg.client_queue_bytes < cfg.ws_max_size_bytes: errors.append("CLIENT_QUEUE_BYTES must be at least WS_MAX_SIZE_BYTES") + # Validate every operator-supplied entry against uvicorn's own parse rules. + # uvicorn turns anything that is not a valid IP or CIDR into a literal that + # is compared against a peer address, so a typo or a hostname would start + # cleanly and then silently never match -- the exact undiagnosable failure + # this setting exists to fix. Reject instead of silently trusting less. + entries = [ + entry.strip() for entry in cfg.forwarded_allow_ips.split(",") if entry.strip() + ] + if len(entries) > 64: + errors.append( + "FORWARDED_ALLOW_IPS must not exceed 64 trusted proxies in total " + "(the loopback defaults count toward this)" + ) + for entry in entries: + if entry == "*": + errors.append( + "FORWARDED_ALLOW_IPS must not be '*': it would trust every peer " + "to supply X-Forwarded-Proto and X-Forwarded-For" + ) + continue + try: + network = ( + ipaddress.ip_network(entry) if "/" in entry + else ipaddress.ip_address(entry) + ) + except ValueError: + errors.append( + f"FORWARDED_ALLOW_IPS entry {entry!r} is not a valid IP address " + "or CIDR network" + ) + continue + if getattr(network, "prefixlen", None) == 0: + errors.append( + f"FORWARDED_ALLOW_IPS entry {entry!r} trusts every peer; " + "list only the addresses your reverse proxy connects from" + ) + push_values = ( cfg.push_vapid_public_key, cfg.push_vapid_private_key, diff --git a/cc_remote/relay/__main__.py b/cc_remote/relay/__main__.py index 31fe5898..25d839d7 100644 --- a/cc_remote/relay/__main__.py +++ b/cc_remote/relay/__main__.py @@ -26,7 +26,7 @@ def main() -> None: log_config=uvicorn_log_config(), access_log=False, proxy_headers=True, - forwarded_allow_ips="127.0.0.1,::1", + forwarded_allow_ips=cfg.forwarded_allow_ips, ws_max_size=cfg.ws_max_size_bytes, ws_max_queue=2, ) diff --git a/deploy/README.md b/deploy/README.md index 4897a032..44600eac 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -235,6 +235,15 @@ state. Public TLS + WebSocket termination stays with your existing front. and proxies the `/ws` WebSocket to `127.0.0.1:8765`. Keep it loopback-only: the relay trusts forwarded transport metadata only from loopback peers. +**Proxy on a non-loopback address.** If your front end cannot reach the relay +over loopback — a Tailscale/LAN address, a Docker bridge gateway — add those +addresses to `FORWARDED_ALLOW_IPS` (comma-separated IPs or CIDR networks). +Without it uvicorn ignores `X-Forwarded-Proto`, the relay computes an `http` +request target while the browser's Origin says `https`, and every WebSocket is +rejected with 403 while page loads and `/api/*` keep working. Only your own +reverse proxy's addresses belong there: a trusted peer may claim any client IP +and any scheme, so the relay must stay unreachable from anywhere else. + **Mainland-China mirrors.** The Docker build defaults to PyPI.org. Behind the GFW, build with Aliyun as the primary index and TUNA as the fallback (both carry the sdist-only `http-ece` wheel): diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index de9ce02d..2950af0a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -28,6 +28,11 @@ services: # the HTTPS PUBLIC_ORIGIN. With host networking the relay binds loopback # only (RELAY_HOST=127.0.0.1), which the host nginx/Caddy reaches directly. # Linux hosts only; the `ports:` mapping is ignored under host networking. + # If you switch to bridge networking instead, remove `network_mode`, publish + # only to host loopback (`ports: ["127.0.0.1:8765:8765"]`), set + # RELAY_HOST=0.0.0.0 inside the container, and add the bridge gateway to + # FORWARDED_ALLOW_IPS. The loopback host bind prevents direct clients from + # bypassing the reverse proxy. See env.relay.docker.example. network_mode: host env_file: - env.relay diff --git a/deploy/env.relay.docker.example b/deploy/env.relay.docker.example index 49e231a1..80817f70 100644 --- a/deploy/env.relay.docker.example +++ b/deploy/env.relay.docker.example @@ -22,6 +22,18 @@ PUBLIC_ORIGIN=https://cc-remote.example.com # Leave at 0 for the normal domain + TLS path (see the nginx example / Caddy). ALLOW_INSECURE_HTTP=0 +# Only needed if you drop the compose file's `network_mode: host` and publish +# the container port instead. In bridge mode the relay must listen on the +# container interface, while the published host port must remain loopback-only: +# RELAY_HOST=0.0.0.0 +# ports: ["127.0.0.1:8765:8765"] +# The host reverse proxy then reaches Docker through 127.0.0.1, but the relay +# sees the Docker bridge gateway as its direct peer. Trust only that gateway (or +# the exact address your proxy connects from); a trusted peer can claim any +# client IP and any scheme. Never publish the host port on all interfaces and +# never use "*". +# RELAY_HOST=0.0.0.0 +# FORWARDED_ALLOW_IPS=172.17.0.1 # Web login password (what you type in the browser to log in). REQUIRED. LOGIN_PASSWORD=REPLACE_WITH_A_STRONG_PASSWORD # HMAC secret for signing web session tokens. REQUIRED. diff --git a/deploy/env.relay.example b/deploy/env.relay.example index 474da70b..ce94c0a4 100644 --- a/deploy/env.relay.example +++ b/deploy/env.relay.example @@ -10,6 +10,14 @@ RELAY_PORT=8765 ALLOW_PRIVATE_ORIGINS=0 # Exact browser origin allowed to open a cookie-authenticated WebSocket. PUBLIC_ORIGIN=https://cc-remote.example.com +# Extra peers allowed to set X-Forwarded-Proto / X-Forwarded-For, on top of the +# built-in loopback defaults. Only needed when Caddy/nginx does not reach the +# relay over loopback (Tailscale, LAN, or the Docker bridge gateway); without +# it uvicorn ignores the proxy headers and every WebSocket is rejected with 403 +# while /api/* keeps working. List ONLY your own proxy's addresses -- a trusted +# peer can claim any client IP and any scheme, so the relay must not be +# reachable from anywhere else. Never "*". +# FORWARDED_ALLOW_IPS=100.64.0.9,fd7a:115c:a1e0::1 # Default: bridge (same-origin transport, opaque sandbox; no extra DNS/TLS). # No directory is published automatically. off disables viewing. # An existing explicit origin template selects isolated when mode is unset. diff --git a/deploy/nginx-reverse-proxy.conf.example b/deploy/nginx-reverse-proxy.conf.example index 9b0c128f..c9c946a9 100644 --- a/deploy/nginx-reverse-proxy.conf.example +++ b/deploy/nginx-reverse-proxy.conf.example @@ -6,6 +6,10 @@ # deliberately trusts forwarded transport metadata only from loopback peers, so # nginx MUST run on the same host and proxy to the relay's loopback address # (with the Docker compose file, that is 127.0.0.1:8765 on host networking). +# If nginx instead reaches the relay from a non-loopback address (Tailscale, +# LAN, a Docker bridge gateway), add ONLY that address to the relay's +# FORWARDED_ALLOW_IPS; otherwise uvicorn ignores the proxy headers above and +# every WebSocket is rejected with 403. # # Bootstrap on a fresh host: # 1. Point an A/AAAA record for cc-remote.example.com at this VPS and open diff --git a/docs/configuration.md b/docs/configuration.md index d2fd039c..1074b11d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -133,6 +133,7 @@ hook 和 Wrapper 应使用相同的 `CC_REMOTE_STATE_DIR`;日志默认为 | `PUBLIC_ORIGIN` | 空 | 浏览器允许连接 WS 的精确来源,如 `https://remote.example.com`;**必须设**,非 loopback 必须 HTTPS(除非开了 `ALLOW_INSECURE_HTTP`)。 | | `ALLOW_PRIVATE_ORIGINS` | `0` | 设为 `1` 后,在保留 `PUBLIC_ORIGIN` 的同时,允许浏览器通过 `RELAY_PORT` 上的私网/loopback 字面 IP 直连:`127/8`、`10/8`、`172.16/12`、`192.168/16`、Tailscale `100.64/10`、IPv6 loopback/ULA。Origin 的协议/主机/端口还必须与实际请求目标完全一致;主机名、公网 IP 和其他端口仍拒绝。内网 HTTP 不加密,且通常不能安装 PWA。 | | `ALLOW_INSECURE_HTTP` | `0` | 逃生开关:设为 `1` 允许 `PUBLIC_ORIGIN` / `RELAY_URL` 在非 loopback 时仍用明文 `http://`/`ws://`(例如直接暴露一个没有 TLS 终端的公网 IP)。默认关闭;开启后登录口令、会话 cookie 和全部流量都走明文,链路上任何人都能窃取或劫持会话,务必优先使用 TLS。 | +| `FORWARDED_ALLOW_IPS` | 空 | 额外信任哪些对端可以携带 `X-Forwarded-Proto` / `X-Forwarded-For`,逗号分隔的 IP 或 CIDR,会追加在内置的 `127.0.0.1,::1` 之后。仅用于反向代理与 relay 不在同一个 loopback 的情形(Tailscale、LAN、Docker 网桥网关):此时 uvicorn 默认忽略代理头,relay 会把请求算成 `http:80`,而浏览器的 Origin 是 `https:443`,于是页面和 `/api/*` 正常、只有 WebSocket 全部 403。填写的地址必须**只属于你自己的反向代理**:被信任的对端可以自称任意客户端 IP、声明任意协议,所以 relay 自身不能被这些地址之外的人直接访问,也不要填 `*`、`0.0.0.0/0` 或 `::/0`(会在启动时拒绝)。非法 IP/CIDR 同样在启动时拒绝,而不是静默不匹配。 | | `WRAPPER_TOKEN` | 占位值 | 单机器/兼容模式下的 wrapper Bearer token;未设置 `WRAPPER_TOKENS_JSON` 时必须配置。 | | `WRAPPER_TOKENS_JSON` | 空 | 可选机器绑定 token:`{"laptop":"…","server":"…"}`;设置后替代 relay 的通配 `WRAPPER_TOKEN`。 | | `WEB_STATIC_DIR` | 空 | 指向 `web/dist` 则同源托管网页;留空则只做 API/WS。 | diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 38efb796..81431059 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -151,6 +151,7 @@ Common settings below; [config.py](../cc_remote/config.py) and the deployment en | `PUBLIC_ORIGIN` | empty | Exact browser origin allowed to connect, e.g. `https://remote.example.com`; **required**, and non-loopback origins must use HTTPS unless `ALLOW_INSECURE_HTTP` is enabled. | | `ALLOW_PRIVATE_ORIGINS` | `0` | Set to `1` to retain `PUBLIC_ORIGIN` while also accepting literal private/loopback IP origins on `RELAY_PORT`: `127/8`, `10/8`, `172.16/12`, `192.168/16`, Tailscale `100.64/10`, IPv6 loopback, and ULA. The Origin scheme/host/port must also exactly match the effective request target; hostnames, public IPs, and other ports remain rejected. Private HTTP is unencrypted and normally cannot install a PWA. | | `ALLOW_INSECURE_HTTP` | `0` | Escape hatch for a bare public IPv4 address: allows plain `http://`/`ws://` outside loopback. Off by default; login credentials, cookies, wrapper tokens, and all session traffic are unencrypted while enabled. Prefer TLS whenever possible. | +| `FORWARDED_ALLOW_IPS` | empty | Extra peers trusted to supply `X-Forwarded-Proto` / `X-Forwarded-For`: a comma-separated list of IPs or CIDR networks, appended after the built-in `127.0.0.1,::1`. Only needed when the reverse proxy does not reach the relay over loopback (Tailscale, LAN, or the Docker bridge gateway): uvicorn ignores the proxy headers from an untrusted peer, so the relay computes an `http:80` request target while the browser's Origin says `https:443`, and page loads and `/api/*` keep working while every WebSocket is rejected with 403. List **only the addresses your own reverse proxy connects from** — a trusted peer may claim any client address and any scheme, so the relay must stay unreachable from anywhere else, and `*`, `0.0.0.0/0`, and `::/0` are rejected at startup. Malformed entries are rejected at startup too, rather than silently never matching. | | `WRAPPER_TOKEN` | placeholder | Wrapper Bearer token for single-machine/compatibility mode; required unless `WRAPPER_TOKENS_JSON` is set. | | `WRAPPER_TOKENS_JSON` | empty | Optional machine-bound tokens: `{"laptop":"…","server":"…"}`; replaces the relay's wildcard `WRAPPER_TOKEN`. | | `WEB_STATIC_DIR` | empty | Point at `web/dist` to serve the web client same-origin; empty = API/WS only. | diff --git a/tests/test_auth.py b/tests/test_auth.py index e77db6d4..9a9e6142 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -989,7 +989,9 @@ def test_uvicorn_access_log_is_disabled(monkeypatch): assert called["args"] == (app,) assert called["kwargs"]["access_log"] is False assert called["kwargs"]["proxy_headers"] is True - assert called["kwargs"]["forwarded_allow_ips"] == "127.0.0.1,::1" + # Loopback by default; FORWARDED_ALLOW_IPS appends extra trusted proxies. + # Behavior is covered in tests/test_relay_forwarded_headers.py. + assert called["kwargs"]["forwarded_allow_ips"] == cfg.forwarded_allow_ips configured = called["kwargs"]["log_config"] expected = uvicorn_log_config() assert configured.keys() == expected.keys() diff --git a/tests/test_relay_forwarded_headers.py b/tests/test_relay_forwarded_headers.py new file mode 100644 index 00000000..f1e25766 --- /dev/null +++ b/tests/test_relay_forwarded_headers.py @@ -0,0 +1,348 @@ +"""Regression tests for the forwarded-header trust boundary. + +The relay sits behind a same-host reverse proxy, so it accepts +``X-Forwarded-Proto`` / ``X-Forwarded-For`` — but only from peers on an +allowlist. Uvicorn enforces that in ``ProxyHeadersMiddleware``, which it wraps +around the app at startup (``uvicorn/config.py``). These tests drive that same +middleware instead of asserting on the allowlist string, because the string +being right is not what makes the feature work. + +The failure this guards is silent: when a proxy connects from an address that +is not trusted, uvicorn ignores the forwarded scheme, the relay computes an +``http`` request target while the browser's Origin says ``https``, and every +WebSocket upgrade is rejected with 403 while page loads and ``/api/*`` keep +working. See the PR description for the original report. +""" +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + +from cc_remote.config import ( + LOOPBACK_PROXY_IPS, + RelayConfig, + _forwarded_allow_ips, + validate_relay_config, +) +from cc_remote.protocol import Hello, serialize +from cc_remote.relay import server +from cc_remote.relay.auth import SESSION_COOKIE_NAME +from cc_remote.relay.server import create_app + +ORIGIN = "https://remote.example" +# A non-loopback address standing in for a Tailscale/LAN/Docker-bridge proxy. +PROXY_IP = "100.64.0.9" +FOREIGN_IP = "203.0.113.77" +PASSWORD = "correct horse battery staple" + + +@pytest.fixture(autouse=True) +def _clear_login_rate_limit(): + server._login_limiter.reset() + yield + server._login_limiter.reset() + + +def _cfg(**overrides) -> RelayConfig: + values = { + "login_password": PASSWORD, + "session_secret": "s" * 48, + "wrapper_token": "w" * 48, + "public_origin": ORIGIN, + "session_ttl_seconds": 3600, + "device_db_path": str( + Path(tempfile.mkdtemp(prefix="cc-remote-fwd-test-")) / "devices.sqlite3" + ), + } + values.update(overrides) + return RelayConfig(**values) + + +def _client(cfg: RelayConfig, peer: str) -> TestClient: + """A client whose TCP peer is ``peer``, behind uvicorn's proxy middleware. + + ``TestClient(client=...)`` sets the ASGI ``client`` entry, which is exactly + the peer address the middleware inspects, so this reproduces the production + trust decision without needing a real reverse proxy. The transport is plain + ``http`` because that is what the browser-to-proxy leg looks like on the + wire: TLS terminates at Caddy/nginx, and the relay learns the real scheme + only from ``X-Forwarded-Proto``. + """ + app = create_app(cfg) + wrapped = ProxyHeadersMiddleware(app, trusted_hosts=cfg.forwarded_allow_ips) + return TestClient(wrapped, base_url="http://remote.example", client=(peer, 12345)) + + +def _login(client: TestClient, password: str = PASSWORD, **headers): + return client.post( + "/api/login", + json={"password": password}, + headers={"Origin": ORIGIN, "X-Forwarded-Proto": "https", **headers}, + ) + + +def _ws_url(path: str = "/ws") -> str: + """WebSocket URL for ``TestClient``. + + ``websocket_connect`` ignores the client's ``base_url`` and defaults to + ``testserver``, which would not match ``PUBLIC_ORIGIN`` in the origin gate. + """ + return f"ws://{ORIGIN.split('://', 1)[1]}{path}" + + +def _ws_cookie(client: TestClient) -> str: + response = _login(client) + assert response.status_code == 200 + token = response.cookies.get(SESSION_COOKIE_NAME) + assert token + return f"{SESSION_COOKIE_NAME}={token}" + + +# -------------------------------------------------------------------------- +# The env-var contract: defaults, composition, validation +# -------------------------------------------------------------------------- + + +def test_unset_or_blank_keeps_loopback_only(monkeypatch): + for value in (None, "", " ", ",", " , ,"): + if value is None: + monkeypatch.delenv("FORWARDED_ALLOW_IPS", raising=False) + else: + monkeypatch.setenv("FORWARDED_ALLOW_IPS", value) + assert _forwarded_allow_ips() == "127.0.0.1,::1" + assert _cfg().forwarded_allow_ips == "127.0.0.1,::1" + + +def test_extra_proxies_are_appended_after_the_loopback_defaults(monkeypatch): + monkeypatch.setenv("FORWARDED_ALLOW_IPS", f"{PROXY_IP} , fd7a:115c:a1e0::/48") + assert _forwarded_allow_ips() == f"127.0.0.1,::1,{PROXY_IP},fd7a:115c:a1e0::/48" + # The defaults survive configuration of extras -- an operator adding a + # proxy must not have to restate them. + for loopback in LOOPBACK_PROXY_IPS: + assert loopback in _forwarded_allow_ips().split(",") + + +def test_duplicates_and_loopback_restatements_are_dropped(monkeypatch): + monkeypatch.setenv( + "FORWARDED_ALLOW_IPS", f"127.0.0.1,{PROXY_IP}, ::1 ,{PROXY_IP},127.0.0.1" + ) + assert _forwarded_allow_ips() == f"127.0.0.1,::1,{PROXY_IP}" + + +@pytest.mark.parametrize("value", ["*", "0.0.0.0/0", "::/0"]) +def test_wildcard_trust_is_rejected(value): + with pytest.raises(ValueError, match="FORWARDED_ALLOW_IPS"): + validate_relay_config(_cfg(forwarded_allow_ips=value)) + + +@pytest.mark.parametrize( + "value", + ["not-an-ip", "proxy.internal", "100.64.0.9/24", "100.64.0.9/", "1.2.3.4:5"], +) +def test_malformed_entries_are_rejected_instead_of_silently_unmatched(value): + # uvicorn stores these as string literals that are compared against a peer + # address, so they start cleanly and then never match. Reject at startup. + with pytest.raises(ValueError, match="not a valid IP address or CIDR"): + validate_relay_config(_cfg(forwarded_allow_ips=f"127.0.0.1,::1,{value}")) + + +def test_valid_extra_entries_pass_validation(): + validate_relay_config( + _cfg(forwarded_allow_ips=f"127.0.0.1,::1,{PROXY_IP},fd7a:115c:a1e0::/48") + ) + + +def test_main_passes_the_configured_allowlist_to_uvicorn(monkeypatch): + import cc_remote.relay.__main__ as relay_main + + cfg = _cfg(forwarded_allow_ips=f"127.0.0.1,::1,{PROXY_IP}") + called: dict = {} + monkeypatch.setattr(relay_main, "relay_config", lambda: cfg) + monkeypatch.setattr(relay_main, "create_app", lambda actual: object()) + monkeypatch.setattr( + relay_main.uvicorn, "run", + lambda *args, **kwargs: called.update(kwargs), + ) + + relay_main.main() + + assert called["proxy_headers"] is True + assert called["forwarded_allow_ips"] == f"127.0.0.1,::1,{PROXY_IP}" + + +# -------------------------------------------------------------------------- +# Middleware behavior: scheme +# -------------------------------------------------------------------------- + + +def _cfg_via_env(monkeypatch, extras: str = "") -> RelayConfig: + """Build the config the way a deployment does: through the environment. + + ``RelayConfig.forwarded_allow_ips`` defaults to ``_forwarded_allow_ips()``, + which reads ``FORWARDED_ALLOW_IPS`` at construction time. Going through the + env var keeps these tests on the real production path instead of injecting + an allowlist that no deployment could produce. + """ + if extras: + monkeypatch.setenv("FORWARDED_ALLOW_IPS", extras) + else: + monkeypatch.delenv("FORWARDED_ALLOW_IPS", raising=False) + return _cfg() + + +def test_configured_proxy_scheme_is_trusted_and_matches_the_browser_origin(monkeypatch): + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + assert PROXY_IP in cfg.forwarded_allow_ips.split(",") + with _client(cfg, PROXY_IP) as client: + response = _login(client) + assert response.status_code == 200 + + +def test_untrusted_peer_cannot_assert_a_scheme_it_does_not_terminate(monkeypatch): + """The exact reported failure: the origin check compares the forwarded + scheme against the browser Origin and rejects every WebSocket.""" + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + with _client(cfg, FOREIGN_IP) as client: + response = _login(client) + assert response.status_code == 403 + assert response.json() == {"error": "origin_rejected"} + + +def test_loopback_only_is_the_default_when_the_env_var_is_unset(monkeypatch): + """Unset must reproduce the original behavior byte for byte: a proxy on a + non-loopback address is not trusted, and loopback still is.""" + cfg = _cfg_via_env(monkeypatch) + assert cfg.forwarded_allow_ips == "127.0.0.1,::1" + with _client(cfg, PROXY_IP) as client: + assert _login(client).status_code == 403 + with _client(cfg, "127.0.0.1") as client: + assert _login(client).status_code == 200 + + +def test_blank_env_var_is_treated_as_unset(monkeypatch): + cfg = _cfg_via_env(monkeypatch, " ") + assert cfg.forwarded_allow_ips == "127.0.0.1,::1" + with _client(cfg, PROXY_IP) as client: + assert _login(client).status_code == 403 + + +def test_the_scheme_mismatch_disappears_once_the_proxy_address_is_trusted(monkeypatch): + """Same peer, same headers -- only the allowlist changes.""" + untrusted = _cfg_via_env(monkeypatch) + with _client(untrusted, PROXY_IP) as client: + assert _login(client).status_code == 403 + + trusted = _cfg_via_env(monkeypatch, PROXY_IP) + with _client(trusted, PROXY_IP) as client: + assert _login(client).status_code == 200 + + +def test_loopback_proxy_stays_trusted_when_extras_are_configured(monkeypatch): + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + for loopback in LOOPBACK_PROXY_IPS: + with _client(cfg, loopback) as client: + assert _login(client).status_code == 200 + + +def test_proxy_without_forwarded_proto_still_uses_the_real_scheme(monkeypatch): + """A trusted proxy that forwards nothing must not break the plain path.""" + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + with _client(cfg, PROXY_IP) as client: + response = client.post( + "/api/login", json={"password": PASSWORD}, headers={"Origin": ORIGIN}, + ) + # No X-Forwarded-Proto, so the request target is http and the https Origin + # is correctly rejected rather than silently accepted. + assert response.status_code == 403 + + +# -------------------------------------------------------------------------- +# Middleware behavior: client address (rate-limit bucketing) +# -------------------------------------------------------------------------- + + +def test_trusted_proxy_forwards_the_client_address_for_rate_limiting(monkeypatch): + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + with _client(cfg, PROXY_IP) as client: + for _ in range(server._LOGIN_MAX): + response = _login(client, password="wrong-password-here", + **{"X-Forwarded-For": "203.0.113.1"}) + assert response.status_code == 401 + # The bucket is exhausted for that forwarded client... + exhausted = _login(client, password="wrong-password-here", + **{"X-Forwarded-For": "203.0.113.1"}) + assert exhausted.status_code == 429 + # ...but not for a different one behind the same proxy address. Without + # a trusted X-Forwarded-For every user would share one bucket and five + # bad attempts would lock the relay out for everyone. + other = _login(client, password="wrong-password-here", + **{"X-Forwarded-For": "203.0.113.2"}) + assert other.status_code == 401 + + +def test_untrusted_peer_cannot_choose_its_own_rate_limit_bucket(monkeypatch): + """A forged X-Forwarded-For must not let a caller escape the peer bucket.""" + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + with _client(cfg, FOREIGN_IP) as client: + for _ in range(server._LOGIN_MAX): + response = _login(client, password="wrong-password-here", + **{"X-Forwarded-For": "203.0.113.1"}) + # Rejected at the origin gate before the limiter is consulted. + assert response.status_code == 403 + still_rejected = _login(client, password="wrong-password-here", + **{"X-Forwarded-For": "203.0.113.2"}) + assert still_rejected.status_code == 403 + + +# -------------------------------------------------------------------------- +# The same gate on the WebSocket route +# -------------------------------------------------------------------------- + + +def test_trusted_proxy_websocket_reaches_the_handshake(monkeypatch): + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + with _client(cfg, PROXY_IP) as client: + cookie = _ws_cookie(client) + with client.websocket_connect( + _ws_url(), headers={"cookie": cookie, "origin": ORIGIN, + "X-Forwarded-Proto": "https"}, + ) as websocket: + websocket.send_text( + serialize(Hello(role="client", client_id="forwarded-test")) + ) + # Reaching the application layer proves the origin gate passed; + # no wrapper is connected in this test. + assert json.loads(websocket.receive_text())["code"] == "wrapper_offline" + # Close explicitly: letting the context manager tear the portal + # down first races the relay's own teardown and intermittently + # surfaces as a CancelledError instead of a clean exit. + websocket.close() + + +def test_untrusted_peer_websocket_is_closed_by_the_origin_gate(monkeypatch): + cfg = _cfg_via_env(monkeypatch, PROXY_IP) + with _client(cfg, FOREIGN_IP) as client: + # Login itself is rejected, so build the cookie from a trusted peer and + # replay it from the untrusted one -- the WS gate must stand alone. + assert client.post( + "/api/login", json={"password": PASSWORD}, headers={"Origin": ORIGIN}, + ).status_code == 403 + + with _client(cfg, PROXY_IP) as trusted: + cookie = _ws_cookie(trusted) + + with _client(cfg, FOREIGN_IP) as untrusted: + # The gate rejects before accepting, so entering the context raises. + with pytest.raises(WebSocketDisconnect) as excinfo: + with untrusted.websocket_connect( + _ws_url(), headers={"cookie": cookie, "origin": ORIGIN, + "X-Forwarded-Proto": "https"}, + ): + pass + assert excinfo.value.code == 1008