fix(security): harden Python service boundaries

This commit is contained in:
Alexandre Teixeira
2026-10-06 03:16:54 +01:00
parent eeff41a9ef
commit 1d6d87e2be
32 changed files with 1911 additions and 248 deletions
+17 -2
View File
@@ -3504,6 +3504,21 @@ def _block_sender(sender=None, uids=None, folder="INBOX", account=None, reason="
}
def _attachment_dir(folder, uid, account_id) -> Path:
"""Owner/account-scoped extraction directory (see src.mail_attachment_paths).
`folder` comes from tool arguments and the IMAP server's own mailbox names
(`/` hierarchies, absolute or `..` segments), so it must not become a path;
the old `MAIL_ATTACHMENTS_DIR/{folder}_{uid}` wrote outside the root and
shared one directory between every owner's "INBOX 42".
"""
from src.mail_attachment_paths import attachment_scope_dir
return attachment_scope_dir(
MAIL_ATTACHMENTS_DIR, folder, uid, owner=_current_owner(), account_id=account_id,
)
def _download_attachment(uid, index, folder="INBOX", account=None):
"""Extract a specific attachment to disk and return its local path."""
fixture = _fixture_attachment_source(uid, index, folder=folder, account=account)
@@ -3512,7 +3527,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
filename = str(att.get("filename") or f"attachment-{index}.txt")
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
content = str(att.get("content") or "")
target_dir = Path(MAIL_ATTACHMENTS_DIR) / re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}")
target_dir = _attachment_dir(folder, uid, _row.get("account_id") or account)
path = target_dir / safe_name
size = len(content.encode("utf-8"))
try:
@@ -3545,7 +3560,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}"
target_dir = _attachment_dir(folder, uid, _load_config(account).get("account_id") or account)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"error": f"Attachment index {index} not found"}
+4 -4
View File
@@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str:
lower = s.lower().strip()
def _parse_time(t):
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m: return None
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
@@ -433,7 +433,7 @@ def parse_due_for_user(s: str) -> str:
return base.replace(hour=t[0], minute=t[1]).isoformat()
# Time-first: "3pm today", "11pm today", "9am tomorrow"
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
if m:
time_part, word = m.group(1).strip(), m.group(2)
base = today
@@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime:
def _parse_time(t: str):
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m:
return None
@@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime:
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
# (parity with parse_due_for_user, which handles these via the same form)
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
if m:
time_part, word = m.group(1).strip(), m.group(2)
base = today
+9 -3
View File
@@ -98,7 +98,9 @@ def clean_repeated_assistant_content(text: object) -> str:
"",
value,
).strip()
value = re.sub(r"(?is)\s*</\s*think\s*>\s*$", "", value).strip()
# No leading `\s*`: .strip() removes that whitespace anyway, and scanning
# it from every offset of a long whitespace run was quadratic (ReDoS).
value = re.sub(r"(?is)</\s*think\s*>\s*$", "", value).strip()
return value
_CASUAL_OPENING_RE = re.compile(
@@ -1324,13 +1326,17 @@ def _normalize_thinking(text: str) -> str:
# Handle garbled <think> tags: reasoning text followed by <think> as separator
# e.g. "The user said...I should respond.\n<think>Hey! What's up?"
# Linear form of `^([\s\S]+?)\n*<think>\s*([\s\S]*?)(?:</think>)?\s*$`:
# the lookbehind stops the lazy prefix re-scanning a newline run from every
# offset, and the optional trailing closer is dropped after the match
# instead of being retried at every body offset (both were quadratic).
garbled = re.match(
r'^([\s\S]+?)\n*<think(?:ing)?>\s*([\s\S]*?)(?:</think(?:ing)?>)?\s*$',
r'^([\s\S]+?)(?<![\s\S]\n)\n*<think(?:ing)?>\s*([\s\S]*)$',
text, re.IGNORECASE
)
if garbled:
before = garbled.group(1).strip()
after = garbled.group(2).strip()
after = re.sub(r'</think(?:ing)?>$', '', garbled.group(2).rstrip(), flags=re.IGNORECASE).strip()
# Only treat as garbled if the part before <think> looks like reasoning
reasoning_starts = (
'The user ', 'I need ', 'I should ', 'I will ',
+20 -2
View File
@@ -327,13 +327,31 @@ def _is_external_discovery_request(text: str) -> bool:
))
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
def _mentions_pdf_url(value: str) -> bool:
"""Same as ``re.search(r"https?://[^\\s]+(?:\\.pdf\\b|/pdf/)", value, re.I)``.
Checked per whitespace-free token from its first scheme only (a later
scheme's tail is a suffix of the first's), so a token packed with
`http://` repeats is scanned once instead of once per repeat (ReDoS).
"""
for token in value.split():
scheme = _URL_SCHEME_RE.search(token)
if scheme and _PDF_URL_TAIL_RE.search(token, scheme.end() + 1):
return True
return False
def _prefers_structured_document_tools(text: str) -> bool:
"""Identify external paper/PDF extraction where shell is a bad source route."""
value = str(text or "")
if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I):
return False
return bool(
re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I)
_mentions_pdf_url(value)
or re.search(
r"\b(?:paper|report|study)\b[\s\S]{0,1200}?"
r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b",
@@ -2507,7 +2525,7 @@ def setup_chat_routes(
_explicit_web_intent = (
_explicit_url_target
or bool(re.search(
r"\b(search|look\s+(?:this|that|it|them|these|those)?\s*up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
r"\b(search|look\s+(?:(?:this|that|it|them|these|those)\s*)?up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
_msg_l,
))
or requires_external_web_verification(message)
+153 -16
View File
@@ -17,6 +17,8 @@ import base64
import time
import imaplib
import smtplib
import socket
import ssl
import email as email_mod
import email.header
import email.utils
@@ -36,6 +38,7 @@ from typing import Optional, List
from src.auth_helpers import _auth_disabled, get_current_user
from src.secret_storage import decrypt as _decrypt
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
logger = logging.getLogger(__name__)
@@ -160,6 +163,94 @@ def _smtp_security_mode(cfg: dict) -> str:
return "ssl"
# Raised when a mail host resolves into a denied range (see _mail_private_blocked).
MailServerAddressBlocked = OutboundAddressBlocked
def _mail_private_blocked(owner: str | None) -> bool:
"""Whether private/loopback/shared mail destinations are denied for *owner*.
Link-local (cloud metadata), multicast, reserved and unspecified addresses
are always denied (src/url_safety.py). Private ranges are where a mail
tester or saved account becomes an internal-network probe, so they are
only allowed for an admin or in single-user mode (AUTH_ENABLED=false), the
same principals trusted with host-level tools. Operators of multi-user
deployments with a LAN mail server opt in with EMAIL_ALLOW_PRIVATE_IPS=true;
EMAIL_BLOCK_PRIVATE_IPS=true denies private ranges for everyone.
"""
if os.getenv("EMAIL_BLOCK_PRIVATE_IPS", "").strip().lower() == "true":
return True
if os.getenv("EMAIL_ALLOW_PRIVATE_IPS", "").strip().lower() == "true":
return False
from src.tool_security import owner_is_admin_or_single_user
return not owner_is_admin_or_single_user((owner or "").strip() or None)
def _mail_socket_timeout(timeout):
return socket._GLOBAL_DEFAULT_TIMEOUT if timeout is None else timeout
class _PolicyIMAP4(imaplib.IMAP4):
"""IMAP4 whose socket comes from connect_outbound_tcp.
The mail host is resolved once and connected to only at the addresses the
address policy approved, so DNS rebinding between check and connect is
impossible. STARTTLS still verifies against ``self.host``.
"""
def __init__(self, host, port, *, block_private: bool, timeout=None):
self._block_private = block_private
super().__init__(host, port, timeout=timeout)
def _create_socket(self, timeout):
if timeout is not None and not timeout:
raise ValueError("Non-blocking socket (timeout=0) is not supported")
return connect_outbound_tcp(
self.host, self.port, timeout=_mail_socket_timeout(timeout), block_private=self._block_private,
)
class _PolicyIMAP4_SSL(imaplib.IMAP4_SSL):
"""IMAP4_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
def __init__(self, host, port, *, block_private: bool, timeout=None, ssl_context=None):
self._block_private = block_private
super().__init__(host, port, ssl_context=ssl_context, timeout=timeout)
def _create_socket(self, timeout):
sock = _PolicyIMAP4._create_socket(self, timeout)
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
class _PolicySMTP(smtplib.SMTP):
"""SMTP whose socket comes from connect_outbound_tcp (see _PolicyIMAP4)."""
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
self._block_private = block_private
super().__init__(host, port, **kwargs)
def _get_socket(self, host, port, timeout):
if timeout is not None and not timeout:
raise ValueError("Non-blocking socket (timeout=0) is not supported")
return connect_outbound_tcp(
host, port, timeout=timeout, block_private=self._block_private,
source_address=self.source_address,
)
class _PolicySMTP_SSL(smtplib.SMTP_SSL):
"""SMTP_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
self._block_private = block_private
super().__init__(host, port, **kwargs)
def _get_socket(self, host, port, timeout):
sock = _PolicySMTP._get_socket(self, host, port, timeout)
return self.context.wrap_socket(sock, server_hostname=self._host)
def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None:
"""Send through SMTP using the configured transport security mode."""
host = cfg["smtp_host"]
@@ -178,14 +269,15 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
smtp.login(user, password)
security = _smtp_security_mode(cfg)
block_private = _mail_private_blocked(cfg.get("owner"))
if security == "ssl":
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
with _PolicySMTP_SSL(host, port, timeout=timeout, block_private=block_private) as smtp:
_auth_smtp(smtp)
smtp.sendmail(from_addr, recipients, message)
return
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
with _PolicySMTP(host, port, timeout=timeout, block_private=block_private) as smtp:
if security == "starttls":
smtp.starttls()
_auth_smtp(smtp)
@@ -224,6 +316,39 @@ def _friendly_email_auth_error(protocol: str, host: str, error: object) -> str:
return raw[:200]
# Bound at import: callers (and tests) may swap out imaplib.IMAP4 itself.
_IMAP_ABORT = imaplib.IMAP4.abort
def _mail_connection_test_error(protocol: str, host: str, error: BaseException) -> str:
"""User-facing result for a failed IMAP/SMTP connection test.
Transport failures are reported by category, never with the peer's own
bytes: a non-mail service answering on the chosen host/port would
otherwise have its banner echoed back (an internal-service fingerprinting
oracle). Errors from a server that does speak the protocol (login/auth
rejections) keep their text via _friendly_email_auth_error, since that is
what the user needs to fix their settings.
"""
if isinstance(error, MailServerAddressBlocked):
return f"{protocol} server address is not allowed by this server's network policy"
if isinstance(error, (_IMAP_ABORT, smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected)):
return f"{protocol} server did not respond like an {protocol} server"
if isinstance(error, ssl.SSLCertVerificationError):
return f"{protocol} TLS certificate verification failed"
if isinstance(error, ssl.SSLError):
return f"{protocol} TLS handshake failed; check the port and security setting"
if isinstance(error, (socket.timeout, TimeoutError)):
return f"{protocol} connection timed out"
if isinstance(error, ConnectionRefusedError):
return f"{protocol} connection refused"
if isinstance(error, socket.gaierror):
return f"{protocol} server name could not be resolved"
if isinstance(error, OSError) and not isinstance(error, smtplib.SMTPException):
return f"{protocol} connection failed"
return _friendly_email_auth_error(protocol, host, error)
def _strip_think(text: str) -> str:
"""Email-flavored think strip — thin wrapper over the central helper.
@@ -682,18 +807,21 @@ def _ensure_sender_signatures_table(conn):
_lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}")
def attachment_extract_dir(folder: str, uid: str) -> Path:
"""Containment-safe extraction directory for an attachment.
def attachment_extract_dir(folder: str, uid: str, *, owner: str, account_id: str | None) -> Path:
"""Containment-safe extraction directory for one message's attachments.
`folder` and `uid` are user-controlled (query/path params). Flatten them to
a single safe path segment so a value like folder='../../tmp' can't escape
ATTACHMENTS_DIR, then assert containment as belt-and-suspenders."""
key = re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}") or "_"
target = (ATTACHMENTS_DIR / key).resolve()
base = ATTACHMENTS_DIR.resolve()
if target != base and base not in target.parents:
IMAP UIDs are small per-mailbox counters, so `folder_uid` alone put every
user's and every account's "INBOX 42" in one directory, where extractions
overwrote each other by filename (a path handed to one user's agent could
then hold another user's attachment). See src.mail_attachment_paths: the
directory is keyed by (owner, account, folder, uid) and contained in
ATTACHMENTS_DIR whatever `folder`/`uid` hold."""
from src.mail_attachment_paths import attachment_scope_dir
try:
return attachment_scope_dir(ATTACHMENTS_DIR, folder, uid, owner=owner, account_id=account_id)
except ValueError:
raise HTTPException(400, "Invalid attachment location")
return target
def _init_scheduled_db():
@@ -1074,6 +1202,8 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
cfg = {
"account_id": row.id,
"account_name": row.name,
# Principal for the outbound mail address policy.
"owner": row.owner or owner or "",
"smtp_host": row.smtp_host or "",
"smtp_port": int(row.smtp_port or 465),
"smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}),
@@ -1107,6 +1237,7 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
cfg = {
"account_id": resolved_id,
"account_name": "legacy",
"owner": owner or "",
"smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")),
"smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465),
"smtp_security": _smtp_security_mode({
@@ -1170,11 +1301,16 @@ def _open_imap_connection(
starttls: bool,
timeout: int = _IMAP_TIMEOUT_SECONDS,
ssl_context=None,
owner: str | None = None,
):
"""Open an IMAP connection using the configured security mode."""
"""Open an IMAP connection using the configured security mode.
`owner` is the principal the destination is judged for
(see _mail_private_blocked)."""
port = int(port or 993)
block_private = _mail_private_blocked(owner)
if starttls:
conn = imaplib.IMAP4(host, port, timeout=timeout)
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
try:
if ssl_context:
conn.starttls(ssl_context=ssl_context)
@@ -1190,9 +1326,9 @@ def _open_imap_connection(
raise
elif port == 993:
kwargs = {"ssl_context": ssl_context} if ssl_context else {}
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout, **kwargs)
conn = _PolicyIMAP4_SSL(host, port, timeout=timeout, block_private=block_private, **kwargs)
else:
conn = imaplib.IMAP4(host, port, timeout=timeout)
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
try:
conn.sock.settimeout(timeout)
except Exception:
@@ -1232,6 +1368,7 @@ def _imap_connect(account_id: str | None = None, owner: str = "",
cfg["imap_port"],
starttls=bool(cfg.get("imap_starttls")),
timeout=timeout,
owner=cfg.get("owner"),
)
try:
if cfg.get("oauth_provider") == "google":
+206 -153
View File
@@ -36,6 +36,7 @@ from pathlib import Path
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from starlette.concurrency import run_in_threadpool
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
from fastapi.responses import FileResponse, StreamingResponse
from src.constants import DATA_DIR
@@ -60,6 +61,7 @@ from .email_helpers import (
_fetch_sender_thread_context, _pre_retrieve_context,
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
_friendly_email_auth_error, _email_summary_failure_log_detail,
_mail_connection_test_error, _mail_private_blocked, _PolicySMTP, _PolicySMTP_SSL,
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
SendEmailRequest, ExtractStyleRequest,
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
@@ -1523,6 +1525,44 @@ def _envelope_recipients(*fields: str) -> list:
return out
_MD_LINK_URL_RE = re.compile(r"\((https?://)[^)\s]*")
def _md_links_to_html(s: str) -> str:
"""Linear ``re.sub(r"\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)", '<a href="\\2">\\1</a>', s)``.
Every `[` before the next `]` shares that `]`, so only the first can match;
and when a URL runs into whitespace/end without `)`, every later `[` whose
`]` also lies before that stop fails the same way. Skipping those spans
keeps a `[[[` or `[a](http://x[a](http://x` flood O(n) instead of the
regex's O(n^2).
"""
out: list[str] = []
emitted = scan = 0
while True:
open_at = s.find("[", scan)
if open_at < 0:
break
close_at = s.find("]", open_at + 1)
if close_at < 0:
break
scan = close_at + 1
if close_at == open_at + 1:
continue
url = _MD_LINK_URL_RE.match(s, close_at + 1)
if url is None:
continue
end = url.end()
if end < len(s) and s[end] == ")" and end > url.end(1):
out.append(s[emitted:open_at])
out.append(f'<a href="{s[url.start(1):end]}">{s[open_at + 1:close_at]}</a>')
emitted = scan = end + 1
elif end > url.end(1):
scan = max(scan, s.rfind("]", scan, end) + 1)
out.append(s[emitted:])
return "".join(out)
def _md_to_email_html(text: str) -> str:
"""Render the compose markdown body to a SAFE HTML fragment for the email's
text/html part. Everything is HTML-escaped FIRST (so a pasted <script> /
@@ -1538,7 +1578,7 @@ def _md_to_email_html(text: str) -> str:
s = re.sub(r"~~([^~]+)~~", r"<del>\1</del>", s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
# links: text + http(s) url only (escape() already neutralised quotes)
s = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r'<a href="\2">\1</a>', s)
s = _md_links_to_html(s)
return s
parts: list[str] = []
@@ -3900,7 +3940,7 @@ def setup_email_routes():
_row, att = fixture_att
filename = str(att.get("filename") or f"attachment-{index}.txt")
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
target_dir.mkdir(parents=True, exist_ok=True)
filepath = target_dir / safe_name
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
@@ -3919,7 +3959,7 @@ def setup_email_routes():
msg = email_mod.message_from_bytes(raw)
# Extract to a per-email folder
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"error": f"Attachment index {index} not found"}
@@ -3951,7 +3991,7 @@ def setup_email_routes():
if not attachments:
raise HTTPException(status_code=404, detail="No downloadable attachments")
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
zip_buf = io.BytesIO()
used_names: dict[str, int] = {}
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
@@ -4091,7 +4131,7 @@ def setup_email_routes():
att for att in _list_attachments_from_msg(msg)
if not _is_likely_signature_image_attachment(att)
]
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=acct)
for att in attachments:
idx = att.get("index")
if idx is None:
@@ -4156,7 +4196,7 @@ def setup_email_routes():
continue
if not ct.lower().startswith("image/"):
raise HTTPException(status_code=415, detail="Content-ID is not an image")
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, idx, target_dir)
if not filepath:
raise HTTPException(status_code=404, detail="Inline image not found")
@@ -4194,7 +4234,7 @@ def setup_email_routes():
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"error": f"Attachment index {index} not found"}
@@ -4522,7 +4562,9 @@ def setup_email_routes():
try:
content = filepath.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return {"error": f"Failed to read text file: {e}", "filename": base}
# OSError text includes the server-side extraction path.
logger.warning("Failed to read text attachment %s: %s", base, e)
return {"error": "Failed to read text file", "filename": base}
doc_id = _create_markdown_doc(content, "Imported from email attachment")
return {"doc_id": doc_id, "filename": filepath.name}
@@ -4539,7 +4581,7 @@ def setup_email_routes():
_row, att = fixture_att
filename = str(att.get("filename") or f"attachment-{index}.txt")
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
target_dir.mkdir(parents=True, exist_ok=True)
filepath = target_dir / safe_name
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
@@ -4553,7 +4595,7 @@ def setup_email_routes():
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"error": f"Attachment index {index} not found"}
@@ -5162,7 +5204,7 @@ def setup_email_routes():
return {"success": False, "error": "Email not found"}
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"success": False, "error": f"Attachment index {index} not found"}
@@ -6860,6 +6902,158 @@ def setup_email_routes():
finally:
db.close()
def _test_mail_connections(body: dict, owner: str) -> dict:
"""Blocking IMAP/SMTP connection test for /accounts/test.
Runs in the threadpool: each probe can block for its full socket
timeout, which on the event loop stalled every other request. The
connections go through the outbound mail address policy for `owner`
(the user who chose the hosts)."""
imap_result = {"ok": False}
smtp_result = None
imap_host = (body.get("imap_host") or "").strip()
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
imap_user = (body.get("imap_user") or "").strip()
imap_pass = body.get("imap_password") or ""
imap_starttls = bool(body.get("imap_starttls"))
oauth_provider = body.get("oauth_provider") or ""
google_token = None
google_token_loaded = False
google_ssl_context = (
ssl.create_default_context()
if oauth_provider == "google"
else None
)
def _google_token():
nonlocal google_token, google_token_loaded
if not google_token_loaded:
google_token = _get_valid_google_token(body.get("account_id"), body)
google_token_loaded = True
if not google_token:
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
return google_token
if imap_port_err:
imap_result = {"ok": False, "error": imap_port_err}
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
else:
# Connection mode resolution:
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
# STARTTLS off + any other port → plain IMAP4 (no encryption)
# Without the last branch, local servers exposed on a non-993
# port (Dovecot on 31143, etc.) would always fail the SSL
# handshake because they're not actually wrapped in TLS.
try:
imap_kwargs = {
"starttls": imap_starttls,
"timeout": _IMAP_TIMEOUT_SECONDS,
"owner": owner,
}
if google_ssl_context:
imap_kwargs["ssl_context"] = google_ssl_context
conn = _open_imap_connection(
imap_host,
imap_port,
**imap_kwargs,
)
try:
if oauth_provider == "google":
token = _google_token()
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
else:
conn.login(imap_user, imap_pass)
imap_result = {"ok": True}
finally:
try: conn.logout()
except Exception: pass
except Exception as e:
imap_result = {"ok": False, "error": _mail_connection_test_error("IMAP", imap_host, e)}
smtp_host = (body.get("smtp_host") or "").strip()
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
if smtp_host and smtp_port_err:
smtp_result = {"ok": False, "error": smtp_port_err}
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
elif (
oauth_provider == "google"
and smtp_host
and not _google_oauth_smtp_transport_allowed(
smtp_port,
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
)
):
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
elif smtp_host:
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
smtp_user = (body.get("smtp_user") or imap_user).strip()
smtp_pass = body.get("smtp_password") or imap_pass
smtp = None
try:
if smtp_security == "ssl":
smtp_kwargs = (
{"context": google_ssl_context}
if google_ssl_context
else {}
)
smtp = _PolicySMTP_SSL(
smtp_host,
smtp_port,
timeout=10,
block_private=_mail_private_blocked(owner),
**smtp_kwargs,
)
else:
smtp = _PolicySMTP(smtp_host, smtp_port, timeout=10, block_private=_mail_private_blocked(owner))
if smtp_security == "starttls":
try:
if google_ssl_context:
smtp.starttls(context=google_ssl_context)
else:
smtp.starttls()
except Exception:
# STARTTLS failed before the auth cleanup block.
# Close the still-open plaintext socket explicitly.
try:
smtp.close()
except Exception:
pass
smtp = None
raise
if oauth_provider == "google":
token = _google_token()
smtp.ehlo()
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
else:
smtp.login(smtp_user, smtp_pass)
smtp_result = {"ok": True}
except Exception as e:
smtp_result = {"ok": False, "error": _mail_connection_test_error("SMTP", smtp_host, e)}
finally:
if smtp is not None:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
return {
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
"imap": imap_result,
"smtp": smtp_result,
}
@router.post("/accounts/test")
async def test_account_config(req: Request, owner: str = Depends(require_user)):
"""Try to actually connect to the provided IMAP (and optionally SMTP)
@@ -6923,148 +7117,7 @@ def setup_email_routes():
# inline test payloads select the OAuth branch or supply token data.
body = {key: value for key, value in body.items() if key not in _SERVER_OWNED_OAUTH_FIELDS}
imap_result = {"ok": False}
smtp_result = None
imap_host = (body.get("imap_host") or "").strip()
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
imap_user = (body.get("imap_user") or "").strip()
imap_pass = body.get("imap_password") or ""
imap_starttls = bool(body.get("imap_starttls"))
oauth_provider = body.get("oauth_provider") or ""
google_token = None
google_token_loaded = False
google_ssl_context = (
ssl.create_default_context()
if oauth_provider == "google"
else None
)
def _google_token():
nonlocal google_token, google_token_loaded
if not google_token_loaded:
google_token = _get_valid_google_token(body.get("account_id"), body)
google_token_loaded = True
if not google_token:
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
return google_token
if imap_port_err:
imap_result = {"ok": False, "error": imap_port_err}
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
else:
# Connection mode resolution:
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
# STARTTLS off + any other port → plain IMAP4 (no encryption)
# Without the last branch, local servers exposed on a non-993
# port (Dovecot on 31143, etc.) would always fail the SSL
# handshake because they're not actually wrapped in TLS.
try:
imap_kwargs = {
"starttls": imap_starttls,
"timeout": _IMAP_TIMEOUT_SECONDS,
}
if google_ssl_context:
imap_kwargs["ssl_context"] = google_ssl_context
conn = _open_imap_connection(
imap_host,
imap_port,
**imap_kwargs,
)
try:
if oauth_provider == "google":
token = _google_token()
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
else:
conn.login(imap_user, imap_pass)
imap_result = {"ok": True}
finally:
try: conn.logout()
except Exception: pass
except Exception as e:
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
smtp_host = (body.get("smtp_host") or "").strip()
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
if smtp_host and smtp_port_err:
smtp_result = {"ok": False, "error": smtp_port_err}
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
elif (
oauth_provider == "google"
and smtp_host
and not _google_oauth_smtp_transport_allowed(
smtp_port,
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
)
):
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
elif smtp_host:
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
smtp_user = (body.get("smtp_user") or imap_user).strip()
smtp_pass = body.get("smtp_password") or imap_pass
smtp = None
try:
if smtp_security == "ssl":
smtp_kwargs = (
{"context": google_ssl_context}
if google_ssl_context
else {}
)
smtp = smtplib.SMTP_SSL(
smtp_host,
smtp_port,
timeout=10,
**smtp_kwargs,
)
else:
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
if smtp_security == "starttls":
try:
if google_ssl_context:
smtp.starttls(context=google_ssl_context)
else:
smtp.starttls()
except Exception:
# STARTTLS failed before the auth cleanup block.
# Close the still-open plaintext socket explicitly.
try:
smtp.close()
except Exception:
pass
smtp = None
raise
if oauth_provider == "google":
token = _google_token()
smtp.ehlo()
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
else:
smtp.login(smtp_user, smtp_pass)
smtp_result = {"ok": True}
except Exception as e:
smtp_result = {"ok": False, "error": _friendly_email_auth_error("SMTP", smtp_host, e)}
finally:
if smtp is not None:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
return {
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
"imap": imap_result,
"smtp": smtp_result,
}
return await run_in_threadpool(_test_mail_connections, body, owner)
@router.post("/accounts/{account_id}/set-default")
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
+7 -2
View File
@@ -5,8 +5,9 @@ import shlex
import subprocess
from copy import deepcopy
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from core.middleware import require_admin
from core.platform_compat import run_ssh_command
from routes._validators import validate_remote_host, validate_ssh_port
@@ -198,7 +199,11 @@ def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") ->
def setup_hwfit_routes():
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
# Admin-gated like the Cookbook routes this backs (/api/cookbook/gpus does
# the same SSH host probing): `host`/`ssh_port` make the server SSH out with
# its own key to any syntactically valid host, and `model_path` probes the
# host filesystem. Single-user mode and internal tool calls still pass.
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"], dependencies=[Depends(require_admin)])
@router.get("/system")
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
+17 -4
View File
@@ -43,11 +43,22 @@ def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
@router.get("/search/web", response_class=HTMLResponse)
async def web_search_page(q: str = Query("", min_length=0)) -> HTMLResponse:
async def web_search_page(request: Request, q: str = Query("", min_length=0)) -> HTMLResponse:
"""Browser-facing search results page for clickable agent web_search rows."""
# The site CSP allows inline script only with the per-request nonce
# (SecurityHeadersMiddleware); without it this page's script never ran.
nonce = html.escape(getattr(request.state, "csp_nonce", "") or "", quote=True)
safe_q = str(q or "").strip()
title = html.escape(safe_q or "Web search")
q_json = json.dumps(safe_q)
# json.dumps leaves `<`, `>` and `&` alone, so q=</script><script ...>
# would close this inline <script> and inject markup (reflected XSS).
# Unicode-escape them; the JS string value is unchanged.
q_json = (
json.dumps(safe_q)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
page = f"""<!doctype html>
<html>
<head>
@@ -79,7 +90,7 @@ def setup_search_routes(config) -> APIRouter:
<div class="status" id="status">Loading...</div>
<div id="results"></div>
</main>
<script>
<script nonce="{nonce}">
const initialQuery = {q_json};
const input = document.getElementById('query');
const statusEl = document.getElementById('status');
@@ -103,7 +114,9 @@ def setup_search_routes(config) -> APIRouter:
}}
statusEl.textContent = sources.length ? `${{sources.length}} results` : 'No results';
resultsEl.innerHTML = sources.map(s => {{
const url = s.url || s.link || '';
const rawUrl = String(s.url || s.link || '');
// Result URLs come from third-party search results: only http(s) may become a link.
const url = /^https?:[/][/]/i.test(rawUrl) ? rawUrl : '';
const title = s.title || url || 'Untitled';
const snippet = s.snippet || s.content || '';
return `<a class="result" href="${{esc(url)}}" target="_blank" rel="noopener noreferrer">
+8 -2
View File
@@ -1601,7 +1601,13 @@ def setup_session_routes(
raise HTTPException(404, "Session not found")
skills = _context_info_skill_inventory(skills_manager, owner=owner)
tools = _context_info_tool_inventory()
agents_md = _context_info_agents_md_inventory(cwd)
# `cwd` is caller-chosen. Walking it for AGENTS.md and reporting whether
# it exists confirms paths on the host, which /api/workspace/browse and
# /vet reserve for admins (or single-user mode); keep that boundary here.
from src.tool_security import owner_is_admin_or_single_user
_host_paths_visible = owner_is_admin_or_single_user(owner)
agents_md = _context_info_agents_md_inventory(cwd) if _host_paths_visible else []
# Workspace visibility: lets the TUI answer "can the backend actually
# see this directory?" (mounted vs bridge-only) without probing.
from src.workspace_paths import backend_workspace_path, workspace_mount_pairs
@@ -1626,7 +1632,7 @@ def setup_session_routes(
}
_workspace = {
"backend_path": _backend_cwd,
"exists_in_backend": bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
"exists_in_backend": _host_paths_visible and bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
"mount_configured": bool(workspace_mount_pairs()),
"via_mount": bool(_raw_cwd) and backend_workspace_path(_raw_cwd) != _raw_cwd,
}
+5 -4
View File
@@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user
from src.prompt_security import untrusted_context_message
from src.text_helpers import strip_closed_think_blocks
from core.middleware import require_admin
logger = logging.getLogger(__name__)
@@ -250,7 +251,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
# Strip closed think blocks. If a <think> was opened but never closed
# (the model ran out of budget mid-reasoning), drop everything from it
# onward so its stray braces don't poison JSON extraction.
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I)
text = strip_closed_think_blocks(text)
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
def _coerce(d):
@@ -403,7 +404,7 @@ async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: st
except Exception as e:
logger.warning(f"Necessity check failed: {e}")
return None
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
text = strip_closed_think_blocks(raw or '')
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
data = None
a, b = text.find('{'), text.rfind('}')
@@ -493,7 +494,7 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
except Exception as e:
logger.warning(f"Retrieval precision check failed: {e}")
return None
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
text = strip_closed_think_blocks(raw or '')
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
data = None
a, b = text.find('{'), text.rfind('}')
@@ -1188,7 +1189,7 @@ async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url,
except Exception as e:
logger.warning(f"Audit: improve call failed: {e}")
return None
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
text = strip_closed_think_blocks(raw or '')
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I)
text = _re.sub(r'</think(?:ing)?>', '', text, flags=_re.I).strip()
if text.startswith("```"):
+7 -1
View File
@@ -1252,8 +1252,14 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if not out.get("prompt"):
return {"success": False, "message": "Could not extract a task instruction"}
return {"success": True, "draft": out}
except HTTPException as e:
# llm_core raises HTTPException with curated upstream text.
logger.error(f"parse_task failed: {e.detail}")
return {"success": False, "message": str(e.detail)}
except Exception as e:
# Anything else (JSON/parse/runtime errors) can carry internals;
# log it and keep the client message generic.
logger.error(f"parse_task failed: {e}")
return {"success": False, "message": str(e)}
return {"success": False, "message": f"Could not draft a task ({type(e).__name__})"}
return router
+8 -3
View File
@@ -610,7 +610,9 @@ async def update_persona_memory(
)
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
updated = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", updated, flags=re.I | re.S).strip()
# No leading `\s*` before the closing fence: .strip() drops that
# whitespace anyway, and scanning it from every offset was quadratic.
updated = re.sub(r"^```(?:text|markdown)?\s*|```$", "", updated, flags=re.I | re.S).strip()
if len(updated) > 6000:
updated = updated[:6000].rstrip()
if updated == existing_memory:
@@ -694,8 +696,9 @@ async def audit_memories(
# Parse the JSON list, tolerating reasoning-model noise: <think> blocks,
# markdown fences, leading prose, and trailing commas.
import re as _re
from src.text_helpers import strip_closed_think_blocks
text = (raw or "").strip()
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I).strip()
text = strip_closed_think_blocks(text).strip()
def _loads_list(s):
if not s:
@@ -711,7 +714,9 @@ async def audit_memories(
cleaned = _loads_list(text)
if cleaned is None:
_m = _re.search(r'```(?:json)?\s*\n?([\s\S]*?)```', text)
# Possessive `\s*+`: handing fence whitespace back to the body on a
# missing closing fence only rescanned the same tail (ReDoS).
_m = _re.search(r'```(?:json)?\s*+\n?([\s\S]*?)```', text)
if _m:
cleaned = _loads_list(_m.group(1).strip())
if cleaned is None:
+13 -3
View File
@@ -3249,7 +3249,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
except Exception as e:
logger.error(f"Ollama stream error: {e}")
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
return
# ── Anthropic streaming ──
@@ -3402,7 +3402,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
except Exception as e:
logger.error(f"Anthropic stream error: {e}")
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
return
# ── OpenAI-compatible streaming ──
@@ -3872,7 +3872,17 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
except Exception as e:
logger.error(f"Stream error: {e}")
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
def _stream_failure_message(error: BaseException) -> str:
"""Client-facing text for an unexpected streaming failure.
The raw exception can carry request URLs, local paths or provider internals;
callers log it server-side and stream only this generic message, like the
named transport failures above it.
"""
return f"Model stream failed ({type(error).__name__})"
def _summarize_stream_error(err_chunk: Optional[str]) -> str:
+32
View File
@@ -0,0 +1,32 @@
"""Per-principal extraction directories for email attachments.
Shared by the HTTP email routes and the email MCP server, which both extract
attachments under MAIL_ATTACHMENTS_DIR.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
def attachment_scope_dir(root, folder, uid, *, owner, account_id) -> Path:
"""Return the extraction directory for one message's attachments.
IMAP UIDs are small per-mailbox counters and folder names are server- or
caller-supplied (`/`-delimited hierarchies, absolute or `..` segments), so
neither may become a path. The directory is one hex segment derived from
(owner, account, folder, uid): distinct principals and mailboxes never
share it, and no input can steer it out of *root*. Containment is checked
after resolution, so a symlinked entry cannot redirect it either.
"""
scope = json.dumps(
[str(owner or ""), str(account_id or ""), str(folder or ""), str(uid or "")],
ensure_ascii=False,
)
base = Path(root).resolve()
target = (base / hashlib.sha256(scope.encode("utf-8")).hexdigest()[:32]).resolve()
if target.parent != base:
raise ValueError("attachment directory escapes the extraction root")
return target
+9 -2
View File
@@ -6,11 +6,14 @@ writable, and storage is local-first. Served by ``GET /api/ready`` and suitable
for an orchestrator readiness probe (200 only when every critical check passes).
"""
import logging
import os
import uuid
from datetime import datetime
from typing import Dict
logger = logging.getLogger(__name__)
def check_readiness() -> Dict[str, object]:
"""Run the readiness checks and return a JSON-serialisable report.
@@ -33,7 +36,10 @@ def check_readiness() -> Dict[str, object]:
conn.execute(sql_text("SELECT 1"))
checks["database"] = {"ok": True}
except Exception as e:
checks["database"] = {"ok": False, "error": str(e)}
# The raw driver error can carry the DB host/user/path; keep it in the
# server log and give the client only the exception type.
logger.warning("Readiness database check failed: %s", e)
checks["database"] = {"ok": False, "error_type": type(e).__name__}
# Data directory present and writable — home must be able to hold its own data.
try:
@@ -44,7 +50,8 @@ def check_readiness() -> Dict[str, object]:
os.remove(probe)
checks["data_dir"] = {"ok": True, "path": DATA_DIR}
except Exception as e:
checks["data_dir"] = {"ok": False, "error": str(e)}
logger.warning("Readiness data_dir check failed: %s", e)
checks["data_dir"] = {"ok": False, "error_type": type(e).__name__}
# Local-first: storage stays on the home machine (informational, never fatal).
local_first = (
+14
View File
@@ -193,3 +193,17 @@ def strip_think(text: str, *, prose: bool = False, prompt_echo: bool = True) ->
# from `src.research_utils` working while delegating to the central impl.
def strip_thinking(text: str) -> str:
return strip_think(text or "", prose=False, prompt_echo=True)
_CLOSED_THINK_OPEN_RE = re.compile(r"<think(?:ing)?>", re.IGNORECASE)
_CLOSED_THINK_CLOSE_RE = re.compile(r"</think(?:ing)?>", re.IGNORECASE)
def strip_closed_think_blocks(text: str) -> str:
"""Remove closed ``<think>``/``<thinking>`` blocks, leaving everything else.
Same result as ``re.sub(r'<think(?:ing)?>[\\s\\S]*?</think(?:ing)?>', '', text,
flags=re.I)`` but forward-only (see _sub_delimited), so an unclosed opener
flood in model output stays O(n) instead of O(n^2).
"""
return _sub_delimited(text or "", _CLOSED_THINK_OPEN_RE, _CLOSED_THINK_CLOSE_RE, lambda _inner: "")
+5 -5
View File
@@ -236,10 +236,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
text = str(raw).strip().lower()
if text in {"none", "no", "off", "false"}:
return None
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
m = re.search(r"(?<!\d)(\d+)\s*(?:minutes?|mins?|m)\b", text)
if m:
return max(0, int(m.group(1)))
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
m = re.search(r"(?<!\d)(\d+)\s*(?:hours?|hrs?|h)\b", text)
if m:
return max(0, int(m.group(1)) * 60)
if text.isdigit():
@@ -251,7 +251,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
if minutes_before is None:
return desc
reminder_only = re.compile(
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
r"^\s*(?:remind(?:er)?|alarm)\s*(?::\s*)?\d+\s*"
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
re.I,
)
@@ -497,8 +497,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
delta = None
if dur:
import re as _re_d
h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
h = _re_d.search(r'(?<!\d)(\d+)\s*(?:h|hr|hours?)', dur)
m = _re_d.search(r'(?<!\d)(\d+)\s*(?:m|min|minutes?)', dur)
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
if secs > 0:
delta = timedelta(seconds=secs)
+1 -1
View File
@@ -322,7 +322,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
if looks_like_reminder:
temporal = re.search(
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
r"|\b\d{1,2}(?::\d{2})?(?:\s*(?:am|pm))?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
lower_combined,
)
+70 -9
View File
@@ -63,19 +63,80 @@ INTERNAL_UPLOAD_URL_RE = re.compile(
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
r"(?=$|[\s\"'<>\[\](){},;!?:&#]|\.(?![A-Za-z0-9]))"
)
PDF_SOURCE_UPLOAD_RE = re.compile(
r"<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id="
r"[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"'][^>]*-->",
# `<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id=["'](id)["'][^>]*-->` and
# `\[Attachment:[^\]\r\n]*\|\s*id=(id)(?:\s*\||\s*\])` are matched by the
# forward-only scanners below. As single regexes, every opener in a run with no
# closing `>` / `]` rescanned to the end of that run: O(n^2) on chat content,
# which is unbounded on persisted assistant output (CodeQL py/polynomial-redos).
_PDF_SOURCE_OPEN_RE = re.compile(r"<!--\s*pdf(?:_form)?_source\b", re.IGNORECASE)
_PDF_SOURCE_ID_RE = re.compile(
r"\bupload_id=[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"']",
re.IGNORECASE,
)
ATTACHMENT_REFERENCE_LINE_RE = re.compile(
r"\[Attachment:[^\]\r\n]*\|\s*id="
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
r"(?:\s*\||\s*\])",
_ATTACHMENT_REFERENCE_OPEN_RE = re.compile(r"\[Attachment:", re.IGNORECASE)
_ATTACHMENT_REFERENCE_STOP_RE = re.compile(r"[\]\r\n]")
_ATTACHMENT_REFERENCE_TAIL_RE = re.compile(
r"\|\s*id=([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)(?:\s*\||\s*\])",
re.IGNORECASE,
)
def _pdf_source_upload_ids(value: str) -> list[str]:
"""IDs from `<!-- pdf_source ... upload_id="<id>" ... -->` comments.
Every opener before the next `>` shares that `>`: the comment matches only
if `--` sits right before it, and then with the last `upload_id=` (the
greedy `[^>]*` backtracks from the right). Otherwise all of those openers
fail together, so the scan resumes after the `>`.
"""
found: list[str] = []
pos = 0
while True:
opener = _PDF_SOURCE_OPEN_RE.search(value, pos)
if opener is None:
return found
close = value.find(">", opener.end())
if close < 0:
return found
if value[close - 2:close] == "--":
last = None
for last in _PDF_SOURCE_ID_RE.finditer(value, opener.end(), close):
pass
if last is not None:
found.append(last.group(1))
pos = close + 1
def _attachment_reference_ids(value: str) -> list[str]:
"""IDs from `[Attachment: name | id=<id> | ...]` reference lines.
The label scan stops at the first `]`/CR/LF, so every opener before that
stop shares it and can only use the last `|` (scanning right to left)
whose tail matches. If none does, all of those openers fail together and
the scan resumes at the stop.
"""
found: list[str] = []
pos = 0
while True:
opener = _ATTACHMENT_REFERENCE_OPEN_RE.search(value, pos)
if opener is None:
return found
stop = _ATTACHMENT_REFERENCE_STOP_RE.search(value, opener.end())
end = stop.start() if stop else len(value)
tail = None
pipe = value.rfind("|", opener.end(), end)
while pipe >= 0:
tail = _ATTACHMENT_REFERENCE_TAIL_RE.match(value, pipe)
if tail is not None:
break
pipe = value.rfind("|", opener.end(), pipe)
if tail is None:
pos = end
else:
found.append(tail.group(1))
pos = tail.end()
def is_valid_upload_id(upload_id: str) -> bool:
"""Return True when *upload_id* matches the canonical uploads.json id format."""
return UPLOAD_ID_RE.fullmatch(upload_id or "") is not None
@@ -110,8 +171,8 @@ def extract_internal_upload_ids(value: Any) -> set[str]:
return set()
return (
set(INTERNAL_UPLOAD_URL_RE.findall(value))
| set(PDF_SOURCE_UPLOAD_RE.findall(value))
| set(ATTACHMENT_REFERENCE_LINE_RE.findall(value))
| set(_pdf_source_upload_ids(value))
| set(_attachment_reference_ids(value))
)
+49
View File
@@ -154,3 +154,52 @@ def check_outbound_url(
if not saw_ip:
return False, "host does not resolve to an IP"
return True, "ok"
class OutboundAddressBlocked(PermissionError):
"""A non-HTTP outbound host resolves into a disallowed address range."""
def connect_outbound_tcp(
host: str,
port: int,
*,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
block_private: bool = False,
source_address=None,
resolver: Optional[Callable[..., list]] = None,
) -> socket.socket:
"""Open a TCP connection to *host* under the outbound address policy.
For raw TCP clients (IMAP/SMTP). The host is resolved exactly once, every
resolved address is judged with the same policy as check_outbound_url, and
the socket connects only to those already-judged addresses. A second
lookup at connect time (what socket.create_connection(host) does) would let
a rebinding name pass the check with a public answer and then connect to
metadata/private space. TLS callers keep wrapping the returned socket with
``server_hostname=host``, so SNI and certificate checks are unchanged.
"""
resolve = resolver or socket.getaddrinfo
infos = resolve(host, port, 0, socket.SOCK_STREAM)
for _family, _type, _proto, _canon, sockaddr in infos:
ip = ipaddress.ip_address(str(sockaddr[0]).split("%")[0])
reason = _classify(ip, block_private=block_private)
if reason:
raise OutboundAddressBlocked(reason)
last_error: Optional[OSError] = None
for family, socktype, proto, _canon, sockaddr in infos:
sock = socket.socket(family, socktype, proto)
try:
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: # same contract as create_connection
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
return sock
except OSError as exc:
last_error = exc
sock.close()
if last_error is not None:
raise last_error
raise socket.gaierror(socket.EAI_NONAME, f"{host} did not resolve to an address")
@@ -123,3 +123,37 @@ async def test_update_event_can_add_reminder_after_creation():
assert note is not None, "update_event should create the reminder note"
finally:
db.close()
async def test_reminder_and_duration_parsers_stay_linear_on_digit_and_space_floods():
"""CodeQL py/polynomial-redos: `(\\d+)\\s*unit` rescanned a digit run from
every offset and `alarm\\s*:?\\s*\\d+` split one whitespace run two ways.
Tool arguments come from model output, so keep them O(n)."""
import time
from src.tool_implementations import do_manage_calendar
owner = "tester-" + uuid.uuid4().hex[:6]
started = time.perf_counter()
res = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "Flood",
"dtstart": "2030-04-01T10:00:00",
"reminder_minutes": "0" * 40_000 + "x",
"duration": "0" * 40_000 + "x",
}), owner=owner)
assert time.perf_counter() - started < 2.0
assert res.get("exit_code") == 0, res
assert "reminder set" not in res.get("response", ""), res
# A reminder is set, so the "is the description only a reminder?" check runs.
started = time.perf_counter()
res = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "Flood 2",
"dtstart": "2030-04-02T10:00:00",
"description": "alarm" + "\t" * 40_000,
"reminder_minutes": 5,
}), owner=owner)
assert time.perf_counter() - started < 2.0
assert res.get("exit_code") == 0, res
assert "reminder 5 min before" in res.get("response", ""), res
@@ -0,0 +1,522 @@
"""Final closure tests for the residual Python CodeQL lane (PR #6503).
1. MCP attachment extraction: no traversal, per owner/account directories.
2. Mail DNS rebinding: one resolution, connect only to the checked address,
TLS SNI/verification still against the requested hostname.
3. /accounts/test runs its blocking mail I/O off the event loop.
4. /search/web works under the production CSP and payloads cannot execute.
5. Outbound mail address policy matrix.
"""
import asyncio
import json
import os
import re
import shutil
import socket
import subprocess
import threading
import time
import urllib.parse
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from pathlib import Path
from types import SimpleNamespace
import pytest
# -- 1. MCP attachment extraction -------------------------------------------
def _message_with_attachment(body: bytes) -> bytes:
msg = MIMEMultipart()
part = MIMEApplication(body, Name="invoice.pdf")
part["Content-Disposition"] = 'attachment; filename="invoice.pdf"'
msg.attach(part)
return msg.as_bytes()
@pytest.fixture
def mcp_mail(tmp_path, monkeypatch):
import mcp_servers.email_server as es
root = tmp_path / "mail-attachments"
root.mkdir()
monkeypatch.setattr(es, "MAIL_ATTACHMENTS_DIR", str(root))
monkeypatch.setattr(es, "_fixture_attachment_source", lambda *a, **k: None)
monkeypatch.setattr(es, "_load_config", lambda account=None: {"account_id": account})
box = {"raw": _message_with_attachment(b"%PDF one")}
class _Conn:
def select(self, *a, **k):
return ("OK", [b"1"])
def uid(self, *a):
return ("OK", [(b"42 (BODY[])", box["raw"])])
def logout(self):
pass
monkeypatch.setattr(es, "_imap_connect", lambda account=None: _Conn())
return es, root.resolve(), box
@pytest.mark.parametrize("folder,uid", [
("/tmp/odysseus-escape", "42"), # absolute mailbox name replaced the root
("../../escape", "42"), # relative traversal
("INBOX/../../../escape", "42"), # hierarchy delimiter + traversal
("..", ".."),
("INBOX", "../../42"),
("INBOX/Receipts", "42"), # legitimate hierarchical folder
])
def test_mcp_download_attachment_stays_in_one_root_segment(mcp_mail, folder, uid):
es, root, _box = mcp_mail
result = es._download_attachment(uid, 0, folder)
path = Path(result["path"]).resolve()
assert path.parent.parent == root, path
assert path.read_bytes() == b"%PDF one"
def test_mcp_download_attachment_isolates_owners_and_accounts(mcp_mail):
es, _root, box = mcp_mail
paths = {}
for owner, account, body in (
("alice", "acct-a", b"%PDF alice"),
("bob", "acct-a", b"%PDF bob"),
("alice", "acct-b", b"%PDF alice second mailbox"),
):
token = es._CURRENT_OWNER.set(owner)
try:
box["raw"] = _message_with_attachment(body)
paths[(owner, account)] = (es._download_attachment("42", 0, "INBOX", account=account)["path"], body)
finally:
es._CURRENT_OWNER.reset(token)
assert len({p for p, _ in paths.values()}) == 3
for path, body in paths.values():
assert Path(path).read_bytes() == body
def test_attachment_scope_dir_rejects_symlinked_scope(tmp_path):
from src.mail_attachment_paths import attachment_scope_dir
root = tmp_path / "root"
root.mkdir()
target = attachment_scope_dir(root, "INBOX", "1", owner="o", account_id="a")
outside = tmp_path / "outside"
outside.mkdir()
target.symlink_to(outside, target_is_directory=True)
with pytest.raises(ValueError):
attachment_scope_dir(root, "INBOX", "1", owner="o", account_id="a")
def test_attachment_scope_dir_normalises_uid_type(tmp_path):
from src.mail_attachment_paths import attachment_scope_dir
assert attachment_scope_dir(tmp_path, "INBOX", 42, owner="o", account_id=None) == \
attachment_scope_dir(tmp_path, "INBOX", "42", owner="o", account_id="")
# -- 2. DNS rebinding: resolve once, connect to the checked address ------------
class _LineServer:
"""Tiny loopback server speaking just enough IMAP or SMTP for a handshake."""
def __init__(self, protocol):
self.protocol = protocol
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(4)
self.port = self.sock.getsockname()[1]
self.thread = threading.Thread(target=self._serve, daemon=True)
self.thread.start()
def _serve(self):
try:
conn, _ = self.sock.accept()
except OSError:
return
with conn, conn.makefile("rwb") as io:
io.write(b"* OK ready\r\n" if self.protocol == "imap" else b"220 fake ESMTP\r\n")
io.flush()
for raw in io:
line = raw.decode().strip()
if self.protocol == "imap":
tag, _, command = line.partition(" ")
if command.upper().startswith("CAPABILITY"):
io.write(f"* CAPABILITY IMAP4rev1\r\n{tag} OK done\r\n".encode())
else:
io.write(f"* BYE\r\n{tag} OK bye\r\n".encode())
io.flush()
return
elif line.upper().startswith(("EHLO", "HELO")):
io.write(b"250 fake\r\n")
else:
io.write(b"221 bye\r\n")
io.flush()
return
io.flush()
def close(self):
self.sock.close()
@pytest.fixture
def rebinding_dns(monkeypatch):
"""`rebind.test` answers loopback once, then the metadata address."""
real = socket.getaddrinfo
lookups = []
def _resolve(host, port, *args, **kwargs):
if host == "rebind.test":
lookups.append(host)
ip = "127.0.0.1" if len(lookups) == 1 else "169.254.169.254"
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port))]
return real(host, port, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", _resolve)
return lookups
class _RecordingTLS:
"""Stands in for an SSLContext: records the SNI name, keeps the socket plain."""
def __init__(self):
self.server_hostname = None
def wrap_socket(self, sock, server_hostname=None, **_kwargs):
self.server_hostname = server_hostname
return sock
@pytest.mark.parametrize("tls", [False, True])
def test_imap_connects_to_the_checked_address_only(rebinding_dns, tls):
from routes.email.email_helpers import _PolicyIMAP4, _PolicyIMAP4_SSL
server = _LineServer("imap")
try:
if tls:
context = _RecordingTLS()
conn = _PolicyIMAP4_SSL("rebind.test", server.port, block_private=False, timeout=5, ssl_context=context)
assert context.server_hostname == "rebind.test"
else:
conn = _PolicyIMAP4("rebind.test", server.port, block_private=False, timeout=5)
assert conn.sock.getpeername()[0] == "127.0.0.1"
conn.logout()
finally:
server.close()
assert rebinding_dns == ["rebind.test"] # a second lookup would have answered 169.254.169.254
@pytest.mark.parametrize("tls", [False, True])
def test_smtp_connects_to_the_checked_address_only(rebinding_dns, tls):
from routes.email.email_helpers import _PolicySMTP, _PolicySMTP_SSL
server = _LineServer("smtp")
try:
if tls:
context = _RecordingTLS()
smtp = _PolicySMTP_SSL("rebind.test", server.port, block_private=False, timeout=5, context=context)
assert context.server_hostname == "rebind.test"
else:
smtp = _PolicySMTP("rebind.test", server.port, block_private=False, timeout=5)
assert smtp.sock.getpeername()[0] == "127.0.0.1"
assert smtp.ehlo()[0] == 250
smtp.quit()
finally:
server.close()
assert rebinding_dns == ["rebind.test"]
def test_any_denied_answer_in_a_mixed_resolution_blocks_the_connection():
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
def _mixed(host, port, *args):
return [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", port)),
]
with pytest.raises(OutboundAddressBlocked):
connect_outbound_tcp("mixed.test", 993, block_private=False, resolver=_mixed)
# -- 5. Address/principal policy matrix ----------------------------------------
_ALWAYS_DENIED = [
"169.254.169.254", "fe80::1", "0.0.0.0", "::", "224.0.0.1", "240.0.0.1",
"::ffff:169.254.169.254", "64:ff9b::a9fe:a9fe", "64:ff9b::a00:5",
"::1", # IPv6 loopback sits in ::/8, which the existing policy treats as reserved
]
_PRIVATE = ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.10", "100.64.0.1", "fd00::1"]
_PUBLIC = ["93.184.216.34", "2606:2800:220:1::1"]
@pytest.fixture
def fake_sockets(monkeypatch):
import src.url_safety as url_safety
connected = []
class _Sock:
def __init__(self, *args):
pass
def settimeout(self, timeout):
pass
def bind(self, address):
pass
def connect(self, sockaddr):
connected.append(sockaddr[0])
def close(self):
pass
monkeypatch.setattr(url_safety.socket, "socket", _Sock)
return connected
def _answer(ip):
family = socket.AF_INET6 if ":" in ip else socket.AF_INET
return lambda host, port, *args: [(family, socket.SOCK_STREAM, 6, "", (ip, port))]
@pytest.mark.parametrize("block_private", [False, True])
@pytest.mark.parametrize("ip", _ALWAYS_DENIED)
def test_metadata_and_special_ranges_are_always_denied(fake_sockets, ip, block_private):
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
with pytest.raises(OutboundAddressBlocked):
connect_outbound_tcp("h", 993, block_private=block_private, resolver=_answer(ip))
assert fake_sockets == []
@pytest.mark.parametrize("ip", _PRIVATE)
def test_private_ranges_follow_the_principal_policy(fake_sockets, ip):
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
connect_outbound_tcp("h", 993, block_private=False, resolver=_answer(ip))
assert fake_sockets == [ip]
with pytest.raises(OutboundAddressBlocked):
connect_outbound_tcp("h", 993, block_private=True, resolver=_answer(ip))
@pytest.mark.parametrize("ip", _PUBLIC)
def test_public_mail_servers_are_always_allowed(fake_sockets, ip):
from src.url_safety import connect_outbound_tcp
connect_outbound_tcp("h", 993, block_private=True, resolver=_answer(ip))
assert fake_sockets == [ip]
@pytest.mark.parametrize("trusted,env,owner,blocked", [
(False, {}, "bob", True), # multi-user, non-admin: denied
(False, {}, "", True), # multi-user, no principal: denied
(True, {}, "admin", False), # admin (or single-user mode)
(False, {"EMAIL_ALLOW_PRIVATE_IPS": "true"}, "bob", False), # operator opt-in for LAN mail
(True, {"EMAIL_BLOCK_PRIVATE_IPS": "true"}, "admin", True), # operator lockdown wins
(False, {"EMAIL_ALLOW_PRIVATE_IPS": "true", "EMAIL_BLOCK_PRIVATE_IPS": "true"}, "bob", True),
])
def test_private_mail_destination_principal_matrix(monkeypatch, trusted, env, owner, blocked):
import src.tool_security as tool_security
from routes.email.email_helpers import _mail_private_blocked
for key in ("EMAIL_ALLOW_PRIVATE_IPS", "EMAIL_BLOCK_PRIVATE_IPS"):
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
monkeypatch.setattr(tool_security, "owner_is_admin_or_single_user", lambda o: trusted)
assert _mail_private_blocked(owner) is blocked
def test_single_user_mode_allows_lan_mail_servers(monkeypatch):
from routes.email.email_helpers import _mail_private_blocked
for key in ("EMAIL_ALLOW_PRIVATE_IPS", "EMAIL_BLOCK_PRIVATE_IPS"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("AUTH_ENABLED", "false")
assert _mail_private_blocked("") is False
def test_non_admin_cannot_probe_loopback_through_the_connection_test(monkeypatch):
import routes.email_routes as email_routes
import src.tool_security as tool_security
for key in ("EMAIL_ALLOW_PRIVATE_IPS", "EMAIL_BLOCK_PRIVATE_IPS"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(tool_security, "owner_is_admin_or_single_user", lambda o: False)
endpoint = _accounts_test_endpoint(email_routes)
class _Request:
async def json(self):
return {"imap_host": "127.0.0.1", "imap_port": 6379, "imap_user": "u", "imap_password": "p",
"smtp_host": "10.0.0.5", "smtp_port": 25}
result = asyncio.run(endpoint(req=_Request(), owner="bob"))
assert result["imap"]["error"] == "IMAP server address is not allowed by this server's network policy"
assert result["smtp"]["error"] == "SMTP server address is not allowed by this server's network policy"
# -- 3. /accounts/test blocking I/O stays off the event loop -----------------------
def _accounts_test_endpoint(email_routes):
router = email_routes.setup_email_routes()
return next(
r.endpoint for r in router.routes
if r.path == "/api/email/accounts/test" and "POST" in getattr(r, "methods", set())
)
def test_connection_test_does_not_block_the_event_loop(monkeypatch):
import routes.email_routes as email_routes
def _slow_open(host, port, **kwargs):
time.sleep(1.0) # a mail server that never answers within the timeout
raise socket.timeout("timed out")
monkeypatch.setattr(email_routes, "_open_imap_connection", _slow_open)
endpoint = _accounts_test_endpoint(email_routes)
class _Request:
async def json(self):
return {"imap_host": "imap.example.com", "imap_port": 993, "imap_user": "u", "imap_password": "p"}
async def _main():
gaps = []
done = asyncio.Event()
async def _heartbeat():
last = time.perf_counter()
while not done.is_set():
await asyncio.sleep(0.02)
now = time.perf_counter()
gaps.append(now - last)
last = now
beat = asyncio.create_task(_heartbeat())
started = time.perf_counter()
results = await asyncio.gather(*(endpoint(req=_Request(), owner="alice") for _ in range(3)))
elapsed = time.perf_counter() - started
done.set()
await beat
return results, max(gaps), elapsed
results, worst_gap, elapsed = asyncio.run(_main())
assert worst_gap < 0.5, f"event loop stalled for {worst_gap:.2f}s"
assert elapsed < 2.5 # three 1s probes overlapped instead of serialising on the loop
for result in results:
assert result == {"ok": False, "imap": {"ok": False, "error": "IMAP connection timed out"}, "smtp": None}
def test_connection_test_keeps_its_authentication_dependency():
import routes.email_routes as email_routes
from routes.email.email_routes import require_user
router = email_routes.setup_email_routes()
route = next(
r for r in router.routes
if r.path == "/api/email/accounts/test" and "POST" in getattr(r, "methods", set())
)
assert any(dep.call is require_user for dep in route.dependant.dependencies)
# -- 4. /search/web under the production CSP ---------------------------------------
_PAYLOAD = (
"</script><script>document.title='PWNED'</script>"
"<img src=x onerror=\"document.body.dataset.pwned='1'\">"
)
_SOURCES = [
{"title": "Example result", "url": "https://example.com/a", "snippet": "first"},
{"title": "Script link", "url": "javascript:document.body.dataset.pwned='2'", "snippet": "second"},
]
def _csp_app():
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from core.middleware import SecurityHeadersMiddleware
from routes.search.search_routes import setup_search_routes
app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)
@app.post("/api/search")
async def _stub_search():
return {"sources": _SOURCES}
@app.get("/csp-control")
async def _control():
return HTMLResponse("<html><body><script>document.body.dataset.ran='1'</script></body></html>")
app.include_router(setup_search_routes(SimpleNamespace()))
return app
@pytest.mark.parametrize("q", ["weather in lisbon", _PAYLOAD])
def test_search_page_script_carries_the_response_csp_nonce(q):
from fastapi.testclient import TestClient
response = TestClient(_csp_app()).get("/search/web", params={"q": q})
csp = response.headers["content-security-policy"]
nonce = re.search(r"'nonce-([^']+)'", csp).group(1)
assert "unsafe-inline" not in csp.split("script-src", 1)[1].split(";", 1)[0]
scripts = re.findall(r"<script\b[^>]*>", response.text, re.I)
assert scripts == [f'<script nonce="{nonce}">']
markup = response.text.split("<script", 1)[0]
assert "<img" not in markup.lower() # the payload is only ever present HTML-escaped
def _chrome():
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
path = shutil.which(name)
if path:
return path
return None
@pytest.fixture
def live_csp_server():
uvicorn = pytest.importorskip("uvicorn")
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
server = uvicorn.Server(uvicorn.Config(_csp_app(), host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 10
while not server.started and time.time() < deadline:
time.sleep(0.05)
yield f"http://127.0.0.1:{port}"
server.should_exit = True
thread.join(timeout=10)
def _render(chrome, url, profile):
out = subprocess.run(
[chrome, "--headless=new", "--disable-gpu", "--no-sandbox", "--no-first-run",
f"--user-data-dir={profile}", "--virtual-time-budget=5000", "--dump-dom", url],
capture_output=True, text=True, timeout=90,
)
return out.stdout
@pytest.mark.skipif(_chrome() is None, reason="needs a headless Chrome/Chromium")
def test_search_page_runs_under_production_csp_and_payload_does_not(live_csp_server, tmp_path):
chrome = _chrome()
# Control: the production CSP really blocks nonce-less inline script.
control = _render(chrome, f"{live_csp_server}/csp-control", tmp_path / "p0")
assert "<body>" in control and "data-ran" not in control
normal = _render(chrome, f"{live_csp_server}/search/web?q=weather", tmp_path / "p1")
assert "2 results" in normal # the page's script executed
assert 'href="https://example.com/a"' in normal
assert "javascript:" not in normal.split('id="results"', 1)[1] # non-http result URL not linked
hostile = _render(
chrome, f"{live_csp_server}/search/web?q={urllib.parse.quote(_PAYLOAD)}", tmp_path / "p2",
)
assert "2 results" in hostile # search still ran with that query
assert hostile.split("<title>", 1)[1].split("</title>", 1)[0] != "PWNED" # escaped text, not run
assert "data-pwned" not in hostile
@@ -0,0 +1,260 @@
"""Follow-up security fixes for the residual Python CodeQL lane (PR #6503).
A. upload-reference scanners stay linear and match the regexes they replaced.
B. /api/hwfit/* (SSH host probing, host path probing) is admin-only.
C. IMAP/SMTP connections honour the outbound address policy, and the account
connection test never echoes a non-mail service's bytes.
D. attachment extraction directories are per owner/account, not per folder/UID.
"""
import asyncio
import contextlib
import imaplib
import re
import smtplib
import socket
import ssl
import time
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from types import SimpleNamespace
import pytest
# -- A. upload reference scanners ---------------------------------------------
_HEX = "a" * 32
_PDF_REF = re.compile(
r"<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id=[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"'][^>]*-->",
re.IGNORECASE,
)
_ATTACHMENT_REF = re.compile(
r"\[Attachment:[^\]\r\n]*\|\s*id=([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)(?:\s*\||\s*\])",
re.IGNORECASE,
)
@pytest.mark.parametrize("text", [
f"<!-- pdf_source upload_id='{_HEX}' -->",
f"<!--PDF_FORM_SOURCE x upload_id=\"{_HEX}.pdf\" y-->",
f"<!-- pdf_source upload_id='{_HEX}' upload_id='{'b' * 32}' -->",
f"<!-- pdf_source upload_id='{_HEX}' ->",
f"<!-- pdf_sourceupload_id='{_HEX}' -->",
f"<!--pdf_source <!--pdf_source upload_id='{_HEX}'-->",
"<!-- pdf_source no id -->",
])
def test_pdf_source_ids_match_reference(text):
from src.upload_handler import _pdf_source_upload_ids
assert _pdf_source_upload_ids(text) == _PDF_REF.findall(text)
@pytest.mark.parametrize("text", [
f"[Attachment: report.pdf | id={_HEX} | 12 KB]",
f"[attachment: a | b | id={_HEX}.txt]",
f"[Attachment: a | id={_HEX} | id={'b' * 32}]",
f"[Attachment: x |\nid={_HEX}]",
f"[Attachment: [Attachment: n | id={_HEX}]",
f"[Attachment: n | id={_HEX}x]",
"[Attachment: no id here]",
])
def test_attachment_reference_ids_match_reference(text):
from src.upload_handler import _attachment_reference_ids
assert _attachment_reference_ids(text) == _ATTACHMENT_REF.findall(text)
@pytest.mark.parametrize("flood", [
"[attachment:" * 20_000, # CodeQL's reported shape (#615)
"[Attachment: a |" * 15_000,
"<!-- pdf_source upload_id=" * 9_000, # same sink, other pattern
"<!--pdf_source " + f"upload_id='{_HEX}' " * 5_000 + "-->",
])
def test_internal_upload_id_extraction_is_linear(flood):
from src.upload_handler import extract_internal_upload_ids
started = time.perf_counter()
extract_internal_upload_ids(flood)
assert time.perf_counter() - started < 2.0
# -- B. hwfit authorization -----------------------------------------------------
def _hwfit_client(monkeypatch, *, auth_disabled=False):
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
import core.middleware as middleware
import services.hwfit.hardware as hardware
from routes.hwfit_routes import setup_hwfit_routes
probes = []
monkeypatch.setattr(middleware, "auth_disabled", lambda: auth_disabled)
monkeypatch.setattr(
hardware, "detect_system",
lambda **kwargs: probes.append(kwargs) or {"backend": "cpu_x86"},
)
app = FastAPI()
app.state.auth_manager = SimpleNamespace(is_configured=True, is_admin=lambda user: user == "root")
@app.middleware("http")
async def _user(request: Request, call_next):
request.state.current_user = request.headers.get("x-test-user")
return await call_next(request)
app.include_router(setup_hwfit_routes())
return TestClient(app), probes
@pytest.mark.parametrize("path", [
"/api/hwfit/system?host=10.0.0.5&ssh_port=2222",
"/api/hwfit/models?host=169.254.169.254",
"/api/hwfit/profiles?model_path=/etc&host=",
"/api/hwfit/image-models?host=internal-db",
])
def test_hwfit_rejects_non_admin_before_any_probe(monkeypatch, path):
client, probes = _hwfit_client(monkeypatch)
response = client.get(path, headers={"x-test-user": "alice"})
assert response.status_code == 403
assert probes == []
def test_hwfit_allows_admin_and_single_user(monkeypatch):
client, probes = _hwfit_client(monkeypatch)
assert client.get("/api/hwfit/system", headers={"x-test-user": "root"}).status_code == 200
client, probes = _hwfit_client(monkeypatch, auth_disabled=True)
assert client.get("/api/hwfit/system", headers={"x-test-user": "anyone"}).status_code == 200
assert probes
# -- C. mail server address policy and test-connection errors -------------------
def test_imap_and_smtp_helpers_refuse_metadata_hosts_before_any_socket(monkeypatch):
"""Real helper paths (no stdlib patching): the policy rejects a literal
metadata address before a socket is even created. Policy matrix and DNS
rebinding coverage live in test_codeql_python_residual_closure.py."""
import routes.email.email_helpers as helpers
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
with pytest.raises(helpers.MailServerAddressBlocked):
helpers._open_imap_connection("169.254.169.254", 993, starttls=False, owner="alice")
with pytest.raises(helpers.MailServerAddressBlocked):
helpers._send_smtp_message(
{"smtp_host": "169.254.169.254", "smtp_port": 587, "owner": "alice"}, "a@b", ["c@d"], "x",
)
@pytest.mark.parametrize("error,expected,leak", [
(imaplib.IMAP4.abort("unexpected response: b'SSH-2.0-OpenSSH_9.6 internal-db'"),
"IMAP server did not respond like an IMAP server", "OpenSSH"),
(smtplib.SMTPConnectError(554, b"-ERR redis 7.2 internal-cache"),
"SMTP server did not respond like an SMTP server", "redis"),
(smtplib.SMTPServerDisconnected("Connection unexpectedly closed: HTTP/1.1 400"),
"SMTP server did not respond like an SMTP server", "HTTP/1.1"),
(ConnectionRefusedError(111, "Connection refused 10.0.0.5:6379"), "IMAP connection refused", "6379"),
(socket.timeout("timed out"), "IMAP connection timed out", "10.0"),
(ssl.SSLError(1, "[SSL: WRONG_VERSION_NUMBER] banner: nginx"),
"IMAP TLS handshake failed; check the port and security setting", "nginx"),
])
def test_connection_test_errors_never_echo_peer_bytes(error, expected, leak):
from routes.email.email_helpers import _mail_connection_test_error
protocol = expected.split()[0]
message = _mail_connection_test_error(protocol, "10.0.0.5", error)
assert message == expected
assert leak not in message
def test_connection_test_keeps_mail_server_auth_responses():
from routes.email.email_helpers import _mail_connection_test_error
assert _mail_connection_test_error(
"IMAP", "imap.example.com", imaplib.IMAP4.error("[AUTHENTICATIONFAILED] Invalid credentials"),
) == "[AUTHENTICATIONFAILED] Invalid credentials"
microsoft = _mail_connection_test_error(
"SMTP", "smtp.office365.com",
smtplib.SMTPAuthenticationError(535, b"5.7.139 Authentication unsuccessful"),
)
assert "Microsoft no longer accepts" in microsoft
def test_account_connection_test_route_applies_policy_to_both_protocols(monkeypatch):
import routes.email_routes as email_routes
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
router = email_routes.setup_email_routes()
endpoint = next(
r.endpoint for r in router.routes
if r.path == "/api/email/accounts/test" and "POST" in getattr(r, "methods", set())
)
class _Request:
async def json(self):
return {
"imap_host": "169.254.169.254", "imap_port": 993, "imap_user": "u", "imap_password": "p",
"smtp_host": "169.254.169.254", "smtp_port": 587,
}
result = asyncio.run(endpoint(req=_Request(), owner="alice"))
policy = "server address is not allowed by this server's network policy"
assert result["ok"] is False
assert result["imap"]["error"] == f"IMAP {policy}"
assert result["smtp"]["error"] == f"SMTP {policy}"
# -- D. per-owner/account attachment extraction -------------------------------
def _message_with_attachment(body: bytes) -> bytes:
msg = MIMEMultipart()
msg.attach(MIMEText("see attached", "plain"))
part = MIMEApplication(body, Name="invoice.pdf")
part["Content-Disposition"] = 'attachment; filename="invoice.pdf"'
msg.attach(part)
return msg.as_bytes()
def test_attachment_extract_dir_separates_owners_and_accounts(tmp_path, monkeypatch):
import routes.email.email_helpers as helpers
monkeypatch.setattr(helpers, "ATTACHMENTS_DIR", tmp_path)
alice = helpers.attachment_extract_dir("INBOX", "42", owner="alice", account_id="a1")
assert alice != helpers.attachment_extract_dir("INBOX", "42", owner="bob", account_id="a1")
assert alice != helpers.attachment_extract_dir("INBOX", "42", owner="alice", account_id="a2")
assert alice != helpers.attachment_extract_dir("INBOX/42", "", owner="alice", account_id="a1")
assert alice.parent == tmp_path.resolve()
def test_two_users_same_folder_uid_cannot_overwrite_each_others_attachment(tmp_path, monkeypatch):
"""Two principals, same INBOX UID 42, same attachment filename.
Before the fix both extractions landed in ATTACHMENTS_DIR/INBOX_42/, so
bob's request rewrote the file whose path alice's agent was about to read.
"""
import routes.email.email_helpers as helpers
import routes.email_routes as email_routes
monkeypatch.setattr(helpers, "ATTACHMENTS_DIR", tmp_path)
mailboxes = {
"alice": _message_with_attachment(b"%PDF alice private invoice"),
"bob": _message_with_attachment(b"%PDF bob controlled content"),
}
@contextlib.contextmanager
def _fake_imap(account_id=None, owner=""):
yield SimpleNamespace(owner=owner, select=lambda *a, **k: ("OK", [b"1"]))
monkeypatch.setattr(email_routes, "_imap", _fake_imap)
monkeypatch.setattr(
email_routes, "_imap_uid_fetch",
lambda conn, uid, query: ("OK", [(b"42 (RFC822)", mailboxes[conn.owner])]),
)
router = email_routes.setup_email_routes()
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/email/attachment-path/{uid}/{index}")
alice = asyncio.run(endpoint(uid="42", index=0, folder="INBOX", account_id=None, owner="alice"))
bob = asyncio.run(endpoint(uid="42", index=0, folder="INBOX", account_id=None, owner="bob"))
assert alice["filename"] == bob["filename"] == "invoice.pdf"
assert alice["path"] != bob["path"]
with open(alice["path"], "rb") as handle:
assert handle.read() == b"%PDF alice private invoice"
@@ -0,0 +1,164 @@
"""Adversarial tests for the residual Python CodeQL fixes (PR #6503 lane).
Covers py/reflective-xss (search page), py/path-injection (context_info host
path oracle) and py/stack-trace-exposure (readiness, stream errors, task
drafting).
"""
import asyncio
import json
import re
from types import SimpleNamespace
import pytest
# -- py/reflective-xss: GET /search/web?q= ----------------------------------
_XSS_QUERIES = [
'</script><script src="https://cdn.jsdelivr.net/gh/evil/x@1/p.js"></script>',
"</SCRIPT><img src=x onerror=alert(1)>",
"<!--<script>",
"a & b < c > d 
 \"quoted\" 'single'",
]
def _search_page(q):
from routes.search.search_routes import setup_search_routes
router = setup_search_routes(SimpleNamespace())
endpoint = next(r.endpoint for r in router.routes if r.path == "/search/web")
request = SimpleNamespace(state=SimpleNamespace(csp_nonce="n0nce"))
return asyncio.run(endpoint(request=request, q=q)).body.decode("utf-8")
@pytest.mark.parametrize("q", _XSS_QUERIES)
def test_search_page_query_cannot_break_out_of_inline_script(q):
page = _search_page(q)
script = page.split('<script nonce="n0nce">', 1)[1]
# The page's own closing tag is the only one; nothing from q survives raw.
assert script.lower().count("</script") == 1
assert "<!--" not in script
literal = re.search(r"const initialQuery = (.*?);\n", script).group(1)
assert "<" not in literal and ">" not in literal and "&" not in literal
# The JS string value is unchanged for the search itself.
assert json.loads(literal) == q.strip()
# -- py/path-injection: /session/{id}/context_info?cwd= ----------------------
def _context_info(monkeypatch, *, admin, cwd):
import routes.session_routes as routes
from fastapi import APIRouter
monkeypatch.setattr(routes, "_verify_session_owner", lambda *args: None)
monkeypatch.setattr("src.tool_security.blocked_tools_for_owner", lambda owner: set())
monkeypatch.setattr("src.tool_security.owner_is_admin_or_single_user", lambda owner: admin)
monkeypatch.setattr(routes, "router", APIRouter(prefix="/api"))
session = SimpleNamespace(endpoint_url="", model="", cwd="")
router = routes.setup_session_routes(SimpleNamespace(get_session=lambda sid: session), {})
endpoint = next(
r.endpoint for r in router.routes if r.path == "/api/session/{session_id}/context_info"
)
request = SimpleNamespace(state=SimpleNamespace(
api_token=False, api_token_owner=None, api_token_scopes=[], current_user="someone",
))
return asyncio.run(endpoint(request, "session-1", cwd=cwd))
def test_context_info_hides_host_paths_from_non_admin(monkeypatch, tmp_path):
(tmp_path / "AGENTS.md").write_text("x", encoding="utf-8")
result = _context_info(monkeypatch, admin=False, cwd=str(tmp_path))
assert result["agents_md"] == []
assert result["workspace"]["exists_in_backend"] is False
def test_context_info_still_reports_host_paths_for_admin(monkeypatch, tmp_path):
(tmp_path / "AGENTS.md").write_text("x", encoding="utf-8")
result = _context_info(monkeypatch, admin=True, cwd=str(tmp_path))
assert {"path": str(tmp_path / "AGENTS.md"), "source": "workspace"} in result["agents_md"]
assert result["workspace"]["exists_in_backend"] is True
# -- py/stack-trace-exposure ---------------------------------------------------
_SECRET = "postgresql://odysseus:hunter2@10.0.0.5/prod /srv/odysseus/data"
def test_readiness_does_not_return_raw_database_error(monkeypatch):
import core.database as database
from src.readiness import check_readiness
class _Engine:
def connect(self):
raise RuntimeError(_SECRET)
monkeypatch.setattr(database, "engine", _Engine())
result = check_readiness()
db_check = result["checks"]["database"]
assert db_check == {"ok": False, "error_type": "RuntimeError"}
assert "hunter2" not in json.dumps(result)
assert result["ready"] is False
def test_readiness_does_not_return_raw_data_dir_error(monkeypatch, tmp_path):
import core.constants as constants
from src.readiness import check_readiness
blocker = tmp_path / "not-a-dir"
blocker.write_text("file, not a directory", encoding="utf-8")
monkeypatch.setattr(constants, "DATA_DIR", str(blocker / "data"))
result = check_readiness()
data_check = result["checks"]["data_dir"]
assert data_check["ok"] is False
assert set(data_check) == {"ok", "error_type"}
assert str(tmp_path) not in json.dumps(result)
def test_stream_failure_message_is_generic():
from src.llm_core import _stream_failure_message
assert _stream_failure_message(RuntimeError(_SECRET)) == "Model stream failed (RuntimeError)"
def test_catch_all_stream_error_events_do_not_embed_exception_text():
from pathlib import Path
source = (Path(__file__).resolve().parents[1] / "src/llm_core.py").read_text(encoding="utf-8")
assert '{"error": str(e), "status": 502' not in source
def _parse_task(monkeypatch, exc):
from fastapi import HTTPException # noqa: F401 (raised by callers below)
from unittest.mock import MagicMock
import routes.task.task_routes as task_routes
import src.endpoint_resolver as resolver
import src.llm_core as llm_core
async def _boom(*args, **kwargs):
raise exc
monkeypatch.setattr(resolver, "resolve_endpoint", lambda *a, **k: ("http://llm", "m", {}))
monkeypatch.setattr(llm_core, "llm_call_async", _boom)
router = task_routes.setup_task_routes(MagicMock())
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/tasks/parse")
class _Request:
state = SimpleNamespace(current_user="alice")
async def json(self):
return {"description": "every day at 9 summarize the news"}
return asyncio.run(endpoint(_Request()))
def test_parse_task_hides_unexpected_exception_text(monkeypatch):
result = _parse_task(monkeypatch, RuntimeError(_SECRET))
assert result == {"success": False, "message": "Could not draft a task (RuntimeError)"}
def test_parse_task_keeps_curated_upstream_http_error(monkeypatch):
from fastapi import HTTPException
result = _parse_task(monkeypatch, HTTPException(401, "Upstream rejected the API key"))
assert result == {"success": False, "message": "Upstream rejected the API key"}
+6 -6
View File
@@ -26,7 +26,7 @@ class _FakeSock:
class _FakeIMAP:
calls = []
def __init__(self, host, port, timeout=None):
def __init__(self, host, port, timeout=None, **_policy):
self.host = host
self.port = port
self.timeout = timeout
@@ -60,8 +60,8 @@ def test_open_imap_connection_uses_shared_timeout_for_implicit_ssl(monkeypatch):
import routes.email_helpers as helpers
_FakeIMAP.calls = []
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
conn = _open_imap_connection("imap.one.com", 993, starttls=False)
@@ -75,8 +75,8 @@ def test_open_imap_connection_supports_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeIMAP.calls = []
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
_open_imap_connection("imap.local", 143, starttls=True)
@@ -99,7 +99,7 @@ async def test_account_config_uses_shared_imap_timeout(monkeypatch):
def logout(self):
captured["logout"] = True
def fake_open(host, port, *, starttls, timeout):
def fake_open(host, port, *, starttls, timeout, **_policy):
captured["open"] = (host, port, starttls, timeout)
return _Conn()
+1 -1
View File
@@ -64,7 +64,7 @@ def test_imap_connect_with_host_still_connects(monkeypatch):
def login(self, user, password):
opened["login"] = (user, password)
def _fake_open(host, port, *, starttls, timeout):
def _fake_open(host, port, *, starttls, timeout, **_policy):
opened["host"] = host
return _FakeConn()
+9 -9
View File
@@ -12,7 +12,7 @@ from routes.email_helpers import _send_smtp_message
class _FakeSMTP:
calls = []
def __init__(self, host, port, timeout=None):
def __init__(self, host, port, timeout=None, **_policy):
self.host = host
self.port = port
self.timeout = timeout
@@ -54,8 +54,8 @@ def test_send_smtp_message_supports_plain_smtp(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello")
@@ -68,8 +68,8 @@ def test_send_smtp_message_supports_explicit_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello")
@@ -82,8 +82,8 @@ def test_send_smtp_message_defaults_587_to_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
cfg = _cfg("", port=587)
_send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello")
@@ -96,8 +96,8 @@ def test_send_smtp_message_uses_ssl_when_configured(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello")
+12 -12
View File
@@ -89,8 +89,8 @@ async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -141,8 +141,8 @@ async def test_test_connection_password_account_still_uses_login():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn):
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is True
@@ -187,8 +187,8 @@ async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -238,8 +238,8 @@ async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -336,10 +336,10 @@ async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_helpers.imaplib.IMAP4",
"routes.email_helpers._PolicyIMAP4",
return_value=starttls_conn,
) as imap_cls, mock.patch(
"routes.email_helpers.imaplib.IMAP4_SSL",
"routes.email_helpers._PolicyIMAP4_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as imap_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
@@ -412,10 +412,10 @@ async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_routes.smtplib.SMTP",
"routes.email_routes._PolicySMTP",
return_value=starttls_smtp,
) as smtp_cls, mock.patch(
"routes.email_routes.smtplib.SMTP_SSL",
"routes.email_routes._PolicySMTP_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as smtp_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
+1 -1
View File
@@ -281,7 +281,7 @@ def test_open_imap_connection_shuts_down_on_starttls_failure(monkeypatch):
))
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
monkeypatch.setattr(helpers.imaplib, "IMAP4", lambda *a, **kw: conn)
monkeypatch.setattr(helpers, "_PolicyIMAP4", lambda *a, **kw: conn)
raised = False
try:
@@ -0,0 +1,60 @@
"""manage_notes reminder-time sniffing must stay linear (CodeQL py/polynomial-redos).
`\\d{1,2}(?::\\d{2})?\\s*(?:am|pm)?\\s+today` let `\\s*` and `\\s+` split one
whitespace run every possible way, so a digit followed by a long whitespace
run in model-supplied note text was O(n^2). The rewrite groups the optional
meridiem with its own whitespace: same language, one way to match.
"""
import json
import sys
import time
import uuid
import pytest
from tests.helpers.import_state import clear_fake_database_modules
from tests.helpers.sqlite_db import make_temp_sqlite
clear_fake_database_modules()
import core.database as cdb
_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata)
@pytest.fixture(autouse=True)
def _bind_temp_db(monkeypatch):
monkeypatch.setitem(sys.modules, "core.database", cdb)
parent = sys.modules.get("core")
if parent is not None:
monkeypatch.setattr(parent, "database", cdb, raising=False)
monkeypatch.setattr(cdb, "SessionLocal", _TS)
yield
async def _add(content):
from src.tool_implementations import do_manage_notes
return await do_manage_notes(
json.dumps({"action": "add", "title": "remind me", "content": content}),
owner="tester-" + uuid.uuid4().hex[:6],
)
async def test_reminder_time_sniffing_whitespace_flood_is_linear():
started = time.perf_counter()
res = await _add("remind 1" + "\t" * 40_000 + "x")
assert time.perf_counter() - started < 2.0
assert res.get("exit_code", 0) == 0, res
async def test_reminder_time_sniffing_still_finds_time_first_phrases():
res = await _add("remind me to call mom, 9 pm tomorrow")
assert res.get("exit_code", 0) == 0, res
db = _TS()
try:
note = db.query(cdb.Note).filter(cdb.Note.id == res["note_id"]).first()
assert note is not None and "T21:00:00" in (note.due_date or ""), note and note.due_date
finally:
db.close()
+183
View File
@@ -0,0 +1,183 @@
"""Regression tests for the residual py/polynomial-redos fixes (PR #6503 lane).
Each rewritten matcher is pinned two ways:
* equivalence with the regex it replaced on ordinary inputs, and
* the CodeQL-reported adversarial shape completing within a loose budget
(the replaced regexes took seconds to tens of seconds on these sizes).
"""
import re
import time
from pathlib import Path
import pytest
from src.text_helpers import strip_closed_think_blocks
_BUDGET_S = 2.0
_ROOT = Path(__file__).resolve().parents[1]
def _fast(fn, *args):
started = time.perf_counter()
result = fn(*args)
elapsed = time.perf_counter() - started
assert elapsed < _BUDGET_S, f"{getattr(fn, '__name__', fn)} took {elapsed:.2f}s"
return result
# -- closed <think> blocks (skills_routes x4, memory_extractor.audit_memories) --
_THINK_REF = re.compile(r"<think(?:ing)?>[\s\S]*?</think(?:ing)?>", re.I)
@pytest.mark.parametrize("text", [
"",
"plain",
"<think>x</think>{\"ok\": true}",
"a<thinking>b</thinking>c<think>d</think>e",
"<think>a<think>nested</think>rest</think>",
"orphan</think> then <think>unclosed",
"<THINK>case</Thinking>kept",
"<think>\nmulti\nline\n</think>\n{}",
])
def test_strip_closed_think_blocks_matches_reference(text):
assert strip_closed_think_blocks(text) == _THINK_REF.sub("", text)
def test_strip_closed_think_blocks_unclosed_opener_flood_is_linear():
flood = "<think>" * 40_000
assert _fast(strip_closed_think_blocks, flood) == flood
def test_lazy_think_regex_is_gone_from_llm_review_parsers():
for rel in ("routes/skills_routes.py", "services/memory/memory_extractor.py"):
source = (_ROOT / rel).read_text(encoding="utf-8")
assert r"<think(?:ing)?>[\s\S]*?</think(?:ing)?>" not in source, rel
# -- email compose markdown links (email_routes._md_to_email_html) -----------
_LINK_REF = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
@pytest.mark.parametrize("text", [
"see [docs](https://example.com/a) and [b](http://x.y)",
"[a[b](http://x)",
"[]()[x](http://y)",
"[a](http://x [b c](http://y)",
"[a](http://)[b](https://z)",
"[a](ftp://x) [b](https://ok)",
"[unterminated(http://x)",
"[t](http://u) tail ] [",
])
def test_md_links_to_html_matches_reference(text):
from routes.email.email_routes import _md_links_to_html
assert _md_links_to_html(text) == _LINK_REF.sub(r'<a href="\2">\1</a>', text)
@pytest.mark.parametrize("flood", [
"[" + "[\\" * 40_000, # CodeQL's reported shape
"[a](http://x" * 8_000, # URL that never closes, repeated
])
def test_md_links_to_html_floods_are_linear(flood):
from routes.email.email_routes import _md_links_to_html
assert _fast(_md_links_to_html, flood) == flood
def test_md_to_email_html_still_renders_and_escapes_links():
from routes.email.email_routes import _md_to_email_html
html = _md_to_email_html("**hi** [site](https://example.com/x)\n<script>")
assert '<a href="https://example.com/x">site</a>' in html
assert "<strong>hi</strong>" in html
assert "<script>" not in html
# -- PDF URL detection (chat_routes._prefers_structured_document_tools) ------
_PDF_REF = re.compile(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", re.I)
@pytest.mark.parametrize("text", [
"read https://arxiv.org/pdf/2401.0001 please",
"https://x.org/paper.PDF",
"https://x.org/paper.pdfx",
"xhttps://a/b.pdf",
"http:///pdf/",
"http://a
.pdf",
"no links here.pdf",
"https://a/b http://c.pdf",
])
def test_mentions_pdf_url_matches_reference(text):
from routes.chat_routes import _mentions_pdf_url
assert _mentions_pdf_url(text) is bool(_PDF_REF.search(text))
def test_mentions_pdf_url_scheme_flood_is_linear():
from routes.chat_routes import _prefers_structured_document_tools
assert _fast(_prefers_structured_document_tools, "http://" * 8_000) is False
# -- chat_helpers persistence cleanup ----------------------------------------
def test_clean_repeated_assistant_content_trailing_think_closer():
from routes.chat_helpers import clean_repeated_assistant_content
prose = "word " * 30 + "done" # past the 120-char edge-closer window
assert clean_repeated_assistant_content(prose + " </think> ") == prose
whitespace_run = "a" + "\t" * 80_000 + "x"
assert _fast(clean_repeated_assistant_content, whitespace_run) == whitespace_run
@pytest.mark.parametrize("text,expected", [
("The user wants a hi.\n\n<think>Hey there!</think>",
"<think>The user wants a hi.</think>\nHey there!"),
("I should greet.\n<thinking> Hello ", "<think>I should greet.</think>\nHello"),
("Plain answer <think>x</think>", "Plain answer <think>x</think>"),
])
def test_normalize_thinking_garbled_tags_unchanged(text, expected):
from routes.chat_helpers import _normalize_thinking
assert _normalize_thinking(text) == expected
@pytest.mark.parametrize("flood", [
"a" + "\n" * 80_000 + "x",
"The user x\n<think>" + "\t" * 80_000 + "x",
])
def test_normalize_thinking_floods_are_linear(flood):
from routes.chat_helpers import _normalize_thinking
_fast(_normalize_thinking, flood)
# -- calendar natural-language datetimes (GET /events, POST /events) ---------
def test_calendar_time_first_phrases_still_parse():
from routes.calendar_routes import _parse_dt, parse_due_for_user
parsed = _parse_dt("3pm tomorrow")
assert (parsed.hour, parsed.minute) == (15, 0)
assert "T09:00:00" in parse_due_for_user("9 a.m today")
assert "T23:00:00" in parse_due_for_user("11pm tonight")
@pytest.mark.parametrize("payload", [
"a" + "\t" * 80_000 + "x", # CodeQL's reported shape (time-first regex)
"a" + "\t" * 80_000 + "m", # am/pm normaliser on the same input
])
def test_calendar_parsers_whitespace_flood_is_linear(payload):
from routes.calendar_routes import _parse_dt, parse_due_for_user
for fn in (_parse_dt, parse_due_for_user):
started = time.perf_counter()
try:
fn(payload)
except Exception:
pass # unparseable is fine; only the time matters here
assert time.perf_counter() - started < _BUDGET_S, fn.__name__
+5 -3
View File
@@ -984,7 +984,7 @@ def test_attachment_extract_dir_stays_contained(folder, uid):
"""User-controlled folder/uid must never escape ATTACHMENTS_DIR — pins the
fix for the attachment-extraction path traversal."""
aed, base = _import_attachment_extract_dir()
target = aed(folder, uid)
target = aed(folder, uid, owner="../../owner", account_id="../acct")
base_r = base.resolve()
assert target == base_r or base_r in target.parents
# exactly one extra path segment, and no `..` component survived
@@ -992,9 +992,11 @@ def test_attachment_extract_dir_stays_contained(folder, uid):
assert ".." not in rel.parts
def test_attachment_extract_dir_normal_inputs_unchanged():
def test_attachment_extract_dir_is_stable_single_segment_per_scope():
aed, base = _import_attachment_extract_dir()
assert aed("INBOX", "123") == base.resolve() / "INBOX_123"
target = aed("INBOX", "123", owner="alice", account_id="acct-1")
assert target == aed("INBOX", "123", owner="alice", account_id="acct-1")
assert target.parent == base.resolve()
def test_diagnostics_routes_are_admin_gated():