Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 可预览已保存的单元格、切换工作表并下载原文件。
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions cc_remote/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
from __future__ import annotations

import ipaddress
import math
import json
import os
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", ""))
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cc_remote/relay/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
9 changes: 9 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions deploy/env.relay.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions deploy/env.relay.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions deploy/nginx-reverse-proxy.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。 |
Expand Down
1 change: 1 addition & 0 deletions docs/configuration_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include login failures in the proxy-header symptom

In this untrusted-proxy scenario, the browser's POST /api/login includes an Origin and is rejected by _request_origin_allowed before authentication (cc_remote/relay/server.py:721-728); the new regression test likewise expects a 403 (tests/test_relay_forwarded_headers.py:207-214). Therefore the claim that /api/* keeps working and only WebSockets fail is misleading for an operator who cannot even log in; describe both origin-checked API requests and WebSocket upgrades as affected.

Useful? React with 👍 / 👎.

| `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. |
Expand Down
4 changes: 3 additions & 1 deletion tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading