Merge branch 'ready' of https://github.com/agent0ai/agent-zero into ready

This commit is contained in:
frdel 2026-06-08 12:41:57 +02:00
commit c576f67469
57 changed files with 2978 additions and 654 deletions

View file

@ -1,6 +1,7 @@
from abc import abstractmethod
import json
import threading
from urllib.parse import urlsplit, unquote
from functools import wraps
from pathlib import Path
from typing import Union, Dict, Any
@ -102,6 +103,44 @@ class ApiHandler:
from helpers.network import is_loopback_address
def is_safe_next_url(value: str | None) -> bool:
"""Return True when value is a safe same-origin redirect target."""
if not value:
return False
if "\r" in value or "\n" in value:
return False
# Reject raw backslashes (browsers normalize `/\host` to `//host` -> external).
if "\\" in value:
return False
# Decode percent-escapes so encoded backslashes (e.g. `%5C`) are caught too.
decoded = unquote(value)
if "\\" in decoded:
return False
parsed = urlsplit(decoded)
if parsed.scheme or parsed.netloc:
return False
# Require an absolute path within this origin, but reject protocol-relative URLs.
return parsed.path.startswith("/") and not parsed.path.startswith("//")
def get_safe_next_url(value: str | None, fallback: str | None = None) -> str | None:
"""Return value if it is a safe next URL, otherwise return a safe fallback."""
if is_safe_next_url(value):
return value
if is_safe_next_url(fallback):
return fallback
return None
def get_current_request_next_url() -> str:
"""Return the current request path/query as a safe relative redirect target."""
next_url = request.full_path if request.query_string else request.path
return get_safe_next_url(next_url, url_for("serve_index")) or url_for("serve_index")
def requires_api_key(f):
@wraps(f)
async def decorated(*args, **kwargs):
@ -142,7 +181,7 @@ def requires_auth(f):
if not user_pass_hash:
return await f(*args, **kwargs)
if session.get("authentication") != user_pass_hash:
return redirect(url_for("login_handler"))
return redirect(url_for("login_handler", next=get_current_request_next_url()))
return await f(*args, **kwargs)
return decorated

View file

@ -313,6 +313,10 @@ class FileBrowser:
full_path = (self.base_dir / current_path).resolve()
if not str(full_path).startswith(str(self.base_dir)):
raise ValueError("Invalid path")
if not full_path.exists():
raise FileNotFoundError("Directory not found")
if not full_path.is_dir():
raise NotADirectoryError("Path is not a directory")
# Use ls command instead of os.scandir for better error handling
files, folders = self._get_files_via_ls(full_path)
@ -342,7 +346,12 @@ class FileBrowser:
except Exception as e:
PrintStyle.error(f"Error reading directory: {e}")
return {"entries": [], "current_path": "", "parent_path": ""}
return {
"entries": [],
"current_path": current_path,
"parent_path": "",
"error": str(e),
}
def get_full_path(self, file_path: str, allow_dir: bool = False) -> str:
"""Get full file path if it exists and is within base_dir"""

View file

@ -66,6 +66,7 @@ class Settings(TypedDict):
workdir_max_folders: int
workdir_max_lines: int
workdir_gitignore: str
file_browser_remember_last_directory: bool
api_keys: dict[str, str]
@ -506,6 +507,10 @@ def get_default_settings() -> Settings:
workdir_max_folders=get_default_value("workdir_max_folders", 20),
workdir_max_lines=get_default_value("workdir_max_lines", 250),
workdir_gitignore=get_default_value("workdir_gitignore", gitignore),
file_browser_remember_last_directory=get_default_value(
"file_browser_remember_last_directory",
True,
),
rfc_auto_docker=get_default_value("rfc_auto_docker", True),
rfc_url=get_default_value("rfc_url", "localhost"),
rfc_password="",

107
helpers/tunnel_origins.py Normal file
View file

