fix(security): harden Python service boundaries

This commit is contained in:
Alexandre Teixeira
2026-10-06 03:16:54 +01:00
parent da3800b662
commit 3d91ad82cb
32 changed files with 1911 additions and 248 deletions
+4 -4
View File
@@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str:
lower = s.lower().strip()
def _parse_time(t):
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m: return None
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
@@ -433,7 +433,7 @@ def parse_due_for_user(s: str) -> str:
return base.replace(hour=t[0], minute=t[1]).isoformat()
# Time-first: "3pm today", "11pm today", "9am tomorrow"
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
if m:
time_part, word = m.group(1).strip(), m.group(2)
base = today
@@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime:
def _parse_time(t: str):
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m:
return None
@@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime:
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
# (parity with parse_due_for_user, which handles these via the same form)
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
if m:
time_part, word = m.group(1).strip(), m.group(2)
base = today
+9 -3
View File
@@ -98,7 +98,9 @@ def clean_repeated_assistant_content(text: object) -> str:
"",
value,
).strip()
value = re.sub(r"(?is)\s*</\s*think\s*>\s*$", "", value).strip()
# No leading `\s*`: .strip() removes that whitespace anyway, and scanning
# it from every offset of a long whitespace run was quadratic (ReDoS).
value = re.sub(r"(?is)</\s*think\s*>\s*$", "", value).strip()
return value
_CASUAL_OPENING_RE = re.compile(
@@ -1324,13 +1326,17 @@ def _normalize_thinking(text: str) -> str:
# Handle garbled <think> tags: reasoning text followed by <think> as separator
# e.g. "The user said...I should respond.\n<think>Hey! What's up?"
# Linear form of `^([\s\S]+?)\n*<think>\s*([\s\S]*?)(?:</think>)?\s*$`:
# the lookbehind stops the lazy prefix re-scanning a newline run from every
# offset, and the optional trailing closer is dropped after the match
# instead of being retried at every body offset (both were quadratic).
garbled = re.match(
r'^([\s\S]+?)\n*<think(?:ing)?>\s*([\s\S]*?)(?:</think(?:ing)?>)?\s*$',
r'^([\s\S]+?)(?<![\s\S]\n)\n*<think(?:ing)?>\s*([\s\S]*)$',
text, re.IGNORECASE
)
if garbled:
before = garbled.group(1).strip()
after = garbled.group(2).strip()
after = re.sub(r'</think(?:ing)?>$', '', garbled.group(2).rstrip(), flags=re.IGNORECASE).strip()
# Only treat as garbled if the part before <think> looks like reasoning
reasoning_starts = (
'The user ', 'I need ', 'I should ', 'I will ',
+20 -2
View File
@@ -327,13 +327,31 @@ def _is_external_discovery_request(text: str) -> bool:
))
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
def _mentions_pdf_url(value: str) -> bool:
"""Same as ``re.search(r"https?://[^\\s]+(?:\\.pdf\\b|/pdf/)", value, re.I)``.
Checked per whitespace-free token from its first scheme only (a later
scheme's tail is a suffix of the first's), so a token packed with
`http://` repeats is scanned once instead of once per repeat (ReDoS).
"""
for token in value.split():
scheme = _URL_SCHEME_RE.search(token)
if scheme and _PDF_URL_TAIL_RE.search(token, scheme.end() + 1):
return True
return False
def _prefers_structured_document_tools(text: str) -> bool:
"""Identify external paper/PDF extraction where shell is a bad source route."""
value = str(text or "")
if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I):
return False
return bool(
re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I)
_mentions_pdf_url(value)
or re.search(
r"\b(?:paper|report|study)\b[\s\S]{0,1200}?"
r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b",
@@ -2507,7 +2525,7 @@ def setup_chat_routes(
_explicit_web_intent = (
_explicit_url_target
or bool(re.search(
r"\b(search|look\s+(?:this|that|it|them|these|those)?\s*up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
r"\b(search|look\s+(?:(?:this|that|it|them|these|those)\s*)?up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
_msg_l,
))
or requires_external_web_verification(message)
+153 -16
View File
@@ -17,6 +17,8 @@ import base64
import time
import imaplib
import smtplib
import socket
import ssl
import email as email_mod
import email.header
import email.utils
@@ -36,6 +38,7 @@ from typing import Optional, List
from src.auth_helpers import _auth_disabled, get_current_user
from src.secret_storage import decrypt as _decrypt
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
logger = logging.getLogger(__name__)
@@ -160,6 +163,94 @@ def _smtp_security_mode(cfg: dict) -> str:
return "ssl"
# Raised when a mail host resolves into a denied range (see _mail_private_blocked).
MailServerAddressBlocked = OutboundAddressBlocked
def _mail_private_blocked(owner: str | None) -> bool:
"""Whether private/loopback/shared mail destinations are denied for *owner*.
Link-local (cloud metadata), multicast, reserved and unspecified addresses
are always denied (src/url_safety.py). Private ranges are where a mail
tester or saved account becomes an internal-network probe, so they are
only allowed for an admin or in single-user mode (AUTH_ENABLED=false), the
same principals trusted with host-level tools. Operators of multi-user
deployments with a LAN mail server opt in with EMAIL_ALLOW_PRIVATE_IPS=true;
EMAIL_BLOCK_PRIVATE_IPS=true denies private ranges for everyone.
"""
if os.getenv("EMAIL_BLOCK_PRIVATE_IPS", "").strip().lower() == "true":
return True
if os.getenv("EMAIL_ALLOW_PRIVATE_IPS", "").strip().lower() == "true":
return False
from src.tool_security import owner_is_admin_or_single_user
return not owner_is_admin_or_single_user((owner or "").strip() or None)
def _mail_socket_timeout(timeout):
return socket._GLOBAL_DEFAULT_TIMEOUT if timeout is None else timeout
class _PolicyIMAP4(imaplib.IMAP4):
"""IMAP4 whose socket comes from connect_outbound_tcp.
The mail host is resolved once and connected to only at the addresses the
address policy approved, so DNS rebinding between check and connect is
impossible. STARTTLS still verifies against ``self.host``.
"""
def __init__(self, host, port, *, block_private: bool, timeout=None):
self._block_private = block_private
super().__init__(host, port, timeout=timeout)
def _create_socket(self, timeout):
if timeout is not None and not timeout:
raise ValueError("Non-blocking socket (timeout=0) is not supported")
return connect_outbound_tcp(
self.host, self.port, timeout=_mail_socket_timeout(timeout), block_private=self._block_private,
)
class _PolicyIMAP4_SSL(imaplib.IMAP4_SSL):
"""IMAP4_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
def __init__(self, host, port, *, block_private: bool, timeout=None, ssl_context=None):
self._block_private = block_private
super().__init__(host, port, ssl_context=ssl_context, timeout=timeout)
def _create_socket(self, timeout):
sock = _PolicyIMAP4._create_socket(self, timeout)
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
class _PolicySMTP(smtplib.SMTP):
"""SMTP whose socket comes from connect_outbound_tcp (see _PolicyIMAP4)."""
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
self._block_private = block_private
super().__init__(host, port, **kwargs)
def _get_socket(self, host, port, timeout):
if timeout is not None and not timeout:
raise ValueError("Non-blocking socket (timeout=0) is not supported")
return connect_outbound_tcp(
host, port, timeout=timeout, block_private=self._block_private,
source_address=self.source_address,
)
class _PolicySMTP_SSL(smtplib.SMTP_SSL):
"""SMTP_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
self._block_private = block_private
super().__init__(host, port, **kwargs)
def _get_socket(self, host, port, timeout):
sock = _PolicySMTP._get_socket(self, host, port, timeout)
return self.context.wrap_socket(sock, server_hostname=self._host)
def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None:
"""Send through SMTP using the configured transport security mode."""
host = cfg["smtp_host"]
@@ -178,14 +269,15 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
smtp.login(user, password)
security = _smtp_security_mode(cfg)
block_private = _mail_private_blocked(cfg.get("owner"))
if security == "ssl":
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
with _PolicySMTP_SSL(host, port, timeout=timeout, block_private=block_private) as smtp:
_auth_smtp(smtp)
smtp.sendmail(from_addr, recipients, message)
return
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
with _PolicySMTP(host, port, timeout=timeout, block_private=block_private) as smtp:
if security == "starttls":
smtp.starttls()
_auth_smtp(smtp)
@@ -224,6 +316,39 @@ def _friendly_email_auth_error(protocol: str, host: str, error: object) -> str:
return raw[:200]
# Bound at import: callers (and tests) may swap out imaplib.IMAP4 itself.
_IMAP_ABORT = imaplib.IMAP4.abort
def _mail_connection_test_error(protocol: str, host: str, error: BaseException) -> str:
"""User-facing result for a failed IMAP/SMTP connection test.
Transport failures are reported by category, never with the peer's own
bytes: a non-mail service answering on the chosen host/port would
otherwise have its banner echoed back (an internal-service fingerprinting
oracle). Errors from a server that does speak the protocol (login/auth
rejections) keep their text via _friendly_email_auth_error, since that is
what the user needs to fix their settings.
"""
if isinstance(error, MailServerAddressBlocked):
return f"{protocol} server address is not allowed by this server's network policy"
if isinstance(error, (_IMAP_ABORT, smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected)):
return f"{protocol} server did not respond like an {protocol} server"
if isinstance(error, ssl.SSLCertVerificationError):
return f"{protocol} TLS certificate verification failed"
if isinstance(error, ssl.SSLError):
return f"{protocol} TLS handshake failed; check the port and security setting"
if isinstance(error, (socket.timeout, TimeoutError)):
return f"{protocol} connection timed out"
if isinstance(error, ConnectionRefusedError):
return f"{protocol} connection refused"
if isinstance(error, socket.gaierror):
return f"{protocol} server name could not be resolved"
if isinstance(error, OSError) and not isinstance(error, smtplib.SMTPException):
return f"{protocol} connection failed"
return _friendly_email_auth_error(protocol, host, error)
def _strip_think(text: str) -> str:
"""Email-flavored think strip — thin wrapper over the central helper.
@@ -682,18 +807,21 @@ def _ensure_sender_signatures_table(conn):
_lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}")
def attachment_extract_dir(folder: str, uid: str) -> Path:
"""Containment-safe extraction directory for an attachment.
def attachment_extract_dir(folder: str, uid: str, *, owner: str, account_id: str | None) -> Path:
"""Containment-safe extraction directory for one message's attachments.
`folder` and `uid` are user-controlled (query/path params). Flatten them to
a single safe path segment so a value like folder='../../tmp' can't escape
ATTACHMENTS_DIR, then assert containment as belt-and-suspenders."""
key = re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}") or "_"
target = (ATTACHMENTS_DIR / key).resolve()
base = ATTACHMENTS_DIR.resolve()
if target != base and base not in target.parents:
IMAP UIDs are small per-mailbox counters, so `folder_uid` alone put every
user's and every account's "INBOX 42" in one directory, where extractions
overwrote each other by filename (a path handed to one user's agent could
then hold another user's attachment). See src.mail_attachment_paths: the
directory is keyed by (owner, account, folder, uid) and contained in
ATTACHMENTS_DIR whatever `folder`/`uid` hold."""
from src.mail_attachment_paths import attachment_scope_dir
try:
return attachment_scope_dir(ATTACHMENTS_DIR, folder, uid, owner=owner, account_id=account_id)
except ValueError:
raise HTTPException(400, "Invalid attachment location")
return target
def _init_scheduled_db():
@@ -1074,6 +1202,8 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
cfg = {
"account_id": row.id,
"account_name": row.name,
# Principal for the outbound mail address policy.
"owner": row.owner or owner or "",
"smtp_host": row.smtp_host or "",
"smtp_port": int(row.smtp_port or 465),
"smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}),
@@ -1107,6 +1237,7 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
cfg = {
"account_id": resolved_id,
"account_name": "legacy",
"owner": owner or "",
"smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")),
"smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465),
"smtp_security": _smtp_security_mode({
@@ -1170,11 +1301,16 @@ def _open_imap_connection(
starttls: bool,
timeout: int = _IMAP_TIMEOUT_SECONDS,
ssl_context=None,
owner: str | None = None,
):
"""Open an IMAP connection using the configured security mode."""
"""Open an IMAP connection using the configured security mode.
`owner` is the principal the destination is judged for
(see _mail_private_blocked)."""
port = int(port or 993)
block_private = _mail_private_blocked(owner)
if starttls:
conn = imaplib.IMAP4(host, port, timeout=timeout)
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
try:
if ssl_context:
conn.starttls(ssl_context=ssl_context)
@@ -1190,9 +1326,9 @@ def _open_imap_connection(
raise
elif port == 993:
kwargs = {"ssl_context": ssl_context} if ssl_context else {}
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout, **kwargs)
conn = _PolicyIMAP4_SSL(host, port, timeout=timeout, block_private=block_private, **kwargs)
else:
conn = imaplib.IMAP4(host, port, timeout=timeout)
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
try:
conn.sock.settimeout(timeout)
except Exception:
@@ -1232,6 +1368,7 @@ def _imap_connect(account_id: str | None = None, owner: str = "",
cfg["imap_port"],
starttls=bool(cfg.get("imap_starttls")),
timeout=timeout,
owner=cfg.get("owner"),
)
try:
if cfg.get("oauth_provider") == "google":
+206 -153
View File
@@ -36,6 +36,7 @@ from pathlib import Path
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from starlette.concurrency import run_in_threadpool
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
from fastapi.responses import FileResponse, StreamingResponse
from src.constants import DATA_DIR
@@ -60,6 +61,7 @@ from .email_helpers import (
_fetch_sender_thread_context, _pre_retrieve_context,
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
_friendly_email_auth_error, _email_summary_failure_log_detail,
_mail_connection_test_error, _mail_private_blocked, _PolicySMTP, _PolicySMTP_SSL,
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
SendEmailRequest, ExtractStyleRequest,
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
@@ -1523,6 +1525,44 @@ def _envelope_recipients(*fields: str) -> list:
return out
_MD_LINK_URL_RE = re.compile(r"\((https?://)[^)\s]*")
def _md_links_to_html(s: str) -> str:
"""Linear ``re.sub(r"\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)", '<a href="\\2">\\1</a>', s)``.
Every `[` before the next `]` shares that `]`, so only the first can match;
and when a URL runs into whitespace/end without `)`, every later `[` whose
`]` also lies before that stop fails the same way. Skipping those spans
keeps a `[[[` or `[a](http://x[a](http://x` flood O(n) instead of the
regex's O(n^2).
"""
out: list[str] = []
emitted = scan = 0
while True:
open_at = s.find("[", scan)
if open_at < 0:
break
close_at = s.find("]", open_at + 1)
if close_at < 0:
break
scan = close_at + 1
if close_at == open_at + 1:
continue
url = _MD_LINK_URL_RE.match(s, close_at + 1)
if url is None:
continue
end = url.end()
if end < len(s) and s[end] == ")" and end > url.end(1):
out.append(s[emitted:open_at])
out.append(f'<a href="{s[url.start(1):end]}">{s[open_at + 1:close_at]}</a>')
emitted = scan = end + 1
elif end > url.end(1):
scan = max(scan, s.rfind("]", scan, end) + 1)
out.append(s[emitted:])
return "".join(out)
def _md_to_email_html(text: str) -> str:
"""Render the compose markdown body to a SAFE HTML fragment for the email's
text/html part. Everything is HTML-escaped FIRST (so a pasted <script> /
@@ -1538,7 +1578,7 @@ def _md_to_email_html(text: str) -> str:
s = re.sub(r"~~([^~]+)~~", r"<del>\1</del>", s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
# links: text + http(s) url only (escape() already neutralised quotes)
s = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r'<a href="\2">\1</a>', s)
s = _md_links_to_html(s)
return s
parts: list[str] = []
@@ -3900,7 +3940,7 @@ def setup_email_routes():
_row, att = fixture_att
filename = str(att.get("filename") or f"attachment-{index}.txt")
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
target_dir.mkdir(parents=True, exist_ok=True)
filepath = target_dir / safe_name
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
@@ -3919,7 +3959,7 @@ def setup_email_routes():
msg = email_mod.message_from_bytes(raw)
# Extract to a per-email folder
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"error": f"Attachment index {index} not found"}
@@ -3951,7 +3991,7 @@ def setup_email_routes():
if not attachments:
raise HTTPException(status_code=404, detail="No downloadable attachments")
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
zip_buf = io.BytesIO()
used_names: dict[str, int] = {}
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
@@ -4091,7 +4131,7 @@ def setup_email_routes():
att for att in _list_attachments_from_msg(msg)
if not _is_likely_signature_image_attachment(att)
]
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=acct)
for att in attachments:
idx = att.get("index")
if idx is None:
@@ -4156,7 +4196,7 @@ def setup_email_routes():
continue
if not ct.lower().startswith("image/"):
raise HTTPException(status_code=415, detail="Content-ID is not an image")
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, idx, target_dir)
if not filepath:
raise HTTPException(status_code=404, detail="Inline image not found")
@@ -4194,7 +4234,7 @@ def setup_email_routes():
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"error": f"Attachment index {index} not found"}
@@ -4522,7 +4562,9 @@ def setup_email_routes():
try:
content = filepath.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return {"error": f"Failed to read text file: {e}", "filename": base}
# OSError text includes the server-side extraction path.
logger.warning("Failed to read text attachment %s: %s", base, e)
return {"error": "Failed to read text file", "filename": base}
doc_id = _create_markdown_doc(content, "Imported from email attachment")
return {"doc_id": doc_id, "filename": filepath.name}
@@ -4539,7 +4581,7 @@ def setup_email_routes():
_row, att = fixture_att
filename = str(att.get("filename") or f"attachment-{index}.txt")
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
target_dir.mkdir(parents=True, exist_ok=True)
filepath = target_dir / safe_name
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
@@ -4553,7 +4595,7 @@ def setup_email_routes():
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"error": f"Attachment index {index} not found"}
@@ -5162,7 +5204,7 @@ def setup_email_routes():
return {"success": False, "error": "Email not found"}
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
target_dir = attachment_extract_dir(folder, uid)
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath:
return {"success": False, "error": f"Attachment index {index} not found"}
@@ -6860,6 +6902,158 @@ def setup_email_routes():
finally:
db.close()
def _test_mail_connections(body: dict, owner: str) -> dict:
"""Blocking IMAP/SMTP connection test for /accounts/test.
Runs in the threadpool: each probe can block for its full socket
timeout, which on the event loop stalled every other request. The
connections go through the outbound mail address policy for `owner`
(the user who chose the hosts)."""
imap_result = {"ok": False}
smtp_result = None
imap_host = (body.get("imap_host") or "").strip()
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
imap_user = (body.get("imap_user") or "").strip()
imap_pass = body.get("imap_password") or ""
imap_starttls = bool(body.get("imap_starttls"))
oauth_provider = body.get("oauth_provider") or ""
google_token = None
google_token_loaded = False
google_ssl_context = (
ssl.create_default_context()
if oauth_provider == "google"
else None
)
def _google_token():
nonlocal google_token, google_token_loaded
if not google_token_loaded:
google_token = _get_valid_google_token(body.get("account_id"), body)
google_token_loaded = True
if not google_token:
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
return google_token
if imap_port_err:
imap_result = {"ok": False, "error": imap_port_err}
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
else:
# Connection mode resolution:
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
# STARTTLS off + any other port → plain IMAP4 (no encryption)
# Without the last branch, local servers exposed on a non-993
# port (Dovecot on 31143, etc.) would always fail the SSL
# handshake because they're not actually wrapped in TLS.
try:
imap_kwargs = {
"starttls": imap_starttls,
"timeout": _IMAP_TIMEOUT_SECONDS,
"owner": owner,
}
if google_ssl_context:
imap_kwargs["ssl_context"] = google_ssl_context
conn = _open_imap_connection(
imap_host,
imap_port,
**imap_kwargs,
)
try:
if oauth_provider == "google":
token = _google_token()
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
else:
conn.login(imap_user, imap_pass)
imap_result = {"ok": True}
finally:
try: conn.logout()
except Exception: pass
except Exception as e:
imap_result = {"ok": False, "error": _mail_connection_test_error("IMAP", imap_host, e)}
smtp_host = (body.get("smtp_host") or "").strip()
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
if smtp_host and smtp_port_err:
smtp_result = {"ok": False, "error": smtp_port_err}
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
elif (
oauth_provider == "google"
and smtp_host
and not _google_oauth_smtp_transport_allowed(
smtp_port,
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
)
):
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
elif smtp_host:
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
smtp_user = (body.get("smtp_user") or imap_user).strip()
smtp_pass = body.get("smtp_password") or imap_pass
smtp = None
try:
if smtp_security == "ssl":
smtp_kwargs = (
{"context": google_ssl_context}
if google_ssl_context
else {}
)
smtp = _PolicySMTP_SSL(
smtp_host,
smtp_port,
timeout=10,
block_private=_mail_private_blocked(owner),
**smtp_kwargs,
)
else:
smtp = _PolicySMTP(smtp_host, smtp_port, timeout=10, block_private=_mail_private_blocked(owner))
if smtp_security == "starttls":
try:
if google_ssl_context:
smtp.starttls(context=google_ssl_context)
else:
smtp.starttls()
except Exception:
# STARTTLS failed before the auth cleanup block.
# Close the still-open plaintext socket explicitly.
try:
smtp.close()
except Exception:
pass
smtp = None
raise
if oauth_provider == "google":
token = _google_token()
smtp.ehlo()
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
else:
smtp.login(smtp_user, smtp_pass)
smtp_result = {"ok": True}
except Exception as e:
smtp_result = {"ok": False, "error": _mail_connection_test_error("SMTP", smtp_host, e)}
finally:
if smtp is not None:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
return {
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
"imap": imap_result,
"smtp": smtp_result,
}
@router.post("/accounts/test")
async def test_account_config(req: Request, owner: str = Depends(require_user)):
"""Try to actually connect to the provided IMAP (and optionally SMTP)
@@ -6923,148 +7117,7 @@ def setup_email_routes():
# inline test payloads select the OAuth branch or supply token data.
body = {key: value for key, value in body.items() if key not in _SERVER_OWNED_OAUTH_FIELDS}
imap_result = {"ok": False}
smtp_result = None
imap_host = (body.get("imap_host") or "").strip()
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
imap_user = (body.get("imap_user") or "").strip()
imap_pass = body.get("imap_password") or ""
imap_starttls = bool(body.get("imap_starttls"))
oauth_provider = body.get("oauth_provider") or ""
google_token = None
google_token_loaded = False
google_ssl_context = (
ssl.create_default_context()
if oauth_provider == "google"
else None
)
def _google_token():
nonlocal google_token, google_token_loaded
if not google_token_loaded:
google_token = _get_valid_google_token(body.get("account_id"), body)
google_token_loaded = True
if not google_token:
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
return google_token
if imap_port_err:
imap_result = {"ok": False, "error": imap_port_err}
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
else:
# Connection mode resolution:
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
# STARTTLS off + any other port → plain IMAP4 (no encryption)
# Without the last branch, local servers exposed on a non-993
# port (Dovecot on 31143, etc.) would always fail the SSL
# handshake because they're not actually wrapped in TLS.
try:
imap_kwargs = {
"starttls": imap_starttls,
"timeout": _IMAP_TIMEOUT_SECONDS,
}
if google_ssl_context:
imap_kwargs["ssl_context"] = google_ssl_context
conn = _open_imap_connection(
imap_host,
imap_port,
**imap_kwargs,
)
try:
if oauth_provider == "google":
token = _google_token()
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
else:
conn.login(imap_user, imap_pass)
imap_result = {"ok": True}
finally:
try: conn.logout()
except Exception: pass
except Exception as e:
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
smtp_host = (body.get("smtp_host") or "").strip()
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
if smtp_host and smtp_port_err:
smtp_result = {"ok": False, "error": smtp_port_err}
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
elif (
oauth_provider == "google"
and smtp_host
and not _google_oauth_smtp_transport_allowed(
smtp_port,
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
)
):
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
elif smtp_host:
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
smtp_user = (body.get("smtp_user") or imap_user).strip()
smtp_pass = body.get("smtp_password") or imap_pass
smtp = None
try:
if smtp_security == "ssl":
smtp_kwargs = (
{"context": google_ssl_context}
if google_ssl_context
else {}
)
smtp = smtplib.SMTP_SSL(
smtp_host,
smtp_port,
timeout=10,
**smtp_kwargs,
)
else:
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
if smtp_security == "starttls":
try:
if google_ssl_context:
smtp.starttls(context=google_ssl_context)
else:
smtp.starttls()
except Exception:
# STARTTLS failed before the auth cleanup block.
# Close the still-open plaintext socket explicitly.
try:
smtp.close()
except Exception:
pass
smtp = None
raise
if oauth_provider == "google":
token = _google_token()
smtp.ehlo()
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
else:
smtp.login(smtp_user, smtp_pass)
smtp_result = {"ok": True}
except Exception as e:
smtp_result = {"ok": False, "error": _friendly_email_auth_error("SMTP", smtp_host, e)}
finally:
if smtp is not None:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
return {
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
"imap": imap_result,
"smtp": smtp_result,
}
return await run_in_threadpool(_test_mail_connections, body, owner)
@router.post("/accounts/{account_id}/set-default")
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
+7 -2
View File
@@ -5,8 +5,9 @@ import shlex
import subprocess
from copy import deepcopy
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from core.middleware import require_admin
from core.platform_compat import run_ssh_command
from routes._validators import validate_remote_host, validate_ssh_port
@@ -198,7 +199,11 @@ def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") ->
def setup_hwfit_routes():
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
# Admin-gated like the Cookbook routes this backs (/api/cookbook/gpus does
# the same SSH host probing): `host`/`ssh_port` make the server SSH out with
# its own key to any syntactically valid host, and `model_path` probes the
# host filesystem. Single-user mode and internal tool calls still pass.
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"], dependencies=[Depends(require_admin)])
@router.get("/system")
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
+17 -4
View File
@@ -43,11 +43,22 @@ def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
@router.get("/search/web", response_class=HTMLResponse)
async def web_search_page(q: str = Query("", min_length=0)) -> HTMLResponse:
async def web_search_page(request: Request, q: str = Query("", min_length=0)) -> HTMLResponse:
"""Browser-facing search results page for clickable agent web_search rows."""
# The site CSP allows inline script only with the per-request nonce
# (SecurityHeadersMiddleware); without it this page's script never ran.
nonce = html.escape(getattr(request.state, "csp_nonce", "") or "", quote=True)
safe_q = str(q or "").strip()
title = html.escape(safe_q or "Web search")
q_json = json.dumps(safe_q)
# json.dumps leaves `<`, `>` and `&` alone, so q=</script><script ...>
# would close this inline <script> and inject markup (reflected XSS).
# Unicode-escape them; the JS string value is unchanged.
q_json = (
json.dumps(safe_q)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
page = f"""<!doctype html>
<html>
<head>
@@ -79,7 +90,7 @@ def setup_search_routes(config) -> APIRouter:
<div class="status" id="status">Loading...</div>
<div id="results"></div>
</main>
<script>
<script nonce="{nonce}">
const initialQuery = {q_json};
const input = document.getElementById('query');
const statusEl = document.getElementById('status');
@@ -103,7 +114,9 @@ def setup_search_routes(config) -> APIRouter:
}}
statusEl.textContent = sources.length ? `${{sources.length}} results` : 'No results';
resultsEl.innerHTML = sources.map(s => {{
const url = s.url || s.link || '';
const rawUrl = String(s.url || s.link || '');
// Result URLs come from third-party search results: only http(s) may become a link.
const url = /^https?:[/][/]/i.test(rawUrl) ? rawUrl : '';
const title = s.title || url || 'Untitled';
const snippet = s.snippet || s.content || '';
return `<a class="result" href="${{esc(url)}}" target="_blank" rel="noopener noreferrer">
+8 -2
View File
@@ -1601,7 +1601,13 @@ def setup_session_routes(
raise HTTPException(404, "Session not found")
skills = _context_info_skill_inventory(skills_manager, owner=owner)
tools = _context_info_tool_inventory()
agents_md = _context_info_agents_md_inventory(cwd)
# `cwd` is caller-chosen. Walking it for AGENTS.md and reporting whether
# it exists confirms paths on the host, which /api/workspace/browse and
# /vet reserve for admins (or single-user mode); keep that boundary here.
from src.tool_security import owner_is_admin_or_single_user
_host_paths_visible = owner_is_admin_or_single_user(owner)
agents_md = _context_info_agents_md_inventory(cwd) if _host_paths_visible else []
# Workspace visibility: lets the TUI answer "can the backend actually
# see this directory?" (mounted vs bridge-only) without probing.
from src.workspace_paths import backend_workspace_path, workspace_mount_pairs
@@ -1626,7 +1632,7 @@ def setup_session_routes(
}
_workspace = {
"backend_path": _backend_cwd,
"exists_in_backend": bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
"exists_in_backend": _host_paths_visible and bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
"mount_configured": bool(workspace_mount_pairs()),
"via_mount": bool(_raw_cwd) and backend_workspace_path(_raw_cwd) != _raw_cwd,
}
+5 -4
View File
@@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user
from src.prompt_security import untrusted_context_message
from src.text_helpers import strip_closed_think_blocks
from core.middleware import require_admin
logger = logging.getLogger(__name__)
@@ -250,7 +251,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
# Strip closed think blocks. If a <think> was opened but never closed
# (the model ran out of budget mid-reasoning), drop everything from it
# onward so its stray braces don't poison JSON extraction.
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I)
text = strip_closed_think_blocks(text)
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
def _coerce(d):
@@ -403,7 +404,7 @@ async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: st
except Exception as e:
logger.warning(f"Necessity check failed: {e}")
return None
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
text = strip_closed_think_blocks(raw or '')
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
data = None
a, b = text.find('{'), text.rfind('}')
@@ -493,7 +494,7 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
except Exception as e:
logger.warning(f"Retrieval precision check failed: {e}")
return None
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
text = strip_closed_think_blocks(raw or '')
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
data = None
a, b = text.find('{'), text.rfind('}')
@@ -1188,7 +1189,7 @@ async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url,
except Exception as e:
logger.warning(f"Audit: improve call failed: {e}")
return None
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
text = strip_closed_think_blocks(raw or '')
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I)
text = _re.sub(r'</think(?:ing)?>', '', text, flags=_re.I).strip()
if text.startswith("```"):
+7 -1
View File
@@ -1252,8 +1252,14 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if not out.get("prompt"):
return {"success": False, "message": "Could not extract a task instruction"}
return {"success": True, "draft": out}
except HTTPException as e:
# llm_core raises HTTPException with curated upstream text.
logger.error(f"parse_task failed: {e.detail}")
return {"success": False, "message": str(e.detail)}
except Exception as e:
# Anything else (JSON/parse/runtime errors) can carry internals;
# log it and keep the client message generic.
logger.error(f"parse_task failed: {e}")
return {"success": False, "message": str(e)}
return {"success": False, "message": f"Could not draft a task ({type(e).__name__})"}
return router