mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5ff666c5d | |||
| 8c943226f8 | |||
| 0dc98ec9b9 | |||
| 5e9b415bd9 | |||
| b1f9f67d9d | |||
| 88191d17fb | |||
| dff91efb10 | |||
| b26ebbda95 | |||
| 260f432332 | |||
| e157f1e63d | |||
| dc3530b8fa | |||
| 2918739489 |
+2
-1
@@ -3,8 +3,9 @@ uvicorn
|
||||
python-multipart
|
||||
python-dotenv
|
||||
httpx
|
||||
httpcore>=1.0.9,<2.0
|
||||
pydantic>=2.13.4
|
||||
pydantic-settings>=2.14.1
|
||||
pydantic-settings>=2.14.2
|
||||
SQLAlchemy
|
||||
pypdf
|
||||
beautifulsoup4
|
||||
|
||||
@@ -1359,9 +1359,11 @@ def setup_chat_routes(
|
||||
elif chunk.startswith("event: "):
|
||||
yield chunk
|
||||
elif chunk == "data: [DONE]\n\n":
|
||||
if full_response:
|
||||
_has_tool_events = bool((last_metrics or {}).get("tool_events"))
|
||||
if full_response or _has_tool_events:
|
||||
_response_to_save = full_response or "Done."
|
||||
_saved_id = save_assistant_response(
|
||||
sess, session_manager, session, full_response, last_metrics,
|
||||
sess, session_manager, session, _response_to_save, last_metrics,
|
||||
character_name=ctx.preset.character_name,
|
||||
web_sources=web_sources,
|
||||
rag_sources=ctx.rag_sources,
|
||||
@@ -1371,7 +1373,7 @@ def setup_chat_routes(
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||
run_post_response_tasks(
|
||||
sess, session_manager, session, message, full_response,
|
||||
sess, session_manager, session, message, _response_to_save,
|
||||
last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
|
||||
@@ -558,6 +558,18 @@ def _bash_squote(v: str) -> str:
|
||||
return v.replace("'", "'\\''")
|
||||
|
||||
|
||||
# Shown by generated runner scripts when the ollama binary is missing on the
|
||||
# target host. Must stay free of backticks/$( ) and be emitted single-quoted:
|
||||
# an earlier version wrapped the install one-liner in backticks inside a
|
||||
# double-quoted echo, which bash executed as command substitution and ran the
|
||||
# system-wide installer (including on remote SSH hosts) instead of printing
|
||||
# the hint.
|
||||
OLLAMA_MISSING_HINT = (
|
||||
"ERROR: Ollama not found on this server. Install it from "
|
||||
"https://ollama.com/download or run: curl -fsSL https://ollama.com/install.sh | sh"
|
||||
)
|
||||
|
||||
|
||||
# Allow-list of binaries permitted as the leading token of `req.cmd` for /api/model/serve.
|
||||
# Anything else is rejected before the cmd is interpolated into a tmux/PowerShell wrapper.
|
||||
_SERVE_CMD_ALLOWLIST = {
|
||||
|
||||
@@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)
|
||||
from routes.cookbook_helpers import (
|
||||
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
|
||||
_validate_local_dir, _validate_gpus, _shell_path,
|
||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase,
|
||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
|
||||
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
|
||||
load_stored_hf_token,
|
||||
@@ -1861,7 +1861,10 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(' exec 3<&-; exec 3>&-')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('if ! command -v ollama &>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: Ollama not found on this server. Install it from https://ollama.com/download or `curl -fsSL https://ollama.com/install.sh | sh`."')
|
||||
# Single-quoted on purpose: backticks inside a double-quoted
|
||||
# echo are command substitution, and this line used to run the
|
||||
# curl|sh installer on the target host instead of printing it.
|
||||
runner_lines.append(f" echo '{_bash_squote(OLLAMA_MISSING_HINT)}'")
|
||||
runner_lines.append(' echo')
|
||||
runner_lines.append(' echo "=== Process exited with code 127 ==="')
|
||||
runner_lines.append(' exec bash -i')
|
||||
|
||||
@@ -1273,10 +1273,15 @@ def _imap_move(uid, dest, src="INBOX", account_id: str | None = None, owner: str
|
||||
try:
|
||||
c = _imap_connect(account_id, owner=owner)
|
||||
c.select(_q(src))
|
||||
status, _ = c.copy(uid, _q(dest))
|
||||
# Callers pass a real IMAP UID (from conn.uid("SEARCH", ...)). copy()
|
||||
# and store() operate on message SEQUENCE NUMBERS, so addressing them
|
||||
# with a UID moved/deleted the wrong message (or silently no-oped when
|
||||
# the UID exceeded the message count). Use the UID commands, matching
|
||||
# the move/delete path in email_routes.py.
|
||||
status, _ = c.uid("COPY", uid, _q(dest))
|
||||
if status != "OK":
|
||||
return False
|
||||
c.store(uid, "+FLAGS", "\\Deleted")
|
||||
c.uid("STORE", uid, "+FLAGS", "\\Deleted")
|
||||
c.expunge()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -2388,6 +2388,8 @@ def setup_email_routes():
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""Read email body. Cached for 30m, sync IMAP work runs in a thread."""
|
||||
mark_seen = True if mark_seen is True or str(mark_seen).lower() == "true" else False
|
||||
full = True if full is True or str(full).lower() == "true" else False
|
||||
fixture_result = _fixture_email_read(uid, folder, owner)
|
||||
if fixture_result is not None:
|
||||
return fixture_result
|
||||
|
||||
@@ -218,6 +218,8 @@ def _default_endpoint_needs_assignment(
|
||||
return True
|
||||
if current_default_id not in enabled_endpoint_ids:
|
||||
return True
|
||||
if current_default_endpoint is None:
|
||||
return False
|
||||
if not (current_default_model or "").strip():
|
||||
return True
|
||||
visible = _endpoint_visible_model_ids(current_default_endpoint)
|
||||
|
||||
@@ -20,6 +20,26 @@ from src.constants import DEEP_RESEARCH_DIR
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
|
||||
def _confine_research_path(session_id: str) -> Path:
|
||||
"""Return the resolved Path for session_id's JSON inside DEEP_RESEARCH_DIR.
|
||||
|
||||
Validates the session ID format and asserts containment after symlink
|
||||
expansion. Raises HTTPException(400) on format failures, traversal
|
||||
attempts, absolute-path injection, and symlink escape so every caller
|
||||
gets a safe, confined path with no extra validation needed.
|
||||
"""
|
||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||
raise HTTPException(400, "Invalid session ID")
|
||||
root = Path(DEEP_RESEARCH_DIR).resolve()
|
||||
candidate = (root / f"{session_id}.json").resolve()
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid session ID")
|
||||
return candidate
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model-name substrings that are NOT chat/generation models — research must
|
||||
@@ -183,7 +203,10 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
if entry is not None:
|
||||
return entry.get("owner", "") == user
|
||||
# Task no longer in memory — check the persisted JSON.
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
try:
|
||||
path = _confine_research_path(session_id)
|
||||
except HTTPException:
|
||||
return False
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
@@ -247,7 +270,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
def _assert_owns_research(session_id: str, user: str) -> None:
|
||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||
Use BEFORE returning any data or mutating the file."""
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
path = _confine_research_path(session_id)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
@@ -361,7 +384,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
summary, stats — used by the Library preview panel."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
path = _confine_research_path(session_id)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
@@ -378,7 +401,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
path = _confine_research_path(session_id)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
@@ -398,8 +421,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
"""Delete a research result from disk."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||
json_path = data_dir / f"{session_id}.json"
|
||||
json_path = _confine_research_path(session_id)
|
||||
deleted = False
|
||||
if json_path.exists():
|
||||
# SECURITY: verify ownership before letting the caller delete it.
|
||||
@@ -561,7 +583,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
result = research_handler.get_result(session_id)
|
||||
if result is None:
|
||||
p = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
p = _confine_research_path(session_id)
|
||||
if p.exists():
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
return {
|
||||
@@ -601,7 +623,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
query = ""
|
||||
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
path = _confine_research_path(session_id)
|
||||
if path.exists():
|
||||
try:
|
||||
disk = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
+10
-10
@@ -162,7 +162,7 @@ def _persist_session_headers(session_id: str, headers: dict | None) -> None:
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.headers = headers or {}
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
@@ -223,8 +223,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
# purge exists only to catch ghosts the frontend missed (tab close,
|
||||
# crash). Only clean up rows old enough to be definitely orphaned.
|
||||
try:
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
_cutoff = _dt.utcnow() - _td(minutes=10)
|
||||
from datetime import timedelta as _td
|
||||
_cutoff = utcnow_naive() - _td(minutes=10)
|
||||
_purge_db = SessionLocal()
|
||||
try:
|
||||
from core.database import ChatMessage as _DbMsg
|
||||
@@ -470,7 +470,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
|
||||
if db_session:
|
||||
db_session.folder = folder if folder else None
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
result["folder"] = folder if folder else None
|
||||
finally:
|
||||
@@ -517,7 +517,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
db_session.model = model
|
||||
db_session.endpoint_url = endpoint_url
|
||||
db_session.headers = session.headers or {}
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -646,7 +646,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
|
||||
if db_session:
|
||||
db_session.archived = True
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
|
||||
# Update in memory if it exists
|
||||
@@ -680,7 +680,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
if not db_session:
|
||||
raise HTTPException(404, f"Session {sid} not found")
|
||||
db_session.archived = False
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
# Reload into session manager so it appears in the active list
|
||||
try:
|
||||
@@ -890,7 +890,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.is_important = important
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
|
||||
# Update in memory if it exists
|
||||
@@ -979,7 +979,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
metadata={
|
||||
"compacted": True,
|
||||
"summarized_count": len(older),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"timestamp": utcnow_naive().isoformat(),
|
||||
},
|
||||
)
|
||||
new_history = [summary_msg] + recent
|
||||
@@ -1256,7 +1256,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
db_session = db_session_q.first()
|
||||
if db_session:
|
||||
db_session.folder = folder_name
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db_session.updated_at = utcnow_naive()
|
||||
updated += 1
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
|
||||
@@ -137,6 +137,8 @@ def setup_upload_routes(upload_handler):
|
||||
session_id: Optional[str] = Form(None),
|
||||
):
|
||||
"""Upload files with enhanced security and organization."""
|
||||
if not isinstance(session_id, str):
|
||||
session_id = None
|
||||
if not files:
|
||||
raise HTTPException(400, "No files uploaded")
|
||||
|
||||
|
||||
+206
-62
@@ -8,11 +8,13 @@ import os
|
||||
import re
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from typing import Iterable, List, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
import httpcore
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
|
||||
@@ -91,6 +93,148 @@ def _public_http_url(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_public_ips(url: str) -> list[ipaddress._BaseAddress]:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if host in ("localhost", "metadata", "metadata.google.internal"):
|
||||
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if _is_private_address(ip):
|
||||
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
|
||||
return [ip]
|
||||
except httpx.RequestError:
|
||||
raise
|
||||
except ValueError:
|
||||
pass
|
||||
addrs = _resolve_hostname_ips(host)
|
||||
if not addrs or any(_is_private_address(a) for a in addrs):
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
return addrs
|
||||
|
||||
|
||||
class _PinnedBackend(httpcore.NetworkBackend):
|
||||
"""Network backend that connects to a pre-resolved IP.
|
||||
|
||||
httpcore derives the TLS SNI and the ``Host`` header from the URL's
|
||||
origin, not from the host argument passed to ``connect_tcp``. So
|
||||
routing the TCP connect to a resolved IP while leaving the URL
|
||||
untouched keeps SNI / vhost behaviour correct and closes the
|
||||
DNS-rebinding TOCTOU between the SSRF check and the connect.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._ip = str(ip)
|
||||
self._real = httpcore.SyncBackend()
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options=None,
|
||||
):
|
||||
return self._real.connect_tcp(
|
||||
self._ip, port, timeout, local_address, socket_options
|
||||
)
|
||||
|
||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
return self._real.sleep(seconds)
|
||||
|
||||
|
||||
# Map httpcore exception classes to their httpx equivalents. Built
|
||||
# once at import time from the public exception classes; avoids any
|
||||
# import of httpx's private transport machinery. httpcore's
|
||||
# ``ConnectionNotAvailable`` is a pool-internal signal (the pool will
|
||||
# close and retry on its own) — we never expect to see it surface to
|
||||
# a transport caller, so it has no httpx counterpart here.
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ProxyError: httpx.ProxyError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedTransport(httpx.BaseTransport):
|
||||
"""Transport that pins every TCP connect to a pre-resolved IP.
|
||||
|
||||
Uses only the public ``httpcore`` and ``httpx`` APIs — no
|
||||
subclassing of ``httpx.HTTPTransport``, no reads of private
|
||||
``httpcore.ConnectionPool`` attributes, no imports from
|
||||
``httpx private transport internals``. The URL is passed through unchanged so SNI
|
||||
/ vhost work as if httpx had been given the hostname directly;
|
||||
only the TCP destination is pinned, closing the DNS-rebinding
|
||||
TOCTOU between the SSRF check and the connect.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=ssl.create_default_context(),
|
||||
http1=True,
|
||||
http2=http2,
|
||||
network_backend=_PinnedBackend(ip),
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self._pool.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
|
||||
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
httpcore_req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
try:
|
||||
httpcore_resp = self._pool.handle_request(httpcore_req)
|
||||
# Eager materialisation matches the original
|
||||
# ``response.text`` usage in fetch_webpage_content. The
|
||||
# sync pool's stream is a plain Iterable[bytes] despite
|
||||
# the httpcore type hint unioning the async variant.
|
||||
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
|
||||
return httpx.Response(
|
||||
status_code=httpcore_resp.status,
|
||||
headers=httpcore_resp.headers,
|
||||
content=content,
|
||||
extensions=httpcore_resp.extensions,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
class BodyTooLargeError(Exception):
|
||||
"""The server declared a body larger than the hard fetch ceiling."""
|
||||
|
||||
@@ -141,78 +285,78 @@ class _CappedFetch:
|
||||
|
||||
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
|
||||
max_bytes: int = None) -> "_CappedFetch":
|
||||
"""Capped streaming GET with SSRF-guarded manual redirects.
|
||||
"""Capped streaming GET with SSRF-guarded, DNS-pinned manual redirects.
|
||||
|
||||
The body is streamed and buffering stops at ``max_bytes`` (default: the
|
||||
soft cap), so an oversized resource cannot be pulled into memory or the
|
||||
content cache in full. When Content-Length already declares a body over
|
||||
the hard ceiling, the fetch is refused before any body bytes are read.
|
||||
Each hop is resolved once, validated as public, and then the actual TCP
|
||||
connection is pinned to that resolved IP. The request URL is left unchanged
|
||||
so Host and TLS SNI keep the original hostname.
|
||||
"""
|
||||
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
if not _public_http_url(current):
|
||||
raise httpx.RequestError("Blocked private/internal URL", request=httpx.Request("GET", current))
|
||||
ips = _resolve_public_ips(current)
|
||||
|
||||
# Force identity transfer-encoding. With gzip/deflate the wire bytes
|
||||
# (and Content-Length) can be a small fraction of the decoded body, so
|
||||
# a tiny compressed response could pass the hard-cap preflight and then
|
||||
# expand past the ceiling in a single decoded chunk before the streamed
|
||||
# cap below can slice it. Identity makes Content-Length the true body
|
||||
# size and keeps each streamed chunk bounded by the network read.
|
||||
# and Content-Length can be a small fraction of the decoded body, so a
|
||||
# tiny compressed response could pass the hard-cap preflight and then
|
||||
# expand past the ceiling in one decoded chunk before the streamed cap
|
||||
# below can slice it.
|
||||
req_headers = dict(headers or {})
|
||||
req_headers["Accept-Encoding"] = "identity"
|
||||
with httpx.stream("GET", current, headers=req_headers, timeout=timeout,
|
||||
follow_redirects=False) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
return _CappedFetch(response.status_code, response.headers, b"",
|
||||
False, None, response.encoding, str(response.url))
|
||||
current = urljoin(str(response.url), location)
|
||||
continue
|
||||
|
||||
# A server can ignore the identity request and still return a
|
||||
# compressed body; httpx.iter_bytes would then decode it, and a tiny
|
||||
# gzip can balloon into one decoded chunk far past the cap before we
|
||||
# slice. Refuse a compressed Content-Encoding so the streamed cap
|
||||
# stays a real memory bound (Content-Length is the compressed wire
|
||||
# length here, so the preflight and size metadata are unreliable too).
|
||||
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||
if enc and enc != "identity":
|
||||
raise httpx.RequestError(
|
||||
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
||||
"requesting identity: cannot bound decoded body size",
|
||||
request=httpx.Request("GET", current),
|
||||
)
|
||||
with httpx.Client(
|
||||
headers=req_headers,
|
||||
timeout=timeout,
|
||||
follow_redirects=False,
|
||||
transport=_PinnedTransport(ips[0]),
|
||||
) as client:
|
||||
with client.stream("GET", current) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
return _CappedFetch(response.status_code, response.headers, b"",
|
||||
False, None, response.encoding, str(response.url))
|
||||
current = urljoin(str(response.url), location)
|
||||
continue
|
||||
|
||||
declared = None
|
||||
raw_len = response.headers.get("content-length")
|
||||
if raw_len and raw_len.isdigit():
|
||||
declared = int(raw_len)
|
||||
# Refuse before buffering anything when the server already tells
|
||||
# us the body exceeds the absolute ceiling (Content-Length is wire
|
||||
# bytes; the decompressed body can only be larger).
|
||||
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||
raise BodyTooLargeError(current, declared)
|
||||
# A server can ignore the identity request and still return a
|
||||
# compressed body; httpx.iter_bytes would then decode it, and a
|
||||
# tiny gzip can balloon into one decoded chunk far past the cap.
|
||||
# Refuse compressed Content-Encoding so the streamed cap stays
|
||||
# a real memory bound.
|
||||
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||
if enc and enc != "identity":
|
||||
raise httpx.RequestError(
|
||||
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
||||
"requesting identity: cannot bound decoded body size",
|
||||
request=httpx.Request("GET", current),
|
||||
)
|
||||
|
||||
declared = None
|
||||
raw_len = response.headers.get("content-length")
|
||||
if raw_len and raw_len.isdigit():
|
||||
declared = int(raw_len)
|
||||
|
||||
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||
raise BodyTooLargeError(current, declared)
|
||||
|
||||
chunks = []
|
||||
read = 0
|
||||
truncated = False
|
||||
for chunk in response.iter_bytes():
|
||||
read += len(chunk)
|
||||
if read > cap:
|
||||
keep = cap - (read - len(chunk))
|
||||
if keep > 0:
|
||||
chunks.append(chunk[:keep])
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
|
||||
return _CappedFetch(response.status_code, response.headers,
|
||||
b"".join(chunks), truncated, declared,
|
||||
response.encoding, str(response.url))
|
||||
|
||||
chunks = []
|
||||
read = 0
|
||||
truncated = False
|
||||
# We requested identity above, so iter_bytes yields the raw body in
|
||||
# network-read-sized chunks (no decompression expansion); the cap
|
||||
# therefore bounds what we actually buffer.
|
||||
for chunk in response.iter_bytes():
|
||||
read += len(chunk)
|
||||
if read > cap:
|
||||
keep = cap - (read - len(chunk))
|
||||
if keep > 0:
|
||||
chunks.append(chunk[:keep])
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
return _CappedFetch(response.status_code, response.headers,
|
||||
b"".join(chunks), truncated, declared,
|
||||
response.encoding, str(response.url))
|
||||
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
|
||||
|
||||
# PDF extraction (optional dependency)
|
||||
|
||||
@@ -281,7 +281,13 @@ class LsTool:
|
||||
|
||||
class GlobTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
from src.tool_execution import (
|
||||
_SENSITIVE_BASENAMES,
|
||||
_is_sensitive_path,
|
||||
_resolve_tool_path,
|
||||
_resolve_search_root,
|
||||
_truncate,
|
||||
)
|
||||
args = {}
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
@@ -322,7 +328,11 @@ class GlobTool:
|
||||
) == nbase
|
||||
except ValueError:
|
||||
inside = False
|
||||
if inside and os.path.exists(cand):
|
||||
# A literal that names a deny-listed sensitive file (.env,
|
||||
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
||||
# otherwise glob would surface secret paths that read_file /
|
||||
# grep already refuse to touch.
|
||||
if inside and os.path.exists(cand) and not _is_sensitive_path(cand):
|
||||
return [cand], None
|
||||
# Literal not at exact path — fall through to walk so
|
||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||
@@ -334,11 +344,20 @@ class GlobTool:
|
||||
for dp, dns, fns in os.walk(base):
|
||||
# Prune skipped dirs before descending (unlike rglob which
|
||||
# descends first then filters — fatal on large node_modules).
|
||||
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
||||
# Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob
|
||||
# never enumerates the keys/tokens inside them.
|
||||
dns[:] = [
|
||||
d for d in dns
|
||||
if d not in _CODENAV_SKIP_DIRS and d not in _SENSITIVE_BASENAMES
|
||||
]
|
||||
for name in fns + dns:
|
||||
full = os.path.join(dp, name)
|
||||
rel = os.path.relpath(full, base).replace(os.sep, "/")
|
||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||
# known_hosts, …) the same way grep does.
|
||||
if _is_sensitive_path(os.path.realpath(full)):
|
||||
continue
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
except OSError:
|
||||
|
||||
+18
-8
@@ -71,25 +71,35 @@ _SENSITIVE_FILE_PATTERNS: tuple[str, ...] = (
|
||||
"known_hosts",
|
||||
)
|
||||
|
||||
# Case-folded views used for matching. On a case-insensitive filesystem
|
||||
# (Windows, default macOS) ".SSH/AUTHORIZED_KEYS" and ".env" resolve to the
|
||||
# same protected files as their lowercase forms, so the deny-list has to fold
|
||||
# case before comparing — the sibling resolver already normcases paths for the
|
||||
# same reason. casefold (not os.path.normcase) because normcase is a no-op on
|
||||
# POSIX, which is exactly where the macOS read-exfil path lives.
|
||||
_SENSITIVE_BASENAMES_CF: frozenset[str] = frozenset(b.casefold() for b in _SENSITIVE_BASENAMES)
|
||||
_SENSITIVE_FILE_PATTERNS_CF: frozenset[str] = frozenset(p.casefold() for p in _SENSITIVE_FILE_PATTERNS)
|
||||
|
||||
|
||||
def _is_sensitive_path(resolved: str) -> bool:
|
||||
"""Return True if *resolved* falls under a sensitive directory or
|
||||
matches a sensitive filename — regardless of what root it sits under.
|
||||
|
||||
Matching is case-insensitive: on Windows / default macOS a case-variant
|
||||
name (``.SSH``, ``AUTHORIZED_KEYS``, ``Id_Rsa``) points at the same file as
|
||||
the lowercase form, so a case-sensitive check would let it slip past the
|
||||
deny-list in every file tool that relies on it.
|
||||
"""
|
||||
parts = resolved.split(os.sep)
|
||||
filenames: set[str] = {parts[-1]} if parts else set()
|
||||
parts = [p.casefold() for p in resolved.split(os.sep)]
|
||||
filename = parts[-1] if parts else ""
|
||||
|
||||
# Check if any path component is a sensitive directory.
|
||||
for part in parts:
|
||||
if part in _SENSITIVE_BASENAMES:
|
||||
if part in _SENSITIVE_BASENAMES_CF:
|
||||
return True
|
||||
|
||||
# Check filename against known sensitive files.
|
||||
for pat in _SENSITIVE_FILE_PATTERNS:
|
||||
if pat in filenames:
|
||||
return True
|
||||
|
||||
return False
|
||||
return filename in _SENSITIVE_FILE_PATTERNS_CF
|
||||
|
||||
|
||||
def _tool_path_roots() -> list[str]:
|
||||
|
||||
@@ -254,6 +254,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"calendar_href": ev.calendar_id,
|
||||
"event_type": ev.event_type or "",
|
||||
"importance": ev.importance or "normal",
|
||||
"rrule": ev.rrule or "",
|
||||
})
|
||||
if not events:
|
||||
response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}."
|
||||
@@ -268,6 +269,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
line += f" #{ev['event_type']}"
|
||||
if ev.get("importance") and ev["importance"] != "normal":
|
||||
line += f" !{ev['importance']}"
|
||||
if ev.get("rrule"):
|
||||
line += f" repeats({ev['rrule']})"
|
||||
if ev.get("location"):
|
||||
line += f" @ {ev['location']}"
|
||||
if ev.get("calendar"):
|
||||
@@ -480,6 +483,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev.event_type = _tag or None
|
||||
if args.get("importance") is not None:
|
||||
ev.importance = args["importance"]
|
||||
if args.get("rrule") is not None:
|
||||
ev.rrule = args.get("rrule") or ""
|
||||
elif str(args.get("repeat") or "").strip().lower() in {"none", "no", "off", "false", "single"}:
|
||||
ev.rrule = ""
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
ev.caldav_sync_pending = "update"
|
||||
|
||||
@@ -4051,6 +4051,7 @@ function startOdysseusApp() {
|
||||
|
||||
// Non-critical: load in parallel, resolve silently
|
||||
modelsModule.refreshModels(false).then(() => {
|
||||
try { sessionModule.updateModelPicker(); } catch (_) {}
|
||||
const modelsBox = document.getElementById('models');
|
||||
const hasModels = modelsBox && modelsBox.querySelector('.models-row');
|
||||
if (!hasModels) {
|
||||
|
||||
@@ -754,6 +754,18 @@ export function updateModelPicker() {
|
||||
// silently pre-populate the chatbox of the next user that signed in. If
|
||||
// we have no session model and no pending-chat pick, fall through to
|
||||
// the "Select model" placeholder below.
|
||||
//
|
||||
// But if the server model cache already has an online endpoint, make the
|
||||
// same safe fallback visible in the picker immediately. The send path can
|
||||
// already resolve a usable model; the UI should not sit on "Select model"
|
||||
// and make it look broken.
|
||||
if (!modelId && !currentSessionId && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
const fallback = _firstAvailableModel();
|
||||
if (fallback) {
|
||||
_deps.setPendingChat(fallback);
|
||||
modelId = fallback.modelId;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if selected model is still available — fall back ONLY for pending chats with no user selection
|
||||
// Never override an existing session's model — the user explicitly chose it
|
||||
|
||||
@@ -542,6 +542,9 @@ async function initDefaultChat() {
|
||||
renderFallbacks();
|
||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
|
||||
modelSel.addEventListener('change', saveDefault);
|
||||
|
||||
async function saveDefault() {
|
||||
try {
|
||||
var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
|
||||
@@ -558,8 +561,6 @@ async function initDefaultChat() {
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
|
||||
modelSel.addEventListener('change', saveDefault);
|
||||
if (addFbBtn) addFbBtn.addEventListener('click', function() {
|
||||
var first = enabledEndpoints()[0];
|
||||
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
|
||||
|
||||
+1
-1
@@ -1017,7 +1017,7 @@ function _showTaskDropdown(anchor, items) {
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'task-dropdown';
|
||||
dd._anchor = anchor;
|
||||
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
||||
dd.style.cssText = `position:fixed;z-index:${topPortalZ()};background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;`;
|
||||
items.forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;';
|
||||
|
||||
@@ -39695,3 +39695,13 @@ body.theme-frosted .modal {
|
||||
.log-line-default {
|
||||
color: var(--fg, #9cdef2);
|
||||
}
|
||||
|
||||
/* The model-comparison grid hard-codes 2-4 equal columns with no phone
|
||||
breakpoint that stacks them, so at 390px two models render ~178px columns and
|
||||
four render ~88px columns. Each column is a full scrolling chat (code blocks,
|
||||
tool output, vote footer), so the text is unreadably over-wrapped and clipped.
|
||||
On phones, stack the panes into a single scrollable column instead. */
|
||||
@media (max-width: 768px) {
|
||||
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
|
||||
.compare-pane { min-height: 60dvh; }
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_expand_non_recurring_returns_single():
|
||||
|
||||
|
||||
def test_expand_rrule_skips_deleted_occurrence_exdate():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
dtstart=datetime(2026, 7, 1, 14, 0),
|
||||
dtend=datetime(2026, 7, 1, 15, 0),
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_local_windows_platform_comes_from_backend_host_state():
|
||||
assert "platform: _envState.hostPlatform || _envState.platform || ''" not in text
|
||||
assert 'return "windows" if IS_WINDOWS else ""' in routes
|
||||
assert 'env["hostPlatform"] = _client_host_platform()' in routes
|
||||
assert "return _state_for_client({})" in routes
|
||||
assert "client_state = _state_for_client({})" in routes
|
||||
assert 'env.pop("hostPlatform", None)' in routes
|
||||
assert "delete env.hostPlatform;" in running
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ def test_windows_session_commands_use_shared_powershell_wrapper_and_local_log_di
|
||||
assert "host ? '$env:TEMP\\\\odysseus-sessions' : '$env:TEMP\\\\odysseus-tmux'" in source
|
||||
assert "function _winPowerShellCmd(task, ps)" in source
|
||||
assert "const command = `powershell -Command \"${ps}\"`;" in source
|
||||
assert "if (!task.remoteHost) return command;" in source
|
||||
assert "return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(command)}`;" in source
|
||||
assert "if (!host) return command;" in source
|
||||
assert "return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(command)}`;" in source
|
||||
|
||||
|
||||
def test_dep_install_success_recognized_from_exit_sentinel():
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""_imap_move must address messages by UID, not sequence number.
|
||||
|
||||
The auto-spam poller passes a real IMAP UID (from conn.uid("SEARCH", ...))
|
||||
to _imap_move, but the function used conn.copy()/conn.store(), which operate
|
||||
on message SEQUENCE NUMBERS. So a UID like 90521 was interpreted as sequence
|
||||
number 90521 — moving/deleting the wrong message or silently no-oping. It
|
||||
must use the UID commands.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_helpers(monkeypatch, tmp_path):
|
||||
# Keep _init_scheduled_db (run at import) off the real data dir.
|
||||
monkeypatch.setenv("ODYSSEUS_DATA_DIR", str(tmp_path))
|
||||
import routes.email_helpers as eh
|
||||
return eh
|
||||
|
||||
|
||||
class _FakeIMAP:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def select(self, mbox):
|
||||
self.calls.append(("select", mbox)); return ("OK", [b""])
|
||||
|
||||
def copy(self, *a):
|
||||
self.calls.append(("copy",) + a); return ("OK", [b""])
|
||||
|
||||
def store(self, *a):
|
||||
self.calls.append(("store",) + a); return ("OK", [b""])
|
||||
|
||||
def uid(self, *a):
|
||||
self.calls.append(("uid",) + a); return ("OK", [b""])
|
||||
|
||||
def expunge(self):
|
||||
self.calls.append(("expunge",)); return ("OK", [b""])
|
||||
|
||||
def logout(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_move_uses_uid_commands_not_seqnum(email_helpers, monkeypatch):
|
||||
fake = _FakeIMAP()
|
||||
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **k: fake)
|
||||
ok = email_helpers._imap_move(b"90521", "Spam", src="INBOX")
|
||||
assert ok is True
|
||||
verbs = [c[0] for c in fake.calls]
|
||||
uid_ops = [c[1] for c in fake.calls if c[0] == "uid"]
|
||||
assert "COPY" in uid_ops and "STORE" in uid_ops
|
||||
# the sequence-number commands must NOT be used to address a UID
|
||||
assert "copy" not in verbs
|
||||
assert "store" not in verbs
|
||||
@@ -0,0 +1,57 @@
|
||||
"""The generated Ollama runner must print the install hint, not execute it.
|
||||
|
||||
The runner script emitted by /api/model/serve contained:
|
||||
|
||||
echo "ERROR: Ollama not found ... or `curl -fsSL .../install.sh | sh`."
|
||||
|
||||
Backticks inside double quotes are bash command substitution, so on any host
|
||||
without ollama the script downloaded and ran the system-wide installer
|
||||
(including remote SSH serve targets) instead of printing the hint. The hint
|
||||
now lives in OLLAMA_MISSING_HINT, contains no substitution tokens, and is
|
||||
emitted single-quoted.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from routes.cookbook_helpers import OLLAMA_MISSING_HINT, _bash_squote
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def test_hint_has_no_shell_expansion_tokens():
|
||||
assert "`" not in OLLAMA_MISSING_HINT
|
||||
assert "$(" not in OLLAMA_MISSING_HINT
|
||||
|
||||
|
||||
def test_hint_still_tells_the_user_how_to_install():
|
||||
assert "https://ollama.com/download" in OLLAMA_MISSING_HINT
|
||||
assert "install.sh" in OLLAMA_MISSING_HINT
|
||||
|
||||
|
||||
def test_no_runner_echo_line_uses_backticks_in_double_quotes():
|
||||
# Source-level guard: generated-script echo lines must never carry
|
||||
# backticks inside a double-quoted bash string again.
|
||||
src = open(os.path.join(ROOT, "routes", "cookbook_routes.py"), encoding="utf-8").read()
|
||||
offenders = [
|
||||
line.strip()
|
||||
for line in src.splitlines()
|
||||
if "append(" in line and 'echo "' in line and "`" in line.split('echo "', 1)[1]
|
||||
]
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_single_quoted_echo_prints_hint_literally():
|
||||
bash = shutil.which("bash")
|
||||
if not bash:
|
||||
pytest.skip("bash not available")
|
||||
out = subprocess.run(
|
||||
[bash, "-c", f"echo '{_bash_squote(OLLAMA_MISSING_HINT)}'"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert out.returncode == 0
|
||||
assert out.stdout.strip() == OLLAMA_MISSING_HINT
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Path-confinement regression tests for research routes.
|
||||
|
||||
Covers the CodeQL py/path-injection alert cluster (#552-#567) in
|
||||
routes/research/research_routes.py:
|
||||
- _owns_in_memory disk fallback (alerts #552, #553)
|
||||
- _assert_owns_research (alerts #554, #555)
|
||||
- research_detail (alerts #556, #557)
|
||||
- research_archive (alerts #558, #559, #560)
|
||||
- research_delete (alerts #561, #562, #563)
|
||||
- research_result_peek (alerts #564, #565)
|
||||
- research_spinoff (alerts #566, #567)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routes.research_routes import setup_research_routes
|
||||
from routes.research.research_routes import _confine_research_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _redirect_research_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"routes.research_routes.DEEP_RESEARCH_DIR",
|
||||
str(tmp_path / "deep_research"),
|
||||
)
|
||||
|
||||
|
||||
def _request(user: str):
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||
|
||||
|
||||
def _route(router, path: str, method: str):
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") != path:
|
||||
continue
|
||||
if method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route not registered")
|
||||
|
||||
|
||||
def _write_research(data_dir, session_id: str, **data):
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = data_dir / f"{session_id}.json"
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _research_handler():
|
||||
handler = MagicMock()
|
||||
handler._active_tasks = {}
|
||||
return handler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper-level tests — _confine_research_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_confine_allows_valid_session_id(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "deep_research"
|
||||
data_dir.mkdir()
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
path = _confine_research_path("rp-abc123de4567")
|
||||
assert path == (data_dir / "rp-abc123de4567.json").resolve()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", [
|
||||
"../escape",
|
||||
"../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"safe/../../x",
|
||||
"",
|
||||
"rp_bad", # underscore not in allowed charset
|
||||
"rp-bad.json", # dot not in allowed charset
|
||||
"a" * 129, # exceeds length limit
|
||||
])
|
||||
def test_confine_rejects_bad_session_ids(tmp_path, monkeypatch, bad_id):
|
||||
data_dir = tmp_path / "deep_research"
|
||||
data_dir.mkdir()
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_confine_research_path(bad_id)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_confine_rejects_symlink_escape(tmp_path, monkeypatch):
|
||||
"""A symlink inside DEEP_RESEARCH_DIR that resolves outside is rejected."""
|
||||
data_dir = tmp_path / "deep_research"
|
||||
outside = tmp_path / "outside"
|
||||
data_dir.mkdir()
|
||||
outside.mkdir()
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
target = outside / "rp-linktest1234.json"
|
||||
target.write_text("{}", encoding="utf-8")
|
||||
link = data_dir / "rp-linktest1234.json"
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except (AttributeError, NotImplementedError, OSError) as e:
|
||||
pytest.skip(f"symlinks unavailable: {e}")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_confine_research_path("rp-linktest1234")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-level tests — valid paths work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detail_returns_data_for_owner(tmp_path):
|
||||
data_dir = tmp_path / "deep_research"
|
||||
_write_research(data_dir, "rp-validid12345", owner="alice", query="valid query")
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/detail/{session_id}", "GET")
|
||||
out = asyncio.run(target(session_id="rp-validid12345", request=_request("alice")))
|
||||
assert out["query"] == "valid query"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-level tests — traversal and injection rejected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TRAVERSAL_IDS = [
|
||||
"../escape",
|
||||
"../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"safe/../../x",
|
||||
"rp_under",
|
||||
"a" * 129,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
|
||||
def test_detail_rejects_traversal(bad_id):
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/detail/{session_id}", "GET")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id=bad_id, request=_request("alice")))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
|
||||
def test_archive_rejects_traversal(bad_id):
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/{session_id}/archive", "POST")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id=bad_id, request=_request("alice"), archived=True))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
|
||||
def test_delete_rejects_traversal(bad_id):
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/{session_id}", "DELETE")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id=bad_id, request=_request("alice")))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-level tests — traversal does not touch files outside DEEP_RESEARCH_DIR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_traversal_does_not_delete_outside_file(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "deep_research"
|
||||
data_dir.mkdir(parents=True)
|
||||
outside = tmp_path / "sensitive.json"
|
||||
outside.write_text('{"secret": true}', encoding="utf-8")
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/{session_id}", "DELETE")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id="../sensitive", request=_request("alice")))
|
||||
assert exc.value.status_code == 400
|
||||
assert outside.exists(), "file outside DEEP_RESEARCH_DIR must not be deleted"
|
||||
|
||||
|
||||
def test_archive_traversal_does_not_mutate_outside_file(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "deep_research"
|
||||
data_dir.mkdir(parents=True)
|
||||
outside = tmp_path / "sensitive.json"
|
||||
outside.write_text('{"owner": "alice", "archived": false}', encoding="utf-8")
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/{session_id}/archive", "POST")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id="../sensitive", request=_request("alice"), archived=True))
|
||||
assert exc.value.status_code == 400
|
||||
data = json.loads(outside.read_text(encoding="utf-8"))
|
||||
assert data["archived"] is False, "file outside DEEP_RESEARCH_DIR must not be mutated"
|
||||
|
||||
|
||||
def test_detail_traversal_does_not_read_outside_file(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "deep_research"
|
||||
data_dir.mkdir(parents=True)
|
||||
outside = tmp_path / "sensitive.json"
|
||||
outside.write_text('{"owner": "alice", "result": "secret data"}', encoding="utf-8")
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/detail/{session_id}", "GET")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id="../sensitive", request=_request("alice")))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-level symlink escape test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detail_rejects_symlink_escape(tmp_path, monkeypatch):
|
||||
"""research_detail rejects a confined-format ID whose JSON is a symlink to outside."""
|
||||
data_dir = tmp_path / "deep_research"
|
||||
outside_dir = tmp_path / "outside"
|
||||
data_dir.mkdir(parents=True)
|
||||
outside_dir.mkdir()
|
||||
outside_file = outside_dir / "rp-linktest5678.json"
|
||||
outside_file.write_text(
|
||||
json.dumps({"owner": "alice", "result": "secret"}), encoding="utf-8"
|
||||
)
|
||||
link = data_dir / "rp-linktest5678.json"
|
||||
try:
|
||||
link.symlink_to(outside_file)
|
||||
except (AttributeError, NotImplementedError, OSError) as e:
|
||||
pytest.skip(f"symlinks unavailable: {e}")
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/detail/{session_id}", "GET")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id="rp-linktest5678", request=_request("alice")))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner/session scoping cannot escape root
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_owner_scoped_paths_stay_within_research_root(tmp_path, monkeypatch):
|
||||
"""Owner-scoped session IDs never produce paths outside DEEP_RESEARCH_DIR."""
|
||||
data_dir = tmp_path / "deep_research"
|
||||
data_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
|
||||
|
||||
root = data_dir.resolve()
|
||||
for session_id in ("rp-abc123456789", "rp-000000000001", "abc-xyz-123"):
|
||||
path = _confine_research_path(session_id)
|
||||
assert path.resolve().is_relative_to(root), (
|
||||
f"{session_id!r} produced path outside research root: {path}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
|
||||
def test_result_peek_rejects_traversal(bad_id):
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/result-peek/{session_id}", "POST")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id=bad_id, request=_request("alice")))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
|
||||
def test_spinoff_rejects_traversal(bad_id):
|
||||
router = setup_research_routes(_research_handler())
|
||||
target = _route(router, "/api/research/spinoff/{session_id}", "POST")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(target(session_id=bad_id, request=_request("alice")))
|
||||
assert exc.value.status_code == 400
|
||||
@@ -894,7 +894,8 @@ def test_web_fetch_guard_fails_closed_on_empty_resolution(monkeypatch):
|
||||
|
||||
def test_web_fetch_guard_blocks_redirect_into_private(monkeypatch):
|
||||
# A public URL that 302-redirects to an internal address must be blocked
|
||||
# at the redirect hop, not followed.
|
||||
# at the redirect hop, not followed. _get_public_url now uses
|
||||
# httpx.Client(...).stream(...) so the test must mock that path.
|
||||
import httpx
|
||||
from src.search import content
|
||||
|
||||
@@ -905,14 +906,31 @@ def test_web_fetch_guard_blocks_redirect_into_private(monkeypatch):
|
||||
status_code = 302
|
||||
url = "http://public.example/start"
|
||||
headers = {"location": "http://169.254.169.254/latest/meta-data/"}
|
||||
encoding = "utf-8"
|
||||
|
||||
from contextlib import contextmanager
|
||||
class _FakeStream:
|
||||
def __enter__(self):
|
||||
return _Resp()
|
||||
|
||||
@contextmanager
|
||||
def _fake_stream(method, url, **kwargs):
|
||||
yield _Resp()
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(httpx, "stream", _fake_stream)
|
||||
class _FakeClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def stream(self, method, url):
|
||||
assert method == "GET"
|
||||
assert url == "http://public.example/start"
|
||||
return _FakeStream()
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _FakeClient)
|
||||
|
||||
with _pytest.raises(httpx.RequestError) as exc:
|
||||
content._get_public_url("http://public.example/start", headers={}, timeout=5)
|
||||
@@ -1224,3 +1242,274 @@ def test_visual_report_escapes_request_category():
|
||||
# value must coerce rather than crash the render (html.escape needs a str).
|
||||
out = generate_visual_report(question="q", report_markdown="## H", category=12345)
|
||||
assert "category-12345" in out
|
||||
|
||||
|
||||
# ── DNS rebinding (audit finding 8.1) ────────────────────────────────
|
||||
# _resolve_public_ips resolves a URL's hostname once per hop and rejects
|
||||
# private / metadata targets, but httpx would then re-resolve the
|
||||
# hostname at connect time. The fix: the actual TCP connect is pinned
|
||||
# to the resolved IP via a custom httpcore.NetworkBackend, while the
|
||||
# URL / Host header / SNI stay on the original hostname.
|
||||
|
||||
import ipaddress as _ipaddr
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
|
||||
def test_dns_rebinding_blocked_by_resolve_gate(monkeypatch):
|
||||
from src.search import content
|
||||
|
||||
monkeypatch.setattr(content, "_resolve_hostname_ips",
|
||||
lambda host: [_ipaddr.ip_address("10.0.0.5")])
|
||||
|
||||
with _pytest.raises(_httpx.RequestError) as exc:
|
||||
content._resolve_public_ips("https://attacker.example/")
|
||||
assert "non-public" in str(exc.value).lower()
|
||||
|
||||
|
||||
def test_dns_rebinding_pinned_backend_connects_to_resolved_ip(monkeypatch):
|
||||
"""``_PinnedBackend.connect_tcp`` must ignore the URL's host and
|
||||
dial the pinned IP at the original port. This is the core of the
|
||||
fix: httpcore's NetworkBackend contract lets us intercept the
|
||||
connect before DNS lookup happens.
|
||||
"""
|
||||
from src.search import content
|
||||
|
||||
pinned_ip = _ipaddr.ip_address("93.184.216.34")
|
||||
captured = {}
|
||||
|
||||
class _StubStream:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
class _StubBackend:
|
||||
def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None):
|
||||
captured["host"] = host
|
||||
captured["port"] = port
|
||||
return _StubStream()
|
||||
|
||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
raise OSError("not used")
|
||||
|
||||
def sleep(self, seconds):
|
||||
pass
|
||||
|
||||
backend = content._PinnedBackend(pinned_ip)
|
||||
monkeypatch.setattr(backend, "_real", _StubBackend())
|
||||
|
||||
backend.connect_tcp("attacker.example", 443)
|
||||
|
||||
assert captured["host"] == "93.184.216.34", captured
|
||||
assert captured["port"] == 443, captured
|
||||
|
||||
|
||||
def test_dns_rebinding_pinned_transport_dials_pinned_ip(monkeypatch):
|
||||
"""End-to-end: ``_PinnedTransport`` actually dials the pinned IP
|
||||
when given a hostname, with the original URL's Host header
|
||||
preserved. We stand up a local socket server on a free port and
|
||||
make the transport connect there via the pinned backend.
|
||||
"""
|
||||
from src.search import content
|
||||
import httpcore
|
||||
|
||||
# Stand up a TCP server that accepts one connection and records
|
||||
# the request bytes it received, then returns a minimal HTTP/1.1
|
||||
# response.
|
||||
captured = {"request": b""}
|
||||
server_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
||||
server_sock.bind(("127.0.0.1", 0))
|
||||
server_sock.listen(1)
|
||||
port = server_sock.getsockname()[1]
|
||||
|
||||
def serve_once():
|
||||
conn, _ = server_sock.accept()
|
||||
with conn:
|
||||
conn.settimeout(2.0)
|
||||
buf = b""
|
||||
try:
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
except _socket.timeout:
|
||||
pass
|
||||
captured["request"] = buf
|
||||
conn.sendall(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 2\r\n"
|
||||
b"Connection: close\r\n"
|
||||
b"\r\n"
|
||||
b"OK"
|
||||
)
|
||||
|
||||
t = _threading.Thread(target=serve_once, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Pin the transport to 127.0.0.1:<port>. The caller hands it a URL
|
||||
# with a fake hostname so we can verify the host header is sent
|
||||
# while the TCP connect goes to the pinned IP.
|
||||
pinned_ip = _ipaddr.ip_address("127.0.0.1")
|
||||
transport = content._PinnedTransport(pinned_ip)
|
||||
|
||||
req = _httpx.Request(
|
||||
"GET",
|
||||
f"http://attacker.test:{port}/path?q=1",
|
||||
headers={"host": "attacker.test"},
|
||||
)
|
||||
try:
|
||||
with _httpx.Client(transport=transport, timeout=5) as client:
|
||||
response = client.send(req)
|
||||
assert response.status_code == 200, response.text
|
||||
finally:
|
||||
server_sock.close()
|
||||
|
||||
t.join(timeout=2)
|
||||
|
||||
request_bytes = captured["request"]
|
||||
assert request_bytes, "server never received a request"
|
||||
# Host header is the original hostname, not the IP. (httpx
|
||||
# lowercases header names; compare case-insensitively.)
|
||||
headers_blob = request_bytes.lower()
|
||||
assert b"host: attacker.test" in headers_blob, request_bytes
|
||||
# The path was preserved.
|
||||
assert b"/path?q=1" in request_bytes, request_bytes
|
||||
|
||||
|
||||
def test_dns_rebinding_pinned_transport_preserves_url_netloc(monkeypatch):
|
||||
"""The URL the transport hands to the underlying httpcore layer
|
||||
must still be the original ``https://example.com/...`` — never
|
||||
rewritten to the pinned IP. SNI / vhost depend on this.
|
||||
"""
|
||||
from src.search import content
|
||||
|
||||
seen_url = {}
|
||||
|
||||
class _RecordingPool:
|
||||
def handle_request(self, req):
|
||||
seen_url["host"] = req.url.host.decode() if isinstance(req.url.host, bytes) else req.url.host
|
||||
seen_url["scheme"] = req.url.scheme.decode() if isinstance(req.url.scheme, bytes) else req.url.scheme
|
||||
seen_url["target"] = req.url.target.decode() if isinstance(req.url.target, bytes) else req.url.target
|
||||
raise _httpx.ConnectError("intercepted")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
pinned_ip = _ipaddr.ip_address("93.184.216.34")
|
||||
transport = content._PinnedTransport(pinned_ip)
|
||||
transport._pool = _RecordingPool()
|
||||
|
||||
req = _httpx.Request("GET", "https://example.com/some/path?q=1")
|
||||
with _pytest.raises(_httpx.ConnectError):
|
||||
transport.handle_request(req)
|
||||
|
||||
assert seen_url["host"] == "example.com", seen_url
|
||||
assert seen_url["scheme"] == "https", seen_url
|
||||
assert seen_url["target"] == "/some/path?q=1", seen_url
|
||||
|
||||
|
||||
def test_dns_rebinding_redirect_re_resolves_per_hop(monkeypatch):
|
||||
"""Every redirect hop must call ``_resolve_public_ips`` again.
|
||||
A redirect to a private-IP target must be blocked even when the
|
||||
first hop was public.
|
||||
"""
|
||||
from src.search import content
|
||||
|
||||
seen = []
|
||||
|
||||
def fake_resolve(url):
|
||||
seen.append(url)
|
||||
if "private" in url:
|
||||
raise _httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
return [_ipaddr.ip_address("93.184.216.34")]
|
||||
|
||||
monkeypatch.setattr(content, "_resolve_public_ips", fake_resolve)
|
||||
|
||||
class _Resp:
|
||||
status_code = 302
|
||||
headers = {"location": "http://private.example/secret"}
|
||||
encoding = "utf-8"
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
|
||||
def __enter__(self):
|
||||
return self.response
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def stream(self, method, url):
|
||||
assert method == "GET"
|
||||
return _FakeStream(_Resp(url))
|
||||
|
||||
monkeypatch.setattr(_httpx, "Client", _FakeClient)
|
||||
|
||||
with _pytest.raises(_httpx.RequestError) as exc:
|
||||
content._get_public_url("http://public.example/start", headers={}, timeout=5)
|
||||
assert "non-public" in str(exc.value).lower()
|
||||
# Both hops were validated.
|
||||
assert seen == ["http://public.example/start", "http://private.example/secret"], seen
|
||||
|
||||
|
||||
def test_dns_rebinding_transport_uses_public_apis(monkeypatch):
|
||||
"""Static guard: ``_PinnedTransport`` must use only the public
|
||||
``httpx.BaseTransport`` / ``httpcore`` APIs. No subclassing of
|
||||
``httpx.HTTPTransport`` (whose ``_pool`` slot we'd have to
|
||||
overwrite), no reads of private ``httpcore.ConnectionPool``
|
||||
attributes, and no imports from ``httpx._transports``.
|
||||
"""
|
||||
from src.search import content
|
||||
|
||||
import inspect
|
||||
|
||||
# 1) Subclass check: must be BaseTransport, not HTTPTransport.
|
||||
mro_names = [c.__name__ for c in content._PinnedTransport.__mro__]
|
||||
assert "BaseTransport" in mro_names, mro_names
|
||||
assert "HTTPTransport" not in mro_names, (
|
||||
"_PinnedTransport subclasses httpx.HTTPTransport. Subclass "
|
||||
"httpx.BaseTransport instead and build the pool from scratch "
|
||||
"with the public httpcore.ConnectionPool API."
|
||||
)
|
||||
|
||||
# 2) No reads of private httpcore.ConnectionPool attrs.
|
||||
src = inspect.getsource(content._PinnedTransport)
|
||||
forbidden = (
|
||||
"_ssl_context",
|
||||
"_max_connections",
|
||||
"_max_keepalive_connections",
|
||||
"_keepalive_expiry",
|
||||
"_http1",
|
||||
"_http2",
|
||||
"_network_backend",
|
||||
)
|
||||
leaked = [name for name in forbidden if name in src]
|
||||
assert not leaked, (
|
||||
f"_PinnedTransport reads private httpcore.ConnectionPool attrs: {leaked}. "
|
||||
"Build the pool from the public httpcore.ConnectionPool API instead."
|
||||
)
|
||||
|
||||
# 3) No imports from httpx's private transport module.
|
||||
module_src = inspect.getsource(content)
|
||||
forbidden_imports = ("from httpx._transports", "import httpx._transports")
|
||||
leaked_imports = [s for s in forbidden_imports if s in module_src]
|
||||
assert not leaked_imports, (
|
||||
f"content.py imports from httpx's private transport module: {leaked_imports}. "
|
||||
"Use only the public httpx and httpcore APIs."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Regression: session routes must not call datetime.utcnow() (#1116)."""
|
||||
|
||||
import inspect
|
||||
|
||||
import routes.session_routes as sr
|
||||
|
||||
|
||||
def test_session_routes_module_does_not_reference_utcnow():
|
||||
source = inspect.getsource(sr)
|
||||
assert "datetime.utcnow()" not in source
|
||||
assert "_dt.utcnow()" not in source
|
||||
@@ -57,6 +57,27 @@ def test_non_sensitive_path():
|
||||
assert not _is_sensitive_path("/home/user/projects/file.py")
|
||||
|
||||
|
||||
def test_sensitive_case_insensitive():
|
||||
"""On case-insensitive filesystems (Windows, default macOS) a case-variant
|
||||
name resolves to the same protected file, so the deny-list must match
|
||||
regardless of case. Built with os.path.join so the separator is right on
|
||||
both POSIX and Windows.
|
||||
"""
|
||||
from src.tool_execution import _is_sensitive_path
|
||||
# sensitive directory, varied case
|
||||
assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "authorized_keys"))
|
||||
assert _is_sensitive_path(os.path.join("home", "u", ".Gnupg", "pubring.kbx"))
|
||||
# sensitive filename, varied case
|
||||
assert _is_sensitive_path(os.path.join("ws", "AUTHORIZED_KEYS"))
|
||||
assert _is_sensitive_path(os.path.join("ws", "Id_Rsa"))
|
||||
assert _is_sensitive_path(os.path.join("ws", ".ENV"))
|
||||
assert _is_sensitive_path(os.path.join("ws", ".Env"))
|
||||
# both dir and file varied
|
||||
assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "AUTHORIZED_KEYS"))
|
||||
# an ordinary file with none of the sensitive names is still allowed
|
||||
assert not _is_sensitive_path(os.path.join("ws", "Readme.md"))
|
||||
|
||||
|
||||
# ── Unit tests on _resolve_tool_path ─────────────────────────────────
|
||||
|
||||
def test_blocks_etc_shadow():
|
||||
|
||||
@@ -17,7 +17,7 @@ SRC = Path(__file__).resolve().parent.parent / "static/js/fileHandler.js"
|
||||
|
||||
def _upload_pending_body() -> str:
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
start = text.index("export async function uploadPending()")
|
||||
start = text.index("export async function uploadPending(")
|
||||
rest = text[start:]
|
||||
m = re.search(r"\n(export |function )", rest[1:])
|
||||
return rest[: m.start() + 1] if m else rest
|
||||
|
||||
@@ -13,6 +13,57 @@ import pytest
|
||||
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES
|
||||
from services.search import content as content_mod
|
||||
|
||||
import pytest as _pytest_for_client_stream_compat
|
||||
|
||||
|
||||
@_pytest_for_client_stream_compat.fixture(autouse=True)
|
||||
def _client_stream_compat_for_pinned_fetch(monkeypatch):
|
||||
"""Adapt old size-cap tests to the current pinned Client.stream path.
|
||||
|
||||
These tests monkeypatch httpx.stream(...) to return fake responses. The
|
||||
production fetcher now uses httpx.Client(...).stream(...) so it can pass a
|
||||
pinned transport. When a test has replaced httpx.stream, route Client.stream
|
||||
through that fake. When it has not, fall back to a real Client so unrelated
|
||||
behavior in this file is not changed.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
real_client_cls = httpx.Client
|
||||
original_stream = httpx.stream
|
||||
|
||||
class _ClientProxy:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
self._real_cm = None
|
||||
self._real_client = None
|
||||
|
||||
def __enter__(self):
|
||||
if httpx.stream is original_stream:
|
||||
self._real_cm = real_client_cls(*self._args, **self._kwargs)
|
||||
self._real_client = self._real_cm.__enter__()
|
||||
return self._real_client
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
if self._real_cm is not None:
|
||||
return self._real_cm.__exit__(*args)
|
||||
return False
|
||||
|
||||
def stream(self, method, url):
|
||||
if self._real_client is not None:
|
||||
return self._real_client.stream(method, url)
|
||||
|
||||
kwargs = {
|
||||
"headers": self._kwargs.get("headers"),
|
||||
"timeout": self._kwargs.get("timeout"),
|
||||
"follow_redirects": self._kwargs.get("follow_redirects"),
|
||||
}
|
||||
return httpx.stream(method, url, **kwargs)
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _ClientProxy)
|
||||
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""Stands in for the httpx.stream(...) context manager."""
|
||||
|
||||
@@ -167,6 +167,38 @@ async def test_glob_confined_e2e(ws, admin):
|
||||
assert r["exit_code"] == 0 and "No files" in r["output"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glob_skips_sensitive_files_in_workspace(ws, admin):
|
||||
"""glob must not enumerate deny-listed sensitive files that live inside the
|
||||
workspace. read_file/write_file/edit_file refuse them and grep skips them,
|
||||
so glob surfacing their paths is an enumeration oracle for prompt-injection.
|
||||
"""
|
||||
with open(os.path.join(ws, "keep.py"), "w") as f:
|
||||
f.write("x")
|
||||
with open(os.path.join(ws, ".env"), "w") as f:
|
||||
f.write("AWS_SECRET=xxx")
|
||||
with open(os.path.join(ws, "id_rsa"), "w") as f: # non-dotfile key at root
|
||||
f.write("KEY")
|
||||
os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
|
||||
with open(os.path.join(ws, ".ssh", "authorized_keys"), "w") as f:
|
||||
f.write("ssh-rsa AAAA")
|
||||
|
||||
# A recursive wildcard returns ordinary files but none of the sensitive
|
||||
# ones. The pattern "**/*" contains no secret names, so a secret basename
|
||||
# appearing in the output is a real leak (not the echoed not-found pattern).
|
||||
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": "**/*"})), owner="a", workspace=ws)
|
||||
assert r["exit_code"] == 0
|
||||
assert "keep.py" in r["output"]
|
||||
for leak in (".env", "id_rsa", "authorized_keys"):
|
||||
assert leak not in r["output"], f"glob leaked sensitive file: {leak}"
|
||||
|
||||
# Directly targeting a sensitive file (literal fast-path and wildcard) must
|
||||
# come back as the not-found message, never a match with the file's path.
|
||||
for pat in (".env", "**/id_rsa", "**/authorized_keys"):
|
||||
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": pat})), owner="a", workspace=ws)
|
||||
assert r["exit_code"] == 0 and "No files" in r["output"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subprocess_cwd_is_workspace_e2e(ws, admin):
|
||||
"""python tool runs with cwd = workspace (OS-agnostic probe)."""
|
||||
|
||||
Reference in New Issue
Block a user