@ -0,0 +1,107 @@
import json
import urllib.request
from urllib.parse import urlparse
_DEFAULT_PORTS = {
"http": 80,
"https": 443,
"ws": 80,
"wss": 443,
}
def origin_from_url(value):
"""Normalize a URL or Origin header to scheme://host[:port]."""
if not isinstance(value, str) or not value.strip():
return None
parsed = urlparse(value.strip())
if not parsed.scheme or not parsed.hostname:
return None
scheme = parsed.scheme.lower()
host = parsed.hostname.lower()
try:
port = parsed.port
except ValueError:
return None
origin = f"{scheme}://{host}"
if port and port != _DEFAULT_PORTS.get(scheme):
origin += f":{port}"
return origin
def origin_key(value):
"""Return a comparable same-origin tuple including default ports."""
origin = origin_from_url(value)
if not origin:
return None
parsed = urlparse(origin)
try:
port = parsed.port or _DEFAULT_PORTS.get(parsed.scheme)
except ValueError:
return None
if not parsed.scheme or not parsed.hostname or port is None:
return None
return parsed.scheme, parsed.hostname.lower(), int(port)
def get_active_tunnel_origins():
"""Return normalized origins for currently active Remote Control URLs."""
origins = []
try:
from helpers.tunnel_manager import TunnelManager
tunnel_url = TunnelManager.get_instance().get_tunnel_url()
_append_origin(origins, tunnel_url)
except Exception:
pass
try:
_append_origin(origins, _get_tunnel_service_url())
except Exception:
pass
return origins
def _append_origin(origins, url):
origin = origin_from_url(url)
if origin and origin not in origins:
origins.append(origin)
def _get_tunnel_service_url():
try:
from helpers import dotenv, runtime
should_query_service = bool(
runtime.is_dockerized()
or runtime.get_arg("tunnel_api_port")
or dotenv.get_dotenv_value("TUNNEL_API_PORT")
)
if not should_query_service:
return None
port = runtime.get_tunnel_api_port()
except Exception:
return None
body = json.dumps({"action": "get"}).encode("utf-8")
request = urllib.request.Request(
f"http://localhost:{port}/",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=0.35) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
except Exception:
return None
if isinstance(payload, dict) and payload.get("success"):
return payload.get("tunnel_url")
return None

View file

@ -26,7 +26,7 @@ from werkzeug.wrappers.request import Request as WerkzeugRequest
import socketio # type: ignore[import-untyped]
from helpers import dotenv, fasta2a_server, files, git, login, mcp_server, runtime
from helpers.api import register_api_route, requires_auth
from helpers.api import get_safe_next_url, register_api_route, requires_auth
from helpers.extension import extensible
from helpers.files import get_abs_path
from helpers.print_style import PrintStyle
@ -200,19 +200,25 @@ class UiRouteHandlers:
@extensible
async def login_handler(self):
error = None
fallback_url = url_for("serve_index")
next_url = get_safe_next_url(
request.form.get("next") if request.method == "POST" else request.args.get("next"),
fallback_url,
)
if request.method == "POST":
user = dotenv.get_dotenv_value("AUTH_LOGIN")
password = dotenv.get_dotenv_value("AUTH_PASSWORD")
if request.form["username"] == user and request.form["password"] == password:
session["authentication"] = login.get_credentials_hash()
return redirect(url_for("serve_index"))
return redirect(next_url or fallback_url)
else:
await asyncio.sleep(1)
error = "Invalid Credentials. Please try again."
login_page_content = files.read_file("webui/login.html")
return render_template_string(login_page_content, error=error)
return render_template_string(login_page_content, error=error, next=next_url)
@extensible
async def logout_handler(self):

View file

@ -13,6 +13,7 @@ from flask import Flask, session, request
from helpers import files, cache
from helpers.print_style import PrintStyle
from helpers.errors import format_error
from helpers.tunnel_origins import get_active_tunnel_origins, origin_key
if TYPE_CHECKING:
from helpers.ws_manager import WsManager
@ -148,6 +149,11 @@ def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]:
if origin_host == host and origin_port == port:
return True, None
request_origin_key = (origin_parsed.scheme, origin_host, int(origin_port))
for active_origin in get_active_tunnel_origins():
if origin_key(active_origin) == request_origin_key:
return True, None
if origin_host not in {host for host, _ in candidates}:
return False, "origin_host_mismatch"
return False, "origin_port_mismatch"
@ -648,4 +654,4 @@ def _error_response(code: str, message: str,
"ok": False,
"error": {"code": code, "error": message},
}],
}
}