frdel commited on
Commit
09be93d
·
1 Parent(s): 6ef0f89

csrf ALLOWED_ORIGINS protection

Browse files
python/api/csrf_token.py CHANGED
@@ -1,4 +1,5 @@
1
  import secrets
 
2
  from python.helpers.api import (
3
  ApiHandler,
4
  Input,
@@ -7,7 +8,9 @@ from python.helpers.api import (
7
  Response,
8
  session,
9
  )
10
- from python.helpers import runtime
 
 
11
 
12
  class GetCsrfToken(ApiHandler):
13
 
@@ -20,6 +23,90 @@ class GetCsrfToken(ApiHandler):
20
  return False
21
 
22
  async def process(self, input: Input, request: Request) -> Output:
 
 
 
 
 
 
 
 
 
 
23
  if "csrf_token" not in session:
24
  session["csrf_token"] = secrets.token_urlsafe(32)
25
- return {"token": session["csrf_token"], "runtime_id": runtime.get_runtime_id()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import secrets
2
+ from urllib.parse import urlparse
3
  from python.helpers.api import (
4
  ApiHandler,
5
  Input,
 
8
  Response,
9
  session,
10
  )
11
+ from python.helpers import runtime, dotenv, login
12
+ import fnmatch
13
+
14
 
15
  class GetCsrfToken(ApiHandler):
16
 
 
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)
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 CHANGED
@@ -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()
10
 
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":
34
  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
- }
40
 
41
  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()
 
 
 
49
  return {
50
- "success": True
 
 
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  class Tunnel(ApiHandler):
6
  async def process(self, input: dict, request: Request) -> dict | Response:
7
+ return await process(input)
 
 
8
 
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()
 
 
 
 
 
26
 
27
  return {
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 {
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 CHANGED
@@ -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
- )
15
 
16
- # first verify the service is running:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  service_ok = False
 
 
 
18
  try:
19
- response = requests.post(f"http://localhost:{tunnel_api_port}/", json={"action": "health"})
20
- if response.status_code == 200:
21
- service_ok = True
22
  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)
 
6
 
7
  class TunnelProxy(ApiHandler):
8
  async def process(self, input: dict, request: Request) -> dict | Response:
9
+ return await process(input)
 
 
 
 
 
10
 
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:
31
+ response = requests.post(f"http://localhost:{tunnel_api_port}/", json=input)
32
+ return response.json()
 
33
  except Exception as e:
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 ADDED
@@ -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 CHANGED
@@ -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
 
21
  # disable logging
22
  import logging
@@ -116,24 +117,17 @@ def requires_loopback(f):
116
  return decorated
117
 
118
 
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
-
126
  # require authentication for handlers
127
  def requires_auth(f):
128
  @wraps(f)
129
  async def decorated(*args, **kwargs):
130
- user_pass_hash = _get_credentials_hash()
131
  # If no auth is configured, just proceed
132
  if not user_pass_hash:
133
  return await f(*args, **kwargs)
134
 
135
  if session.get('authentication') != user_pass_hash:
136
- return redirect(url_for('login'))
137
 
138
  return await f(*args, **kwargs)
139
 
@@ -153,14 +147,14 @@ def csrf_protect(f):
153
  return decorated
154
 
155
  @webapp.route("/login", methods=["GET", "POST"])
156
- async def login():
157
  error = None
158
  if request.method == 'POST':
159
  user = dotenv.get_dotenv_value("AUTH_LOGIN")
160
  password = dotenv.get_dotenv_value("AUTH_PASSWORD")
161
 
162
  if request.form['username'] == user and request.form['password'] == password:
163
- session['authentication'] = _get_credentials_hash()
164
  return redirect(url_for('serve_index'))
165
  else:
166
  error = 'Invalid Credentials. Please try again.'
@@ -169,9 +163,9 @@ async def login():
169
  return render_template_string(login_page_content, error=error)
170
 
171
  @webapp.route("/logout")
172
- async def logout():
173
  session.pop('authentication', None)
174
- return redirect(url_for('login'))
175
 
176
  # handle default address, load index
177
  @webapp.route("/", methods=["GET"])
 
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
 
117
  return decorated
118
 
119
 
 
 
 
 
 
 
 
120
  # require authentication for handlers
121
  def requires_auth(f):
122
  @wraps(f)
123
  async def decorated(*args, **kwargs):
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:
130
+ return redirect(url_for('login_handler'))
131
 
132
  return await f(*args, **kwargs)
133
 
 
147
  return decorated
148
 
149
  @webapp.route("/login", methods=["GET", "POST"])
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:
157
+ session['authentication'] = login.get_credentials_hash()
158
  return redirect(url_for('serve_index'))
159
  else:
160
  error = 'Invalid Credentials. Please try again.'
 
163
  return render_template_string(login_page_content, error=error)
164
 
165
  @webapp.route("/logout")
166
+ async def logout_handler():
167
  session.pop('authentication', None)
168
+ return redirect(url_for('login_handler'))
169
 
170
  # handle default address, load index
171
  @webapp.route("/", methods=["GET"])
webui/js/api.js CHANGED
@@ -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")){
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;
 
 
 
 
 
94
  }
 
52
  // retry the request with new token
53
  csrfToken = null;
54
  return await _wrap(false);
55
+ } else if (response.redirected && response.url.endsWith("/login")) {
56
  // redirect to login
57
  window.location.href = response.url;
58
  return;
 
88
  return;
89
  }
90
  const json = await response.json();
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
  }