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
}