Fix Tailscale Remote Control CSRF origins

Normalize active Remote Control URLs to same-origin values before adding them to CSRF allowlists, so Tailscale Funnel URLs with paths or trailing slashes can bootstrap tokens correctly. Allow WebSocket origin validation to trust only the currently active Remote Control origin, including Docker split-process tunnel service URLs, while preserving rejection for unrelated external origins. Add focused regression coverage for active Tailscale-style origins, tunnel-service origin lookup, and negative cross-origin cases; keep run_ui decorator re-exports compatible with existing CSRF tests.

Alessandro committed Jun 4, 2026 at 14:52 UTC ca4efe6e6ac27905482f2995264c0bd5c4305832
6 files changed +320 -9
api/csrf_token.py
+6 -8
@@ -1,5 +1,4 @@
1 import secrets
2 -from urllib.parse import urlparse
2 from helpers.api import (
3 ApiHandler,
4 Input,
@@ -9,6 +8,7 @@ from helpers.api import (
8 session,
9 )
10 from helpers import runtime, dotenv, login
11 +from helpers.tunnel_origins import origin_from_url
12 import fnmatch
13
14 ALLOWED_ORIGINS_KEY = "ALLOWED_ORIGINS"
@@ -82,11 +82,7 @@ class GetCsrfToken(ApiHandler):
82 )
83 if not r:
84 return None
85 - # parse and normalize
86 - p = urlparse(r)
87 - if not p.scheme or not p.hostname:
88 - return None
89 - return f"{p.scheme}://{p.hostname}" + (f":{p.port}" if p.port else "")
85 + return origin_from_url(r)
86
87 async def get_allowed_origins(self) -> list[str]:
88 # get the allowed origins from the environment
@@ -107,8 +103,10 @@ class GetCsrfToken(ApiHandler):
103 from api.tunnel_proxy import process as tunnel_api_process
104
105 tunnel = await tunnel_api_process({"action": "get"})
110 - if tunnel and isinstance(tunnel, dict) and tunnel["success"]:
111 - allowed_origins.append(tunnel["tunnel_url"])
106 + if tunnel and isinstance(tunnel, dict) and tunnel.get("success"):
107 + tunnel_origin = origin_from_url(tunnel.get("tunnel_url"))
108 + if tunnel_origin:
109 + allowed_origins.append(tunnel_origin)
110 except Exception:
111 pass
112
helpers/tunnel_origins.py new
+107
@@ -0,0 +1,107 @@
1 +import json
2 +import urllib.request
3 +from urllib.parse import urlparse
4 +
5 +
6 +_DEFAULT_PORTS = {
7 + "http": 80,
8 + "https": 443,
9 + "ws": 80,
10 + "wss": 443,
11 +}
12 +
13 +
14 +def origin_from_url(value):
15 + """Normalize a URL or Origin header to scheme://host[:port]."""
16 + if not isinstance(value, str) or not value.strip():
17 + return None
18 + parsed = urlparse(value.strip())
19 + if not parsed.scheme or not parsed.hostname:
20 + return None
21 +
22 + scheme = parsed.scheme.lower()
23 + host = parsed.hostname.lower()
24 + try:
25 + port = parsed.port
26 + except ValueError:
27 + return None
28 +
29 + origin = f"{scheme}://{host}"
30 + if port and port != _DEFAULT_PORTS.get(scheme):
31 + origin += f":{port}"
32 + return origin
33 +
34 +
35 +def origin_key(value):
36 + """Return a comparable same-origin tuple including default ports."""
37 + origin = origin_from_url(value)
38 + if not origin:
39 + return None
40 + parsed = urlparse(origin)
41 + try:
42 + port = parsed.port or _DEFAULT_PORTS.get(parsed.scheme)
43 + except ValueError:
44 + return None
45 + if not parsed.scheme or not parsed.hostname or port is None:
46 + return None
47 + return parsed.scheme, parsed.hostname.lower(), int(port)
48 +
49 +
50 +def get_active_tunnel_origins():
51 + """Return normalized origins for currently active Remote Control URLs."""
52 + origins = []
53 +
54 + try:
55 + from helpers.tunnel_manager import TunnelManager
56 +
57 + tunnel_url = TunnelManager.get_instance().get_tunnel_url()
58 + _append_origin(origins, tunnel_url)
59 + except Exception:
60 + pass
61 +
62 + try:
63 + _append_origin(origins, _get_tunnel_service_url())
64 + except Exception:
65 + pass
66 +
67 + return origins
68 +
69 +
70 +def _append_origin(origins, url):
71 + origin = origin_from_url(url)
72 + if origin and origin not in origins:
73 + origins.append(origin)
74 +
75 +
76 +def _get_tunnel_service_url():
77 + try:
78 + from helpers import dotenv, runtime
79 +
80 + should_query_service = bool(
81 + runtime.is_dockerized()
82 + or runtime.get_arg("tunnel_api_port")
83 + or dotenv.get_dotenv_value("TUNNEL_API_PORT")
84 + )
85 + if not should_query_service:
86 + return None
87 +
88 + port = runtime.get_tunnel_api_port()
89 + except Exception:
90 + return None
91 +
92 + body = json.dumps({"action": "get"}).encode("utf-8")
93 + request = urllib.request.Request(
94 + f"http://localhost:{port}/",
95 + data=body,
96 + headers={"Content-Type": "application/json"},
97 + method="POST",
98 + )
99 + try:
100 + with urllib.request.urlopen(request, timeout=0.35) as response:
101 + payload = json.loads(response.read().decode("utf-8", errors="replace"))
102 + except Exception:
103 + return None
104 +
105 + if isinstance(payload, dict) and payload.get("success"):
106 + return payload.get("tunnel_url")
107 + return None
helpers/ws.py
+7 -1
@@ -13,6 +13,7 @@ from flask import Flask, session, request
13 from helpers import files, cache
14 from helpers.print_style import PrintStyle
15 from helpers.errors import format_error
16 +from helpers.tunnel_origins import get_active_tunnel_origins, origin_key
17
18 if TYPE_CHECKING:
19 from helpers.ws_manager import WsManager
@@ -148,6 +149,11 @@ def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]:
149 if origin_host == host and origin_port == port:
150 return True, None
151
152 + request_origin_key = (origin_parsed.scheme, origin_host, int(origin_port))
153 + for active_origin in get_active_tunnel_origins():
154 + if origin_key(active_origin) == request_origin_key:
155 + return True, None
156 +
157 if origin_host not in {host for host, _ in candidates}:
158 return False, "origin_host_mismatch"
159 return False, "origin_port_mismatch"
@@ -648,4 +654,4 @@ def _error_response(code: str, message: str,
654 "ok": False,
655 "error": {"code": code, "error": message},
656 }],
651 - }
\ No newline at end of file
657 + }
run_ui.py
+1
@@ -1,5 +1,6 @@
1 import initialize
2 from helpers import dotenv, extension, runtime
3 +from helpers.api import csrf_protect, requires_auth
4 from helpers.print_style import PrintStyle
5 from helpers.server_startup import run_uvicorn_with_retries
6 from helpers.ui_server import UiServerRuntime, configure_process_environment
tests/test_csrf_tunnel_origins.py new
+153
@@ -0,0 +1,153 @@
1 +from __future__ import annotations
2 +
3 +import json
4 +from types import SimpleNamespace
5 +
6 +import pytest
7 +from flask import Flask
8 +
9 +
10 +@pytest.mark.asyncio
11 +async def test_csrf_token_allows_normalized_active_tailscale_origin(monkeypatch):
12 + import api.csrf_token as csrf_module
13 + import api.tunnel_proxy as tunnel_proxy
14 +
15 + handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None)
16 + request = SimpleNamespace(
17 + headers={"Origin": "https://agent-zero.tailabc.ts.net"},
18 + environ={},
19 + referrer=None,
20 + )
21 +
22 + monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False)
23 + monkeypatch.setattr(
24 + csrf_module.dotenv,
25 + "get_dotenv_value",
26 + lambda key: "http://localhost:32080"
27 + if key == csrf_module.ALLOWED_ORIGINS_KEY
28 + else "",
29 + )
30 +
31 + async def fake_tunnel_process(input_data):
32 + return {
33 + "success": True,
34 + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/",
35 + "is_running": True,
36 + }
37 +
38 + monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process)
39 +
40 + origin_check = await handler.check_allowed_origin(request)
41 +
42 + assert origin_check["ok"] is True
43 + assert "https://agent-zero.tailabc.ts.net" in origin_check["allowed_origins"]
44 +
45 +
46 +@pytest.mark.asyncio
47 +async def test_csrf_token_rejects_unrelated_origin_with_active_tunnel(monkeypatch):
48 + import api.csrf_token as csrf_module
49 + import api.tunnel_proxy as tunnel_proxy
50 +
51 + handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None)
52 + request = SimpleNamespace(
53 + headers={"Origin": "https://evil.example"},
54 + environ={},
55 + referrer=None,
56 + )
57 +
58 + monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False)
59 + monkeypatch.setattr(
60 + csrf_module.dotenv,
61 + "get_dotenv_value",
62 + lambda key: "http://localhost:32080"
63 + if key == csrf_module.ALLOWED_ORIGINS_KEY
64 + else "",
65 + )
66 +
67 + async def fake_tunnel_process(input_data):
68 + return {
69 + "success": True,
70 + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/",
71 + "is_running": True,
72 + }
73 +
74 + monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process)
75 +
76 + origin_check = await handler.check_allowed_origin(request)
77 +
78 + assert origin_check["ok"] is False
79 +
80 +
81 +def test_active_tunnel_origins_include_docker_tunnel_service_url(monkeypatch):
82 + import helpers.tunnel_origins as tunnel_origins
83 +
84 + monkeypatch.setattr(
85 + tunnel_origins,
86 + "_get_tunnel_service_url",
87 + lambda: "https://agent-zero.tailabc.ts.net/funnel-ready/",
88 + )
89 +
90 + assert (
91 + "https://agent-zero.tailabc.ts.net"
92 + in tunnel_origins.get_active_tunnel_origins()
93 + )
94 +
95 +
96 +def test_tunnel_service_url_uses_short_local_get_request(monkeypatch):
97 + from helpers import dotenv, runtime
98 + import helpers.tunnel_origins as tunnel_origins
99 +
100 + captured = {}
101 +
102 + class FakeResponse:
103 + def __enter__(self):
104 + return self
105 +
106 + def __exit__(self, exc_type, exc, tb):
107 + return None
108 +
109 + def read(self):
110 + return json.dumps({
111 + "success": True,
112 + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/",
113 + }).encode("utf-8")
114 +
115 + def fake_urlopen(request, timeout):
116 + captured["url"] = request.full_url
117 + captured["body"] = request.data
118 + captured["method"] = request.get_method()
119 + captured["timeout"] = timeout
120 + return FakeResponse()
121 +
122 + monkeypatch.setattr(
123 + runtime,
124 + "is_dockerized",
125 + lambda: True,
126 + )
127 + monkeypatch.setattr(
128 + runtime,
129 + "get_arg",
130 + lambda name: None,
131 + )
132 + monkeypatch.setattr(
133 + runtime,
134 + "get_tunnel_api_port",
135 + lambda: 55520,
136 + )
137 + monkeypatch.setattr(
138 + dotenv,
139 + "get_dotenv_value",
140 + lambda key: "",
141 + )
142 + monkeypatch.setattr(tunnel_origins.urllib.request, "urlopen", fake_urlopen)
143 +
144 + assert (
145 + tunnel_origins._get_tunnel_service_url()
146 + == "https://agent-zero.tailabc.ts.net/funnel-ready/"
147 + )
148 + assert captured == {
149 + "url": "http://localhost:55520/",
150 + "body": b'{"action": "get"}',
151 + "method": "POST",
152 + "timeout": 0.35,
153 + }
tests/test_ws_csrf.py
+46
@@ -49,3 +49,49 @@ def test_validate_ws_origin_rejects_cross_origin():
49 )
50 assert ok is False
51 assert reason == "origin_host_mismatch"
52 +
53 +
54 +def test_validate_ws_origin_allows_active_tunnel_origin_with_local_upstream_host(
55 + monkeypatch,
56 +):
57 + import helpers.ws as ws
58 +
59 + monkeypatch.setattr(
60 + ws,
61 + "get_active_tunnel_origins",
62 + lambda: ["https://agent-zero.tailabc.ts.net"],
63 + raising=False,
64 + )
65 +
66 + ok, reason = validate_ws_origin(
67 + {
68 + "HTTP_ORIGIN": "https://agent-zero.tailabc.ts.net",
69 + "HTTP_HOST": "127.0.0.1:80",
70 + }
71 + )
72 +
73 + assert ok is True
74 + assert reason is None
75 +
76 +
77 +def test_validate_ws_origin_rejects_unrelated_origin_with_active_tunnel(
78 + monkeypatch,
79 +):
80 + import helpers.ws as ws
81 +
82 + monkeypatch.setattr(
83 + ws,
84 + "get_active_tunnel_origins",
85 + lambda: ["https://agent-zero.tailabc.ts.net"],
86 + raising=False,
87 + )
88 +
89 + ok, reason = validate_ws_origin(
90 + {
91 + "HTTP_ORIGIN": "https://evil.example",
92 + "HTTP_HOST": "127.0.0.1:80",
93 + }
94 + )
95 +
96 + assert ok is False
97 + assert reason == "origin_host_mismatch"