Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile)
Two informational findings from the Session-0026 audit, both framework-internal defense-in-depth. No operator action: no migration, no schema/config/overlay change, no deployment-facing surface. - I3: guard the dead text/html branch in email_envelope.build_envelope. No send path passes body_html; the unused branch would emit HTML built from possibly-unescaped user content (C1 stored-XSS class in the mail channel). Passing body_html now raises NotImplementedError; the arg is kept for documented future symmetry, enabling HTML mail becomes a deliberate escape-then-unguard change. - I4: make turnstile.verify_token async. The sync httpx.post ran inside the async /auth/otc/request handler, blocking the event loop up to the 10s timeout on a slow CloudFlare call. It now awaits httpx.AsyncClient via a narrow _siteverify_post seam (tests patch the seam, not the shared AsyncClient). The sole caller (main.py) now awaits it. Tests: full backend suite 365 passed. Added a coroutine-contract unit test for verify_token and flipped the email_envelope HTML test to assert the guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.email_envelope import build_envelope
|
||||
|
||||
|
||||
@@ -160,14 +162,18 @@ def test_envelope_plain_only_body_is_text_plain():
|
||||
assert msg.get_content().strip() == "Hello, world."
|
||||
|
||||
|
||||
def test_envelope_with_html_is_multipart_alternative():
|
||||
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
assert msg.get_content_type() == "multipart/alternative"
|
||||
# Two parts: text/plain first (so plain-text clients picking the
|
||||
# first part get the readable text), text/html second.
|
||||
parts = list(msg.iter_parts())
|
||||
assert len(parts) == 2
|
||||
assert parts[0].get_content_type() == "text/plain"
|
||||
assert parts[1].get_content_type() == "text/html"
|
||||
assert "Hello, world." in parts[0].get_content()
|
||||
assert "<b>world</b>" in parts[1].get_content()
|
||||
def test_envelope_html_body_is_guarded_not_enabled():
|
||||
# I3 (security-audit-0026): the HTML/multipart-alternative path is
|
||||
# intentionally not enabled — passing body_html must fail loudly so
|
||||
# a future caller can't silently ship unescaped user HTML (the C1
|
||||
# stored-XSS class in the mail channel). When HTML mail is enabled
|
||||
# deliberately, this test flips to assert the multipart shape.
|
||||
with pytest.raises(NotImplementedError):
|
||||
build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
|
||||
|
||||
def test_envelope_html_none_is_plain_only():
|
||||
# The guard keys on `is not None`, so the default (None) stays the
|
||||
# live plain-text path — exercised here to lock the boundary.
|
||||
msg = build_envelope(**_base_kwargs(body_html=None))
|
||||
assert msg.get_content_type() == "text/plain"
|
||||
|
||||
Reference in New Issue
Block a user