diff --git a/README.md b/README.md index 1dc6741..7977683 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ it (and the operator map, and the marketplace UI) without installing anything: t evidence site at **[wikithoughts.github.io/passiveworkers](https://wikithoughts.github.io/passiveworkers/)**, or the screenshots in [docs/preview/](docs/preview/). The desk has no auth and only answers loopback names; to reach it from a LAN IP or Tailscale name, set e.g. `PW_SERVE_HOST=0.0.0.0 PW_SERVE_ALLOWED_HOSTS=desk.tailnet.ts.net,192.168.1.5` on a network you trust (a TLS proxy in front must pass the Host and `X-Forwarded-Proto` through, or POSTs get 403). +If that proxy runs on another machine, also set `FORWARDED_ALLOW_IPS=` (uvicorn only trusts `X-Forwarded-Proto` from loopback by default), or every POST gets 403 "forbidden origin". ## What leaves your machine diff --git a/passiveworkers/serve.py b/passiveworkers/serve.py index 06deeee..c8ef4a8 100644 --- a/passiveworkers/serve.py +++ b/passiveworkers/serve.py @@ -15,6 +15,7 @@ import os import pathlib import re +import sys import threading import uuid @@ -33,6 +34,7 @@ _sem = threading.BoundedSemaphore(_MAX_JOBS) # cap concurrency so two heavy runs don't thrash Ollama _JOB_HISTORY = 50 # cap the in-memory job map so a long-running desk doesn't leak _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) +_warned_hosts: set = set() # ignored PW_SERVE_ALLOWED_HOSTS entries, each warned about once def _allowed_hosts() -> frozenset: @@ -44,9 +46,15 @@ def _allowed_hosts() -> frozenset: if host and host not in ("0.0.0.0", "::"): extra.add(host) for entry in os.environ.get("PW_SERVE_ALLOWED_HOSTS", "").split(","): - parsed = _split_host(entry.strip().lower()) + entry = entry.strip().lower() + parsed = _split_host(entry) if parsed and parsed[0] not in ("0.0.0.0", "::"): extra.add(parsed[0]) + elif entry and entry not in _warned_hosts: + # fails closed (a 403 "forbidden host"), so say why once instead of leaving a silent typo + _warned_hosts.add(entry) + print(f" warning: PW_SERVE_ALLOWED_HOSTS entry {entry!r} ignored: use a plain hostname or " + "IP (IPv6 in [brackets]); wildcards are not allowed", file=sys.stderr, flush=True) return _LOOPBACK_HOSTS | extra @@ -317,6 +325,7 @@ def main() -> None: import uvicorn host = os.environ.get("PW_SERVE_HOST", "127.0.0.1") # 0.0.0.0 only inside containers port = int(os.environ.get("PW_SERVE_PORT", "8770") or 8770) + _allowed_hosts() # warn about bad PW_SERVE_ALLOWED_HOSTS entries at startup, not on the first request print(f"🔬 Research desk → http://{host}:{port} (Ctrl-C to stop)", flush=True) uvicorn.run(app, host=host, port=port, log_level="warning") diff --git a/tests/test_serve.py b/tests/test_serve.py index 0e189a6..3c11596 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -250,3 +250,16 @@ def test_history_items_use_data_name_not_inline_onclick(): html = serve.SERVE_HTML assert "onclick=\"openReport" not in html assert 'data-name="' in html and "dataset.name" in html + + +def test_ignored_allowed_hosts_entries_warn_once_each(client, monkeypatch, capsys): + # a typo in the override fails closed (403 "forbidden host"); the operator gets told why, once + monkeypatch.setattr(serve, "_warned_hosts", set()) + monkeypatch.setenv("PW_SERVE_ALLOWED_HOSTS", "desk.ts.net, fd7a::1, *, 0.0.0.0, bad@x,, [fd7a::2]:8770") + for _ in range(3): + assert client.get("/reports", headers={"host": "desk.ts.net"}).status_code == 200 + assert client.get("/reports", headers={"host": "[fd7a::1]"}).status_code == 403 + err = capsys.readouterr().err + for bad in ("'fd7a::1'", "'*'", "'0.0.0.0'", "'bad@x'"): + assert err.count(f"PW_SERVE_ALLOWED_HOSTS entry {bad} ignored") == 1, bad + assert "desk.ts.net" not in err and "fd7a::2" not in err and err.count("ignored") == 4