mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
fix(security): harden Python service boundaries
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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("```"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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: "")
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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__
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user