csrf ALLOWED_ORIGINS protection

frdel committed Oct 6, 2025 at 12:20 UTC c18c04d772700bac0dc174fd052435861145f588
6 files changed +188 -81
python/api/csrf_token.py
+89 -2
@@ -1,4 +1,5 @@
1 import secrets
2 +from urllib.parse import urlparse
3 from python.helpers.api import (
4 ApiHandler,
5 Input,
@@ -7,7 +8,9 @@ from python.helpers.api import (
8 Response,
9 session,
10 )
10 -from python.helpers import runtime
11 +from python.helpers import runtime, dotenv, login
12 +import fnmatch
13 +
14
15 class GetCsrfToken(ApiHandler):
16
@@ -20,6 +23,90 @@ class GetCsrfToken(ApiHandler):
23 return False
24
25 async def process(self, input: Input, request: Request) -> Output:
26 +
27 + # check for allowed origin to prevent dns rebinding attacks
28 + origin_check = await self.check_allowed_origin(request)
29 + if not origin_check["ok"]:
30 + return {
31 + "ok": False,
32 + "error": f"Origin {self.get_origin_from_request(request)} not allowed when login is disabled. Set login and password or add your URL to ALLOWED_ORIGINS env variable. Currently allowed origins: {",".join(origin_check['allowed_origins'])}",
33 + }
34 +
35 + # generate a csrf token if it doesn't exist
36 if "csrf_token" not in session:
37 session["csrf_token"] = secrets.token_urlsafe(32)
25 - return {"token": session["csrf_token"], "runtime_id": runtime.get_runtime_id()}
38 +
39 + # return the csrf token and runtime id
40 + return {
41 + "ok": True,
42 + "token": session["csrf_token"],
43 + "runtime_id": runtime.get_runtime_id(),
44 + }
45 +
46 + async def check_allowed_origin(self, request: Request):
47 + # if login is required, this che
48 + if login.is_login_required():
49 + return {"ok": True, "origin": "", "allowed_origins": ""}
50 + # otherwise, check if the origin is allowed
51 + return await self.is_allowed_origin(request)
52 +
53 + async def is_allowed_origin(self, request: Request):
54 + # get the origin from the request
55 + origin = self.get_origin_from_request(request)
56 + if not origin:
57 + return {"ok": False, "origin": "", "allowed_origins": ""}
58 +
59 + # list of allowed origins
60 + allowed_origins = await self.get_allowed_origins()
61 +
62 + # check if the origin is allowed
63 + match = any(
64 + fnmatch.fnmatch(origin, allowed_origin)
65 + for allowed_origin in allowed_origins
66 + )
67 + return {"ok": match, "origin": origin, "allowed_origins": allowed_origins}
68 +
69 + def get_origin_from_request(self, request: Request):
70 + # get from origin
71 + r = request.headers.get("Origin") or request.environ.get("HTTP_ORIGIN")
72 + if not r:
73 + # try referer if origin not present
74 + r = (
75 + request.headers.get("Referer")
76 + or request.referrer
77 + or request.environ.get("HTTP_REFERER")
78 + )
79 + if not r:
80 + return None
81 + # parse and normalize
82 + p = urlparse(r)
83 + if not p.scheme or not p.hostname:
84 + return None
85 + return f"{p.scheme}://{p.hostname}" + (f":{p.port}" if p.port else "")
86 +
87 + async def get_allowed_origins(self) -> list[str]:
88 + # get the allowed origins from the environment
89 + allowed_origins = [
90 + origin.strip()
91 + for origin in (dotenv.get_dotenv_value("ALLOWED_ORIGINS") or "").split(",")
92 + if origin.strip()
93 + ]
94 +
95 + # if there are no allowed origins, allow default localhosts
96 + if not allowed_origins:
97 + allowed_origins = self.get_default_allowed_origins()
98 +
99 + # always allow tunnel url if running
100 + try:
101 + from python.api.tunnel_proxy import process as tunnel_api_process
102 +
103 + tunnel = await tunnel_api_process({"action": "get"})
104 + if tunnel and isinstance(tunnel, dict) and tunnel["success"]:
105 + allowed_origins.append(tunnel["tunnel_url"])
106 + except Exception:
107 + pass
108 +
109 + return allowed_origins
110 +
111 + def get_default_allowed_origins(self) -> list[str]:
112 + return ["*://localhost:*", "*://127.0.0.1:*", "*://0.0.0.0:*"]
python/api/tunnel.py
+42 -39
@@ -4,48 +4,51 @@ from python.helpers.tunnel_manager import TunnelManager
4
5 class Tunnel(ApiHandler):
6 async def process(self, input: dict, request: Request) -> dict | Response:
7 - action = input.get("action", "get")
8 -
9 - tunnel_manager = TunnelManager.get_instance()
7 + return await process(input)
8
11 - if action == "health":
12 - return {"success": True}
13 -
14 - if action == "create":
15 - port = runtime.get_web_ui_port()
16 - provider = input.get("provider", "serveo") # Default to serveo
17 - tunnel_url = tunnel_manager.start_tunnel(port, provider)
18 - if tunnel_url is None:
19 - # Add a little delay and check again - tunnel might be starting
20 - import time
21 - time.sleep(2)
22 - tunnel_url = tunnel_manager.get_tunnel_url()
23 -
24 - return {
25 - "success": tunnel_url is not None,
26 - "tunnel_url": tunnel_url,
27 - "message": "Tunnel creation in progress" if tunnel_url is None else "Tunnel created successfully"
28 - }
29 -
30 - elif action == "stop":
31 - return self.stop()
32 -
33 - elif action == "get":
9 +async def process(input: dict) -> dict | Response:
10 + action = input.get("action", "get")
11 +
12 + tunnel_manager = TunnelManager.get_instance()
13 +
14 + if action == "health":
15 + return {"success": True}
16 +
17 + if action == "create":
18 + port = runtime.get_web_ui_port()
19 + provider = input.get("provider", "serveo") # Default to serveo
20 + tunnel_url = tunnel_manager.start_tunnel(port, provider)
21 + if tunnel_url is None:
22 + # Add a little delay and check again - tunnel might be starting
23 + import time
24 + time.sleep(2)
25 tunnel_url = tunnel_manager.get_tunnel_url()
35 - return {
36 - "success": tunnel_url is not None,
37 - "tunnel_url": tunnel_url,
38 - "is_running": tunnel_manager.is_running
39 - }
26
27 return {
42 - "success": False,
43 - "error": "Invalid action. Use 'create', 'stop', or 'get'."
44 - }
45 -
46 - def stop(self):
47 - tunnel_manager = TunnelManager.get_instance()
48 - tunnel_manager.stop_tunnel()
28 + "success": tunnel_url is not None,
29 + "tunnel_url": tunnel_url,
30 + "message": "Tunnel creation in progress" if tunnel_url is None else "Tunnel created successfully"
31 + }
32 +
33 + elif action == "stop":
34 + return stop()
35 +
36 + elif action == "get":
37 + tunnel_url = tunnel_manager.get_tunnel_url()
38 return {
50 - "success": True
39 + "success": tunnel_url is not None,
40 + "tunnel_url": tunnel_url,
41 + "is_running": tunnel_manager.is_running
42 }
43 +
44 + return {
45 + "success": False,
46 + "error": "Invalid action. Use 'create', 'stop', or 'get'."
47 + }
48 +
49 +def stop():
50 + tunnel_manager = TunnelManager.get_instance()
51 + tunnel_manager.stop_tunnel()
52 + return {
53 + "success": True
54 + }
python/api/tunnel_proxy.py
+26 -23
@@ -6,30 +6,33 @@ import requests
6
7 class TunnelProxy(ApiHandler):
8 async def process(self, input: dict, request: Request) -> dict | Response:
9 - # Get configuration from environment
10 - tunnel_api_port = (
11 - runtime.get_arg("tunnel_api_port")
12 - or int(dotenv.get_dotenv_value("TUNNEL_API_PORT", 0))
13 - or 55520
14 - )
9 + return await process(input)
10
16 - # first verify the service is running:
11 +async def process(input: dict) -> dict | Response:
12 + # Get configuration from environment
13 + tunnel_api_port = (
14 + runtime.get_arg("tunnel_api_port")
15 + or int(dotenv.get_dotenv_value("TUNNEL_API_PORT", 0))
16 + or 55520
17 + )
18 +
19 + # first verify the service is running:
20 + service_ok = False
21 + try:
22 + response = requests.post(f"http://localhost:{tunnel_api_port}/", json={"action": "health"})
23 + if response.status_code == 200:
24 + service_ok = True
25 + except Exception as e:
26 service_ok = False
27 +
28 + # forward this request to the tunnel service if OK
29 + if service_ok:
30 try:
19 - response = requests.post(f"http://localhost:{tunnel_api_port}/", json={"action": "health"})
20 - if response.status_code == 200:
21 - service_ok = True
31 + response = requests.post(f"http://localhost:{tunnel_api_port}/", json=input)
32 + return response.json()
33 except Exception as e:
23 - service_ok = False
24 -
25 - # forward this request to the tunnel service if OK
26 - if service_ok:
27 - try:
28 - response = requests.post(f"http://localhost:{tunnel_api_port}/", json=input)
29 - return response.json()
30 - except Exception as e:
31 - return {"error": str(e)}
32 - else:
33 - # forward to API handler directly
34 - from python.api.tunnel import Tunnel
35 - return await Tunnel(self.app, self.thread_lock).process(input, request)
34 + return {"error": str(e)}
35 + else:
36 + # forward to API handler directly
37 + from python.api.tunnel import process as local_process
38 + return await local_process(input)
python/helpers/login.py new
+15
@@ -0,0 +1,15 @@
1 +from python.helpers import dotenv
2 +import hashlib
3 +
4 +
5 +def get_credentials_hash():
6 + user = dotenv.get_dotenv_value("AUTH_LOGIN")
7 + password = dotenv.get_dotenv_value("AUTH_PASSWORD")
8 + if not user:
9 + return None
10 + return hashlib.sha256(f"{user}:{password}".encode()).hexdigest()
11 +
12 +
13 +def is_login_required():
14 + user = dotenv.get_dotenv_value("AUTH_LOGIN")
15 + return bool(user)
run_ui.py
+7 -13
@@ -17,6 +17,7 @@ from python.helpers import runtime, dotenv, process
17 from python.helpers.extract_tools import load_classes_from_folder
18 from python.helpers.api import ApiHandler
19 from python.helpers.print_style import PrintStyle
20 +from python.helpers import login
21
22 # disable logging
23 import logging
@@ -116,24 +117,17 @@ def requires_loopback(f):
117 return decorated
118
119
119 -def _get_credentials_hash():
120 - user = dotenv.get_dotenv_value("AUTH_LOGIN")
121 - password = dotenv.get_dotenv_value("AUTH_PASSWORD")
122 - if not user:
123 - return None
124 - return hashlib.sha256(f"{user}:{password}".encode()).hexdigest()
125 -
120 # require authentication for handlers
121 def requires_auth(f):
122 @wraps(f)
123 async def decorated(*args, **kwargs):
130 - user_pass_hash = _get_credentials_hash()
124 + user_pass_hash = login.get_credentials_hash()
125 # If no auth is configured, just proceed
126 if not user_pass_hash:
127 return await f(*args, **kwargs)
128
129 if session.get('authentication') != user_pass_hash:
136 - return redirect(url_for('login'))
130 + return redirect(url_for('login_handler'))
131
132 return await f(*args, **kwargs)
133
@@ -153,14 +147,14 @@ def csrf_protect(f):
147 return decorated
148
149 @webapp.route("/login", methods=["GET", "POST"])
156 -async def login():
150 +async def login_handler():
151 error = None
152 if request.method == 'POST':
153 user = dotenv.get_dotenv_value("AUTH_LOGIN")
154 password = dotenv.get_dotenv_value("AUTH_PASSWORD")
155
156 if request.form['username'] == user and request.form['password'] == password:
163 - session['authentication'] = _get_credentials_hash()
157 + session['authentication'] = login.get_credentials_hash()
158 return redirect(url_for('serve_index'))
159 else:
160 error = 'Invalid Credentials. Please try again.'
@@ -169,9 +163,9 @@ async def login():
163 return render_template_string(login_page_content, error=error)
164
165 @webapp.route("/logout")
172 -async def logout():
166 +async def logout_handler():
167 session.pop('authentication', None)
174 - return redirect(url_for('login'))
168 + return redirect(url_for('login_handler'))
169
170 # handle default address, load index
171 @webapp.route("/", methods=["GET"])
webui/js/api.js
+9 -4
@@ -52,7 +52,7 @@ export async function fetchApi(url, request) {
52 // retry the request with new token
53 csrfToken = null;
54 return await _wrap(false);
55 - }else if(response.redirected && response.url.endsWith("/login")){
55 + } else if (response.redirected && response.url.endsWith("/login")) {
56 // redirect to login
57 window.location.href = response.url;
58 return;
@@ -88,7 +88,12 @@ async function getCsrfToken() {
88 return;
89 }
90 const json = await response.json();
91 - csrfToken = json.token;
92 - document.cookie = `csrf_token_${json.runtime_id}=${csrfToken}; SameSite=Strict; Path=/`;
93 - return csrfToken;
91 + if (json.ok) {
92 + csrfToken = json.token;
93 + document.cookie = `csrf_token_${json.runtime_id}=${csrfToken}; SameSite=Strict; Path=/`;
94 + return csrfToken;
95 + } else {
96 + if (json.error) alert(json.error);
97 + throw new Error(json.error || "Failed to get CSRF token");
98 + }
99 }