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"
|
||||
|
||||
@@ -55,13 +55,19 @@ def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
|
||||
|
||||
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
|
||||
"""Replace `httpx.post` inside `app.turnstile` with a stub that
|
||||
"""Replace `turnstile._siteverify_post` with an async stub that
|
||||
returns the requested success shape. The stub does not touch the
|
||||
real CloudFlare endpoint and never sees a real secret.
|
||||
|
||||
I4 (security-audit-0026): the siteverify call is now awaited on an
|
||||
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
|
||||
Patching that narrow function (rather than the shared
|
||||
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
|
||||
intact.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, *, data=None, timeout=None, **kwargs):
|
||||
async def fake_post(url, data):
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
body = {"success": bool(success)}
|
||||
@@ -70,7 +76,7 @@ def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | No
|
||||
return SimpleNamespace(json=lambda: body)
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(turnstile_mod, "_siteverify_post", fake_post)
|
||||
return captured
|
||||
|
||||
|
||||
@@ -170,14 +176,15 @@ def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_git
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
# The httpx.post inside turnstile must not be called in this path —
|
||||
# patch it to a sentinel that explodes if it ever runs.
|
||||
# The siteverify call inside turnstile must not be made in this path —
|
||||
# patch the seam to a sentinel that explodes if it ever runs
|
||||
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
def must_not_be_called(*a, **kw):
|
||||
async def must_not_be_called(*a, **kw):
|
||||
raise AssertionError("siteverify should not run when no secret is configured")
|
||||
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
|
||||
monkeypatch.setattr(turnstile_mod, "_siteverify_post", must_not_be_called)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
@@ -218,3 +225,27 @@ def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea,
|
||||
)
|
||||
assert r.status_code == 500, r.text
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
|
||||
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
|
||||
# the synchronous event-loop-blocking shape fails here, not just in the
|
||||
# integration paths.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
|
||||
coro = turnstile_mod.verify_token("any-token")
|
||||
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
|
||||
outcome = asyncio.run(coro)
|
||||
# No secret + not required → the gate stays open without any network call.
|
||||
assert outcome.ok is True
|
||||
assert outcome.reason == "skipped"
|
||||
|
||||
Reference in New Issue
Block a user