fix(security): verify email TLS certs and guard settings-write routes
Two defense-in-depth fixes from an adversarial review of the Email/WebSocket guided-config surface (#1544). Both harden pre-existing patterns that the new email probe — the first whose target host is caller-controlled while reusing stored credentials — made relevant. Email TLS certificate verification (CERT_NONE -> verified): - The implicit-SSL paths (IMAP4_SSL/SMTP_SSL) in the connection probe and the polling/send adapter used Python's default unverified context (CERT_NONE, no hostname check), so the mailbox password was sent to a server whose certificate was never checked; an active MITM could harvest it. All four call sites now build their context through a shared email_tls_context() that verifies the certificate and hostname against the system CA bundle, matching the STARTTLS path, which already did. - New EmailConfig.verify_tls field (default True) is the documented opt-out for self-signed/internal-CA servers, surfaced in the guided Email setup with a localized label/help in all nine locales. Settings-write CSRF guard: - require_settings_write_auth was missing the cross-site browser guard its siblings (require_auth, require_event_stream_auth) enforce on unsafe methods. POST /channels/email/test merges stored credentials with a caller patch, so a cross-site page could steer the stored mailbox password to an attacker-chosen host. The guard now runs first on every settings-write route; same-origin Web UI calls and non-browser clients (no Origin) are unaffected. Tests: 9 new email TLS regressions (helper + probe/adapter x verify_tls) and 5 new settings-write cross-site guard regressions; the email field-count (29 -> 30) and i18n parity assertions were updated. Channel sweep 639 passed, frontend 656 passed, test_readme_counts 108 passed; new/changed files are black- and ruff-clean (email.py's 9 ruff errors are pre-existing import-block debt, unchanged 9 -> 9). Follow-up to #1519 (extends #1520, hardens #1544). Signed-off-by: shadowinlife <shadowinlife@gmail.com>
This commit is contained in:
1 parent
d4861f36e3
commit
2242c412ca
20 files changed
+382
-10
No files matched your search
@@ -161,6 +161,27 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Email channel verifies TLS certificates by default** (a follow-up
|
||||
to #1544). The implicit-SSL paths (`IMAP4_SSL` / `SMTP_SSL`) in both the
|
||||
connection probe and the polling/send adapter used Python's default
|
||||
unverified context (`CERT_NONE`, no hostname check), so the mailbox
|
||||
password travelled to a server whose certificate was never checked — an
|
||||
active network attacker between the operator and their mail provider could
|
||||
harvest it. All four call sites now build their context through a shared
|
||||
`email_tls_context()` that verifies the certificate and hostname against
|
||||
the system CA bundle, matching the STARTTLS path, which already did. A new
|
||||
`verify_tls` field (default `true`, surfaced in the guided Email setup in
|
||||
all nine locales) is the documented opt-out for self-signed or internal-CA
|
||||
servers.
|
||||
- **Settings-write routes reject cross-site browser requests** (a follow-up
|
||||
to #1544). `require_settings_write_auth` did not apply the cross-site guard
|
||||
that its siblings `require_auth` and `require_event_stream_auth` enforce on
|
||||
unsafe methods, so a malicious web page could attempt a CSRF write against
|
||||
any settings route — including the new `POST /channels/email/test`, which
|
||||
merges stored credentials with a caller-supplied patch and could be steered
|
||||
to send the stored mailbox password to an attacker-chosen host. The guard
|
||||
now runs first on every settings-write route; same-origin Web UI calls and
|
||||
non-browser clients (CLI/curl, no `Origin`) are unaffected.
|
||||
- **A backtest aborted when a funding debit left cash below zero** (#1542).
|
||||
CompositeEngine and CryptoEngine subtract crypto funding from capital with
|
||||
no floor, and the next open then fitted at no scale, not even an empty plan:
|
||||
|
||||
@@ -642,6 +642,9 @@ async def require_settings_write_auth(
|
||||
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
|
||||
) -> None:
|
||||
"""Require explicit authorization before changing credential-routing settings."""
|
||||
if request.method.upper() not in _SAFE_BROWSER_METHODS:
|
||||
_reject_cross_site_browser_request(request)
|
||||
|
||||
api_key = _configured_api_key()
|
||||
if api_key:
|
||||
token = _auth_credential_from_header_or_query(cred, None, allow_query=False)
|
||||
|
||||
@@ -133,6 +133,7 @@ def _email_hints() -> list[FieldHint]:
|
||||
("smtp_password", "password", True, True),
|
||||
("smtp_use_tls", "bool", False, False),
|
||||
("smtp_use_ssl", "bool", False, False),
|
||||
("verify_tls", "bool", False, False),
|
||||
("from_address", "text", False, False),
|
||||
("auto_reply_enabled", "bool", False, False),
|
||||
("poll_interval_seconds", "text", False, False),
|
||||
|
||||
@@ -28,7 +28,7 @@ from src.channels.bus.queue import MessageBus
|
||||
from src.channels.base import BaseChannel
|
||||
from src.channels.utils import get_media_dir
|
||||
from pydantic import BaseModel
|
||||
from src.channels.utils import safe_filename, send_imap_id
|
||||
from src.channels.utils import email_tls_context, safe_filename, send_imap_id
|
||||
|
||||
|
||||
class EmailConfig(BaseModel):
|
||||
@@ -50,6 +50,9 @@ class EmailConfig(BaseModel):
|
||||
smtp_password: str = ""
|
||||
smtp_use_tls: bool = True
|
||||
smtp_use_ssl: bool = False
|
||||
# TLS certificate verification for implicit-SSL (IMAP4_SSL/SMTP_SSL)
|
||||
# connections; set False only for self-signed / internal-CA mail servers.
|
||||
verify_tls: bool = True
|
||||
from_address: str = ""
|
||||
|
||||
auto_reply_enabled: bool = True
|
||||
@@ -355,6 +358,7 @@ class EmailChannel(BaseChannel):
|
||||
self.config.smtp_host,
|
||||
self.config.smtp_port,
|
||||
timeout=timeout,
|
||||
ssl_context=email_tls_context(self.config.verify_tls),
|
||||
) as smtp:
|
||||
smtp.login(self.config.smtp_username, self.config.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
@@ -574,7 +578,11 @@ class EmailChannel(BaseChannel):
|
||||
|
||||
def _open_imap_client(self, mailbox: str, *, missing_mailbox_ok: bool = False) -> Any | None:
|
||||
if self.config.imap_use_ssl:
|
||||
client: Any = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
|
||||
client: Any = imaplib.IMAP4_SSL(
|
||||
self.config.imap_host,
|
||||
self.config.imap_port,
|
||||
ssl_context=email_tls_context(self.config.verify_tls),
|
||||
)
|
||||
else:
|
||||
client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from src.channels.token_probe import scrub_secrets
|
||||
from src.channels.utils import send_imap_id
|
||||
from src.channels.utils import email_tls_context, send_imap_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.channels.email import EmailConfig
|
||||
@@ -84,7 +84,10 @@ def _probe_imap(config: EmailConfig) -> tuple[str, str] | None:
|
||||
try:
|
||||
if config.imap_use_ssl:
|
||||
client = imaplib.IMAP4_SSL(
|
||||
config.imap_host, config.imap_port, timeout=_CONNECT_TIMEOUT_S
|
||||
config.imap_host,
|
||||
config.imap_port,
|
||||
timeout=_CONNECT_TIMEOUT_S,
|
||||
ssl_context=email_tls_context(config.verify_tls),
|
||||
)
|
||||
else:
|
||||
client = imaplib.IMAP4(
|
||||
@@ -127,7 +130,10 @@ def _probe_smtp(config: EmailConfig) -> tuple[str, str] | None:
|
||||
try:
|
||||
if config.smtp_use_ssl:
|
||||
client = smtplib.SMTP_SSL(
|
||||
config.smtp_host, config.smtp_port, timeout=_CONNECT_TIMEOUT_S
|
||||
config.smtp_host,
|
||||
config.smtp_port,
|
||||
timeout=_CONNECT_TIMEOUT_S,
|
||||
ssl_context=email_tls_context(config.verify_tls),
|
||||
)
|
||||
else:
|
||||
client = smtplib.SMTP(
|
||||
|
||||
@@ -6,6 +6,7 @@ import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -123,6 +124,25 @@ def send_imap_id(client: Any) -> None:
|
||||
logger.debug("IMAP ID command failed (non-fatal)", exc_info=True)
|
||||
|
||||
|
||||
def email_tls_context(verify: bool) -> ssl.SSLContext:
|
||||
"""Return a TLS context for implicit-SSL (IMAP4_SSL/SMTP_SSL) email connections.
|
||||
|
||||
``verify=True`` (the default) verifies the server certificate and hostname
|
||||
against the system CA bundle, so a credential is never sent to an
|
||||
unverified server. ``verify=False`` disables verification — an explicit,
|
||||
documented opt-out for self-signed or internal-CA mail servers that trades
|
||||
MITM protection for connectivity. Mirrors the STARTTLS path, which already
|
||||
uses :func:`ssl.create_default_context`.
|
||||
"""
|
||||
if verify:
|
||||
return ssl.create_default_context()
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
# check_hostname must be cleared before CERT_NONE or Python raises ValueError.
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]:
|
||||
"""Validate a URL is safe to fetch: scheme, hostname, and resolved IPs.
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ _EMAIL_HINT_KEYS = (
|
||||
"smtp_password",
|
||||
"smtp_use_tls",
|
||||
"smtp_use_ssl",
|
||||
"verify_tls",
|
||||
"from_address",
|
||||
"auto_reply_enabled",
|
||||
"poll_interval_seconds",
|
||||
@@ -233,7 +234,7 @@ def test_email_field_hints_contract() -> None:
|
||||
keys = tuple(hint["key"] for hint in hints)
|
||||
assert keys == _EMAIL_HINT_KEYS
|
||||
assert "enabled" not in keys
|
||||
assert len(hints) == 29
|
||||
assert len(hints) == 30
|
||||
|
||||
by_key = {hint["key"]: hint for hint in hints}
|
||||
assert {key for key, hint in by_key.items() if hint["secret"]} == {
|
||||
|
||||
@@ -18,13 +18,15 @@ import json
|
||||
import logging
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from src.channels.bus.queue import MessageBus
|
||||
from src.channels.email import EmailChannel
|
||||
from src.channels.utils import send_imap_id
|
||||
from src.channels.utils import email_tls_context, send_imap_id
|
||||
|
||||
IMAP_HOST = "imap.example.com"
|
||||
SMTP_HOST = "smtp.example.com"
|
||||
@@ -76,6 +78,7 @@ def _install_imap_fakes(
|
||||
"""Replace ``imaplib.IMAP4``/``IMAP4_SSL`` with recording fakes."""
|
||||
calls: dict[str, Any] = {
|
||||
"constructed": [],
|
||||
"ssl_contexts": [],
|
||||
"logins": [],
|
||||
"ids": [],
|
||||
"selects": [],
|
||||
@@ -85,8 +88,15 @@ def _install_imap_fakes(
|
||||
class FakeIMAP4:
|
||||
kind = "IMAP4"
|
||||
|
||||
def __init__(self, host: str, port: int, timeout: float | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
ssl_context: Any = None,
|
||||
) -> None:
|
||||
calls["constructed"].append((self.kind, host, port, timeout))
|
||||
calls["ssl_contexts"].append(ssl_context)
|
||||
if connect_error is not None:
|
||||
raise connect_error
|
||||
|
||||
@@ -128,19 +138,38 @@ def _install_smtp_fakes(
|
||||
"""Replace ``smtplib.SMTP``/``SMTP_SSL`` with recording fakes."""
|
||||
calls: dict[str, Any] = {
|
||||
"constructed": [],
|
||||
"ssl_contexts": [],
|
||||
"starttls": [],
|
||||
"logins": [],
|
||||
"sent": [],
|
||||
"quits": 0,
|
||||
}
|
||||
|
||||
class FakeSMTP:
|
||||
kind = "SMTP"
|
||||
|
||||
def __init__(self, host: str, port: int, timeout: float | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
ssl_context: Any = None,
|
||||
) -> None:
|
||||
calls["constructed"].append((self.kind, host, port, timeout))
|
||||
calls["ssl_contexts"].append(ssl_context)
|
||||
if connect_error is not None:
|
||||
raise connect_error
|
||||
|
||||
def __enter__(self) -> "FakeSMTP":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: Any) -> None:
|
||||
return None
|
||||
|
||||
def send_message(self, msg: Any) -> dict[Any, Any]:
|
||||
calls["sent"].append(msg)
|
||||
return {}
|
||||
|
||||
def starttls(self, context: Any = None) -> tuple[int, bytes]:
|
||||
calls["starttls"].append(context is not None)
|
||||
if starttls_error is not None:
|
||||
@@ -416,6 +445,128 @@ def test_smtp_ssl_transport_skips_starttls(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
assert smtp_calls["starttls"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TLS certificate verification (verify_tls)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify", [True, False])
|
||||
def test_email_tls_context_verify_modes(verify: bool) -> None:
|
||||
ctx = email_tls_context(verify)
|
||||
|
||||
if verify:
|
||||
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||
assert ctx.check_hostname is True
|
||||
else:
|
||||
assert ctx.verify_mode == ssl.CERT_NONE
|
||||
assert ctx.check_hostname is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_tls", [True, False])
|
||||
def test_probe_imap_ssl_context_follows_verify_tls(
|
||||
monkeypatch: pytest.MonkeyPatch, verify_tls: bool
|
||||
) -> None:
|
||||
"""The probe must never send the IMAP password over an unverified socket
|
||||
unless the operator explicitly opted out with ``verify_tls=False``."""
|
||||
imap_calls = _install_imap_fakes(monkeypatch)
|
||||
_install_smtp_fakes(monkeypatch)
|
||||
|
||||
result = _run(_make_channel(verify_tls=verify_tls).test_connection())
|
||||
|
||||
assert result["ok"] is True
|
||||
ctx = imap_calls["ssl_contexts"][0]
|
||||
assert isinstance(ctx, ssl.SSLContext)
|
||||
if verify_tls:
|
||||
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||
assert ctx.check_hostname is True
|
||||
else:
|
||||
assert ctx.verify_mode == ssl.CERT_NONE
|
||||
assert ctx.check_hostname is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_tls", [True, False])
|
||||
def test_probe_smtp_ssl_context_follows_verify_tls(
|
||||
monkeypatch: pytest.MonkeyPatch, verify_tls: bool
|
||||
) -> None:
|
||||
imap_calls = _install_imap_fakes(monkeypatch)
|
||||
smtp_calls = _install_smtp_fakes(monkeypatch)
|
||||
|
||||
result = _run(
|
||||
_make_channel(
|
||||
smtp_use_ssl=True, smtp_port=465, verify_tls=verify_tls
|
||||
).test_connection()
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
ctx = smtp_calls["ssl_contexts"][0]
|
||||
assert isinstance(ctx, ssl.SSLContext)
|
||||
if verify_tls:
|
||||
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||
assert ctx.check_hostname is True
|
||||
else:
|
||||
assert ctx.verify_mode == ssl.CERT_NONE
|
||||
assert ctx.check_hostname is False
|
||||
# The plain IMAP4_SSL probe half shares the same verify_tls switch.
|
||||
assert isinstance(imap_calls["ssl_contexts"][0], ssl.SSLContext)
|
||||
|
||||
|
||||
def test_probe_plain_transports_pass_no_ssl_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Non-SSL transports take no context; STARTTLS keeps its own verifying one."""
|
||||
imap_calls = _install_imap_fakes(monkeypatch)
|
||||
smtp_calls = _install_smtp_fakes(monkeypatch)
|
||||
|
||||
result = _run(
|
||||
_make_channel(imap_use_ssl=False, smtp_use_ssl=False).test_connection()
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert imap_calls["ssl_contexts"] == [None]
|
||||
assert smtp_calls["ssl_contexts"] == [None]
|
||||
assert smtp_calls["starttls"] == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_tls", [True, False])
|
||||
def test_adapter_imap_ssl_context_follows_verify_tls(
|
||||
monkeypatch: pytest.MonkeyPatch, verify_tls: bool
|
||||
) -> None:
|
||||
"""The adapter's polling connection uses the same helper as the probe."""
|
||||
imap_calls = _install_imap_fakes(monkeypatch)
|
||||
channel = _make_channel(verify_tls=verify_tls)
|
||||
|
||||
client = channel._open_imap_client("INBOX")
|
||||
|
||||
assert client is not None
|
||||
ctx = imap_calls["ssl_contexts"][0]
|
||||
expected = ssl.CERT_REQUIRED if verify_tls else ssl.CERT_NONE
|
||||
assert ctx.verify_mode == expected
|
||||
assert ctx.check_hostname is verify_tls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_tls", [True, False])
|
||||
def test_adapter_smtp_ssl_context_follows_verify_tls(
|
||||
monkeypatch: pytest.MonkeyPatch, verify_tls: bool
|
||||
) -> None:
|
||||
smtp_calls = _install_smtp_fakes(monkeypatch)
|
||||
channel = _make_channel(smtp_use_ssl=True, smtp_port=465, verify_tls=verify_tls)
|
||||
|
||||
channel._smtp_send(EmailMessage())
|
||||
|
||||
ctx = smtp_calls["ssl_contexts"][0]
|
||||
expected = ssl.CERT_REQUIRED if verify_tls else ssl.CERT_NONE
|
||||
assert ctx.verify_mode == expected
|
||||
assert ctx.check_hostname is verify_tls
|
||||
assert len(smtp_calls["sent"]) == 1
|
||||
|
||||
|
||||
def test_verify_tls_defaults_to_true() -> None:
|
||||
"""Secure by default: certificate verification is on unless opted out."""
|
||||
channel = _make_channel()
|
||||
|
||||
assert channel.config.verify_tls is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Secret hygiene
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -576,7 +727,13 @@ def test_probe_sends_imap_id_before_select(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
order: list[str] = []
|
||||
|
||||
class FakeIMAP4_SSL:
|
||||
def __init__(self, host: str, port: int, timeout: float | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
ssl_context: Any = None,
|
||||
) -> None:
|
||||
order.append("connect")
|
||||
|
||||
def login(self, user: str, password: str) -> tuple[str, list[bytes]]:
|
||||
|
||||
@@ -308,6 +308,121 @@ def test_local_dev_can_write_llm_settings_when_api_key_unset(
|
||||
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in env_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUT /settings/llm: cross-site browser guard on require_settings_write_auth
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_settings_write_rejects_cross_site_sec_fetch_site(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""A cross-site browser PUT must not reach the settings-write logic."""
|
||||
env_path = tmp_path / ".env"
|
||||
monkeypatch.setattr(api_server, "ENV_PATH", env_path)
|
||||
|
||||
response = _local_client().put(
|
||||
"/settings/llm",
|
||||
headers={"Host": "127.0.0.1:8899", "Sec-Fetch-Site": "cross-site"},
|
||||
json=_llm_settings_payload("https://attacker.example/openai-compatible/v1"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Cross-site request denied"
|
||||
assert not env_path.exists()
|
||||
|
||||
|
||||
def test_settings_write_rejects_cross_site_origin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""An Origin that is neither loopback nor the request host is cross-site."""
|
||||
env_path = tmp_path / ".env"
|
||||
monkeypatch.setattr(api_server, "ENV_PATH", env_path)
|
||||
|
||||
response = _local_client().put(
|
||||
"/settings/llm",
|
||||
headers={"Host": "127.0.0.1:8899", "Origin": "https://attacker.example"},
|
||||
json=_llm_settings_payload("https://attacker.example/openai-compatible/v1"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Cross-site request denied"
|
||||
assert not env_path.exists()
|
||||
|
||||
|
||||
def test_settings_write_same_origin_browser_passes_cross_site_guard(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""A same-origin browser PUT clears the guard and reaches the write path."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(api_server, "ENV_PATH", env_path)
|
||||
|
||||
response = _local_client().put(
|
||||
"/settings/llm",
|
||||
headers={
|
||||
"Host": "127.0.0.1:8899",
|
||||
"Origin": "http://127.0.0.1:8899",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
},
|
||||
json=_llm_settings_payload("https://api.openai.com/v1"),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in env_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_settings_write_remote_same_origin_passes_guard_and_hits_auth_layer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""A host-matching Origin passes the guard; the later auth layer still decides."""
|
||||
env_path = tmp_path / ".env"
|
||||
monkeypatch.setattr(api_server, "ENV_PATH", env_path)
|
||||
client = TestClient(
|
||||
api_server.app,
|
||||
base_url="http://192.168.1.10:8899",
|
||||
client=("192.168.1.20", 50000),
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
"/settings/llm",
|
||||
headers={
|
||||
"Host": "192.168.1.10:8899",
|
||||
"Origin": "http://192.168.1.10:8899",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
},
|
||||
json=_llm_settings_payload("https://api.openai.com/v1"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "Settings writes require API_AUTH_KEY or a local loopback client"
|
||||
)
|
||||
assert not env_path.exists()
|
||||
|
||||
|
||||
def test_settings_write_without_browser_headers_passes_guard(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""A non-browser client (CLI/curl) sends no Origin and must pass the guard."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(api_server, "ENV_PATH", env_path)
|
||||
|
||||
response = _local_client().put(
|
||||
"/settings/llm",
|
||||
json=_llm_settings_payload("https://api.openai.com/v1"),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in env_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_loopback_rejects_rebound_host_before_auth_bypass(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -228,6 +228,7 @@ export function ChannelConfigPanel({
|
||||
"settings.channels.fields.email.smtp_password": { label: t("settings.channels.fields.email.smtp_password.label"), help: t("settings.channels.fields.email.smtp_password.help") },
|
||||
"settings.channels.fields.email.smtp_use_tls": { label: t("settings.channels.fields.email.smtp_use_tls.label"), help: t("settings.channels.fields.email.smtp_use_tls.help") },
|
||||
"settings.channels.fields.email.smtp_use_ssl": { label: t("settings.channels.fields.email.smtp_use_ssl.label"), help: t("settings.channels.fields.email.smtp_use_ssl.help") },
|
||||
"settings.channels.fields.email.verify_tls": { label: t("settings.channels.fields.email.verify_tls.label"), help: t("settings.channels.fields.email.verify_tls.help") },
|
||||
"settings.channels.fields.email.from_address": { label: t("settings.channels.fields.email.from_address.label"), help: t("settings.channels.fields.email.from_address.help") },
|
||||
"settings.channels.fields.email.auto_reply_enabled": { label: t("settings.channels.fields.email.auto_reply_enabled.label"), help: t("settings.channels.fields.email.auto_reply_enabled.help") },
|
||||
"settings.channels.fields.email.poll_interval_seconds": { label: t("settings.channels.fields.email.poll_interval_seconds.label"), help: t("settings.channels.fields.email.poll_interval_seconds.help") },
|
||||
|
||||
@@ -135,6 +135,7 @@ function emailEntry(overrides: Record<string, unknown> = {}) {
|
||||
{ key: "smtp_password", type: "password", secret: true, required: true, help_key: "settings.channels.fields.email.smtp_password" },
|
||||
{ key: "smtp_use_tls", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.smtp_use_tls" },
|
||||
{ key: "smtp_use_ssl", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.smtp_use_ssl" },
|
||||
{ key: "verify_tls", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.verify_tls" },
|
||||
{ key: "from_address", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.from_address" },
|
||||
{ key: "auto_reply_enabled", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.auto_reply_enabled" },
|
||||
{ key: "poll_interval_seconds", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.poll_interval_seconds" },
|
||||
@@ -165,6 +166,7 @@ function emailEntry(overrides: Record<string, unknown> = {}) {
|
||||
smtp_username: "bot@example.com",
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
verify_tls: true,
|
||||
from_address: "",
|
||||
auto_reply_enabled: true,
|
||||
poll_interval_seconds: 30,
|
||||
@@ -621,6 +623,7 @@ describe("ChannelSettings config panel", () => {
|
||||
expect(screen.getByText("After processing")).toBeInTheDocument();
|
||||
expect(screen.getByText("Allowed senders")).toBeInTheDocument();
|
||||
expect(screen.getByText("Verify DKIM")).toBeInTheDocument();
|
||||
expect(screen.getByText("Verify TLS certificate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Allowed attachment types")).toBeInTheDocument();
|
||||
expect(screen.getByText("Max attachments per email")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inbound mail server host, e\.g\. imap\.gmail\.com/)).toBeInTheDocument();
|
||||
|
||||
@@ -574,6 +574,10 @@
|
||||
"label": "SMTP عبر SSL",
|
||||
"help": "الاتصال بـ SMTP عبر TLS الضمني، مثل المنفذ 465 (معطّل افتراضيًا). لا يُستخدم عمليًا مع STARTTLS في الوقت نفسه."
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "التحقق من شهادة TLS",
|
||||
"help": "التحقق من شهادة TLS الخاصة بخادم البريد واسم المضيف (موصى به). لا تُعطّله إلا مع الخوادم ذاتية التوقيع أو التي تستخدم CA داخلي."
|
||||
},
|
||||
"from_address": {
|
||||
"label": "عنوان المرسل",
|
||||
"help": "العنوان الذي تُرسل منه الردود. إذا تُرك فارغًا يُستخدم اسم مستخدم SMTP."
|
||||
|
||||
@@ -579,6 +579,10 @@
|
||||
"label": "SMTP-SSL",
|
||||
"help": "SMTP über implizites TLS verbinden, z. B. Port 465 (standardmäßig aus). In der Praxis nicht mit STARTTLS kombinierbar."
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "TLS-Zertifikat prüfen",
|
||||
"help": "TLS-Zertifikat und Hostname des Mailservers prüfen (empfohlen). Nur für selbstsignierte oder interne CA-Server deaktivieren."
|
||||
},
|
||||
"from_address": {
|
||||
"label": "Absenderadresse",
|
||||
"help": "Adresse, von der Antworten versendet werden. Leer übernimmt den SMTP-Benutzernamen."
|
||||
|
||||
@@ -579,6 +579,10 @@
|
||||
"label": "SMTP SSL",
|
||||
"help": "Connect to SMTP over implicit TLS, e.g. port 465 (off by default). In practice this is mutually exclusive with STARTTLS."
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "Verify TLS certificate",
|
||||
"help": "Verify the mail server's TLS certificate and hostname (recommended). Disable only for self-signed or internal-CA servers."
|
||||
},
|
||||
"from_address": {
|
||||
"label": "From address",
|
||||
"help": "Address replies are sent from. Empty reuses the SMTP username."
|
||||
|
||||
@@ -579,6 +579,10 @@
|
||||
"label": "SSL de SMTP",
|
||||
"help": "Conectar a SMTP mediante TLS implícito, p. ej. el puerto 465 (desactivado de forma predeterminada). En la práctica es excluyente con STARTTLS."
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "Verificar el certificado TLS",
|
||||
"help": "Verificar el certificado TLS y el nombre de host del servidor de correo (recomendado). Desactivar solo para servidores con certificados autofirmados o de CA interna."
|
||||
},
|
||||
"from_address": {
|
||||
"label": "Dirección de remitente",
|
||||
"help": "Dirección desde la que se envían las respuestas. Si se deja vacía, se usa el usuario SMTP."
|
||||
|
||||
@@ -579,6 +579,10 @@
|
||||
"label": "SSL SMTP",
|
||||
"help": "Hubungkan ke SMTP melalui TLS implisit, misalnya port 465 (nonaktif secara default). Dalam praktik tidak dipakai bersama STARTTLS."
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "Verifikasi sertifikat TLS",
|
||||
"help": "Verifikasi sertifikat TLS dan nama host server email (disarankan). Nonaktifkan hanya untuk server dengan sertifikat self-signed atau CA internal."
|
||||
},
|
||||
"from_address": {
|
||||
"label": "Alamat pengirim",
|
||||
"help": "Alamat asal balasan dikirim. Jika kosong, memakai nama pengguna SMTP."
|
||||
|
||||
@@ -574,6 +574,10 @@
|
||||
"label": "SMTP SSL",
|
||||
"help": "暗黙的 TLS で SMTP に接続します(例: ポート 465、既定オフ)。実際には STARTTLS と併用しません。"
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "TLS 証明書を検証",
|
||||
"help": "メールサーバーの TLS 証明書とホスト名を検証します(推奨)。自己署名または社内 CA のサーバーの場合のみ無効にしてください。"
|
||||
},
|
||||
"from_address": {
|
||||
"label": "送信元アドレス",
|
||||
"help": "返信メールの送信元アドレス。空欄の場合は SMTP ユーザー名を使用します。"
|
||||
|
||||
@@ -574,6 +574,10 @@
|
||||
"label": "SMTP SSL",
|
||||
"help": "암시적 TLS로 SMTP에 연결합니다(예: 포트 465, 기본 꺼짐). 실제로는 STARTTLS와 함께 사용하지 않습니다."
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "TLS 인증서 검증",
|
||||
"help": "메일 서버의 TLS 인증서와 호스트 이름을 검증합니다(권장). 자체 서명 또는 내부 CA 서버인 경우에만 끄세요."
|
||||
},
|
||||
"from_address": {
|
||||
"label": "보낸 사람 주소",
|
||||
"help": "답장을 보낼 주소입니다. 비워 두면 SMTP 사용자 이름을 사용합니다."
|
||||
|
||||
@@ -579,6 +579,10 @@
|
||||
"label": "SSL do SMTP",
|
||||
"help": "Conectar ao SMTP via TLS implícito, por exemplo na porta 465 (desativado por padrão). Na prática, é exclusivo com o STARTTLS."
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "Verificar o certificado TLS",
|
||||
"help": "Verificar o certificado TLS e o nome de host do servidor de e-mail (recomendado). Desative apenas para servidores com certificado autoassinado ou CA interna."
|
||||
},
|
||||
"from_address": {
|
||||
"label": "Endereço do remetente",
|
||||
"help": "Endereço de onde as respostas são enviadas. Se ficar vazio, usa o usuário SMTP."
|
||||
|
||||
@@ -574,6 +574,10 @@
|
||||
"label": "SMTP SSL",
|
||||
"help": "通过隐式 TLS 连接 SMTP,例如 465 端口(默认关闭)。实际使用中与 STARTTLS 互斥。"
|
||||
},
|
||||
"verify_tls": {
|
||||
"label": "校验 TLS 证书",
|
||||
"help": "验证邮件服务器的 TLS 证书与主机名(推荐)。仅在使用自签名或内部 CA 服务器时关闭。"
|
||||
},
|
||||
"from_address": {
|
||||
"label": "发件地址",
|
||||
"help": "回复邮件的发件地址。留空则沿用 SMTP 用户名。"
|
||||
|
||||
Reference in new issue
Block a user