Skip to content

Commit 88b7c7b

Browse files
Sbussisoclaude
andcommitted
email(v1): polish — fix 3 LOW review findings
The cosmetic + defense-in-depth fixes from the v1 code review. None of the three are exploitable today; all three remove future footguns and improve operator-facing accuracy. LOW — Worker tick summary over-counted mid-retry failures. ``run_one_tick`` was incrementing ``summary[status]`` based on the per-attempt outcome from ``_process_row``, ignoring the fact that ``_finalize_row`` may have flipped the row back to 'pending' for another attempt. A row that retried twice and succeeded was showing up as ``failed=2 sent=1`` across three ticks instead of ``sent=1`` in the tick that actually succeeded. The numbers in operator log streams now reflect terminal outcomes only — mid- retry rows count zero in any bucket and pop into the right bucket on the tick they finally land. Pinned by an extension to ``test_worker_retries_on_transient_failure`` and a sum-across- ticks check in ``test_worker_gives_up_at_max_attempts``. LOW — Subject template didn't strip embedded CR/LF. ``email_templates.render`` called ``.strip()`` on the rendered subject, which only trims edges. An embedded ``\r\nBcc: ...`` in a notification.title (operator-controlled camera names; AI- agent-supplied incident titles) would survive into the subject line. Resend's API rejects header injection today so this isn't exploitable, but a future provider swap that forwards subjects raw to SMTP would turn it into a Bcc-injection vector. Added the explicit replace pass and ``test_render_strips_embedded_ newlines_from_subject`` to pin it. LOW — Unsubscribe HTML pages didn't escape the substituted ``kind`` and ``frontend`` values. The pages use ``str.format()`` (not Jinja2, so autoescape doesn't apply) and a token whose ``kind`` claim contained ``<script>...</script>`` would render the raw script tag. Practically unreachable — forging the JWT requires CLERK_SECRET_KEY, at which point the attacker has root — but added a one-line ``_safe_html`` helper and applied it to every interpolation site. ``test_unsubscribe_endpoint_html_ escapes_kind`` forges a token (using the same secret the test process holds) to prove the escape actually fires. Full suite: 408 passed (was 406; +2 from the two new explicit tests). All four review fixes (1 CRITICAL + 2 HIGH + 3 LOW) now landed. Ready to push when the operator-side Resend setup is done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8de60c1 commit 88b7c7b

6 files changed

Lines changed: 135 additions & 13 deletions

File tree

backend/app/api/notifications.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,22 @@ async def update_email_preferences(
866866
</body></html>"""
867867

868868

869+
def _safe_html(s: str) -> str:
870+
"""HTML-escape a string for embedding in the unsubscribe pages.
871+
872+
The unsubscribe templates use ``str.format()`` (not Jinja2) so
873+
autoescape doesn't apply. Anything we substitute that COULD
874+
contain user-controlled content needs explicit escaping.
875+
876+
Realistically the values come from a verified JWT (``kind``) and
877+
server config (``frontend``), so neither is reachable by an
878+
attacker without already having the Clerk secret. But escaping
879+
is one line and removes a future footgun.
880+
"""
881+
import html as _html
882+
return _html.escape(s or "", quote=True)
883+
884+
869885
@router.get("/email/unsubscribe", response_class=HTMLResponse)
870886
@limiter.limit("60/minute")
871887
async def email_unsubscribe(
@@ -897,11 +913,12 @@ async def email_unsubscribe(
897913
limits, so explicit @limiter.limit is required here.
898914
"""
899915
frontend = (settings.FRONTEND_URL or "").rstrip("/")
916+
frontend_safe = _safe_html(frontend)
900917

901918
decoded = verify_token(t)
902919
if decoded is None:
903920
return HTMLResponse(
904-
_UNSUBSCRIBE_HTML_ERROR.format(frontend=frontend),
921+
_UNSUBSCRIBE_HTML_ERROR.format(frontend=frontend_safe),
905922
status_code=400,
906923
)
907924

@@ -912,7 +929,9 @@ async def email_unsubscribe(
912929
cfg = _EMAIL_KIND_TO_SETTING.get(kind)
913930
if cfg is None:
914931
return HTMLResponse(
915-
_UNSUBSCRIBE_HTML_OK.format(kind=kind, frontend=frontend),
932+
_UNSUBSCRIBE_HTML_OK.format(
933+
kind=_safe_html(kind), frontend=frontend_safe,
934+
),
916935
)
917936

918937
setting_key, _default = cfg
@@ -941,5 +960,7 @@ async def email_unsubscribe(
941960
# → "camera offline" is friendlier than "email_camera_offline".
942961
pretty_kind = kind.replace("_", " ")
943962
return HTMLResponse(
944-
_UNSUBSCRIBE_HTML_OK.format(kind=pretty_kind, frontend=frontend),
963+
_UNSUBSCRIBE_HTML_OK.format(
964+
kind=_safe_html(pretty_kind), frontend=frontend_safe,
965+
),
945966
)

backend/app/core/email_templates.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ def render(
142142
env, f"{kind}.subject.txt.j2", context,
143143
fallback=f"[SourceBox Sentry] {notif_proxy.title}",
144144
).strip()
145+
# Defense in depth — strip embedded CR/LF. ``.strip()`` only
146+
# trims edges, not embedded newlines. Resend's API rejects
147+
# subjects with header injection today, but if a future provider
148+
# swap forwards subjects raw to SMTP, an embedded ``\r\nBcc: ...``
149+
# in a notification.title (which flows from operator-controlled
150+
# camera names + AI agent-supplied incident titles) would be a
151+
# header-injection vector. Cheap belt-and-suspenders.
152+
subject = subject.replace("\r", "").replace("\n", " ")
145153
body_text = _render_or_fallback(
146154
env, f"{kind}.body.txt.j2", context,
147155
fallback=_generic_body_text(notif_proxy, dash, unsubscribe_url),

backend/app/core/email_worker.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,16 @@ def run_one_tick(db: Session) -> dict:
114114
status, message_id, error = outcome
115115
_finalize_row(db, row, status, message_id, error)
116116
_write_log(db, row, status, message_id, error)
117-
summary[status if status != "sent" else "sent"] = (
118-
summary.get(status, 0) + 1
119-
)
117+
# Count by TERMINAL state, not per-attempt outcome. A row
118+
# that fails twice and succeeds on the third attempt should
119+
# show up as sent=1 in the tick that actually succeeded, not
120+
# failed=2 sent=1 across three ticks. Mid-retry rows
121+
# (status flipped back to 'pending' by _finalize_row) are
122+
# uncounted — they'll show up in the bucket they end up in
123+
# eventually.
124+
terminal = row.status
125+
if terminal in ("sent", "failed", "suppressed"):
126+
summary[terminal] = summary.get(terminal, 0) + 1
120127

121128
db.commit()
122129
return summary

backend/tests/test_email_templates.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,26 @@ def test_render_uses_dashboard_url_override():
206206
dashboard_url="https://override.example.com",
207207
)
208208
assert "https://override.example.com" in body_text
209+
210+
211+
def test_render_strips_embedded_newlines_from_subject():
212+
"""A title containing CR/LF (operator-controlled camera name OR
213+
AI-agent-supplied incident title) must NOT leak into the rendered
214+
subject as embedded newlines. Resend's API rejects subject
215+
header injection today, but a future provider swap that forwards
216+
subjects raw to SMTP would turn this into a Bcc-injection vector.
217+
218+
Covers `\\n`, `\\r`, and `\\r\\n` separators for completeness."""
219+
notif = _fake_notif(title="Front Door\r\nBcc: attacker@evil.test")
220+
221+
subject, _, _ = email_templates.render(
222+
"camera_offline", notif,
223+
unsubscribe_url="https://x.test/u",
224+
)
225+
226+
assert "\r" not in subject
227+
assert "\n" not in subject
228+
# The original characters survive (just as spaces / removed CRs)
229+
# so the alert remains intelligible to the recipient.
230+
assert "Front Door" in subject
231+
assert "Bcc: attacker@evil.test" in subject

backend/tests/test_email_worker.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,14 @@ def test_worker_logs_suppressed_outcomes(db, stub_send):
222222

223223
def test_worker_retries_on_transient_failure(db, stub_send, monkeypatch):
224224
"""First attempt fails → row stays 'pending' with attempts=1.
225-
Second tick succeeds → row flips to 'sent' with attempts=2."""
225+
Second tick succeeds → row flips to 'sent' with attempts=2.
226+
227+
Also pins the summary-counts contract: a row that retries
228+
before succeeding shows up as sent=1 in the tick that actually
229+
succeeded, NOT as failed=1 + sent=1 across both ticks. The
230+
summary line is what hits operator log streams; counting per-
231+
attempt would falsely imply more failures than actually
232+
happened to anyone reading the steady-state log."""
226233
# Make sure MAX_ATTEMPTS is high enough.
227234
monkeypatch.setattr(email_worker.settings, "EMAIL_MAX_ATTEMPTS", 3)
228235

@@ -232,38 +239,56 @@ def test_worker_retries_on_transient_failure(db, stub_send, monkeypatch):
232239
EmailSendResult(ok=True, message_id="msg_retry"),
233240
]
234241

235-
# Tick 1: fails
236-
email_worker.run_one_tick(db)
242+
# Tick 1: fails — row stays pending, summary counts NOTHING
243+
# (mid-retry isn't a terminal outcome).
244+
summary1 = email_worker.run_one_tick(db)
237245
db.refresh(row)
238246
assert row.status == "pending"
239247
assert row.attempts == 1
240248
assert "ConnectionError" in (row.error or "")
249+
assert summary1.get("failed", 0) == 0 # not yet a terminal failure
250+
assert summary1.get("sent", 0) == 0
241251

242-
# Tick 2: succeeds
243-
email_worker.run_one_tick(db)
252+
# Tick 2: succeeds — summary now reports the eventual outcome.
253+
summary2 = email_worker.run_one_tick(db)
244254
db.refresh(row)
245255
assert row.status == "sent"
246256
assert row.attempts == 2
247257
assert row.resend_message_id == "msg_retry"
248258
assert row.error is None
259+
assert summary2["sent"] == 1
260+
assert summary2.get("failed", 0) == 0
249261

250262

251263
def test_worker_gives_up_at_max_attempts(db, stub_send, monkeypatch):
252264
"""After EMAIL_MAX_ATTEMPTS failures, row is marked 'failed'
253-
permanently — no more retries for that row."""
265+
permanently — no more retries for that row.
266+
267+
Summary contract: only the FINAL tick (which marks the row
268+
terminally failed) increments summary['failed']; the prior
269+
two failed-but-retrying ticks count nothing."""
254270
monkeypatch.setattr(email_worker.settings, "EMAIL_MAX_ATTEMPTS", 3)
255271

256272
row = _make_outbox_row(db, recipient="alice@example.com")
257273
stub_send.default = EmailSendResult(ok=False, error="HTTP 500")
258274

275+
summaries = []
259276
for _ in range(3):
260-
email_worker.run_one_tick(db)
277+
summaries.append(email_worker.run_one_tick(db))
261278
db.refresh(row)
262279

263280
assert row.status == "failed"
264281
assert row.attempts == 3
265282
assert row.error == "HTTP 500"
266283

284+
# Ticks 1 + 2 produced no terminal counts; tick 3 produced one
285+
# terminal failure. Total across all ticks: failed=1, NOT failed=3.
286+
total_failed = sum(s.get("failed", 0) for s in summaries)
287+
assert total_failed == 1, (
288+
f"expected 1 terminal failure across all ticks, got {total_failed} "
289+
f"(per-tick: {[s.get('failed', 0) for s in summaries]})"
290+
)
291+
267292
# Fourth tick should pick up nothing — row is no longer 'pending'.
268293
stub_send.calls.clear()
269294
email_worker.run_one_tick(db)

backend/tests/test_notifications.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,3 +1078,41 @@ def test_unsubscribe_endpoint_idempotent(unauthenticated_client, db):
10781078
assert r1.status_code == 200
10791079
assert r2.status_code == 200
10801080
assert Setting.get(db, "org_test123", "email_camera_offline") == "false"
1081+
1082+
1083+
def test_unsubscribe_endpoint_html_escapes_kind(unauthenticated_client):
1084+
"""The unsubscribe HTML page builds via str.format() (not Jinja2,
1085+
so autoescape doesn't apply). Even though the kind value flows
1086+
from a verified JWT — making this practically unreachable without
1087+
the Clerk secret — anything substituted into the page must be
1088+
HTML-escaped as defense in depth.
1089+
1090+
This test forges a token by signing a kind containing HTML; it
1091+
succeeds only because the test process knows the secret. In
1092+
production an attacker would need CLERK_SECRET_KEY to reach
1093+
this code path, at which point they have everything anyway —
1094+
but the escape protects against future paths that might surface
1095+
less-trusted input here."""
1096+
import jwt
1097+
from app.core.email_unsubscribe import _get_secret
1098+
1099+
# Use an unknown kind so we hit the "not in _EMAIL_KIND_TO_SETTING"
1100+
# path that interpolates the raw kind verbatim (the known-kind
1101+
# path uses a pretty-printed version which would obscure the
1102+
# escape).
1103+
bad_kind = "<script>alert('xss')</script>"
1104+
token = jwt.encode(
1105+
{"org_id": "org_x", "kind": bad_kind, "sub": "email-unsubscribe"},
1106+
_get_secret(),
1107+
algorithm="HS256",
1108+
)
1109+
1110+
resp = unauthenticated_client.get(
1111+
f"/api/notifications/email/unsubscribe?t={token}"
1112+
)
1113+
1114+
body = resp.text
1115+
# Escaped form must be present.
1116+
assert "&lt;script&gt;" in body
1117+
# Raw form must NOT appear in the rendered HTML.
1118+
assert "<script>alert" not in body

0 commit comments

Comments
 (0)