mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Merge branch 'ready' of https://github.com/agent0ai/agent-zero into ready
This commit is contained in:
commit
c576f67469
57 changed files with 2978 additions and 654 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
107
helpers/tunnel_origins.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue