[Hardening] Escape HTML in recap emails; stop echoing raw LLM errors from /ask #109
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Two output-hygiene gaps in the backend.
(a) Recap email HTML injection.
send_recap_email(webapp/backend/app/tasks/reminder_tasks.py:234) builds an HTML body by direct f-string interpolation with no escaping (:306-314):data['session_title']— GM-controlled — into the<h2>(:307) and also into theSubject:header (:303)data['campaign_name']— GM-controlled — into a<p>(:308)data['summary']— LLM-generated from player speech — into the body (:311)The result is attached as
MIMEText(html_body, "html")(:319). A session title like<script>...</script>or any HTML in the summary is delivered as live markup to every recipient's mail client — stored-content HTML injection.(b) Raw LLM error echoed to clients. The
/askbot endpoint returnsdetail=f"LLM error: {exc}"(webapp/backend/app/routers/bot.py:964). Exception text from the LLM client can leak internal endpoint URLs/config, and it violates the project-wide error contract (human-readabledetail, no internals).Fix / Spec
send_recap_email, wrap every interpolated value withhtml.escape(...):session_title,campaign_name, andsummary(escape before the.replace(chr(10), '<br>')newline conversion so the inserted<br>tags survive).\r/\nfromsession_titlebefore buildingSubject:(header-injection guard).routers/bot.py:964, replace the detail with a generic message (e.g."LLM request failed") andlog.exception(...)/log.error(...)the real exception server-side.Acceptance criteria
<script>alert(1)</script>produces an email body where it appears escaped (<script>...), and the summary is likewise escaped while newlines still become<br>./askreturns a genericdetailwith no exception text, and the exception is logged.References
webapp/backend/app/tasks/reminder_tasks.py:234(send_recap_email),:303(subject),:306-314(html_body),:319(MIMEText html)webapp/backend/app/routers/bot.py:964(LLM error: {exc})CLAUDE.md("Error response format")Filed from the July 2026 full-project review.
Picking this up as part of a v3.3.0 push. Landing on branch
hardening/backendtogether with #88, #90, #97, and #106 (grouped by component to keep the diffs reviewable).Fixed on
main(commit54437d4, merged via1c9c19f).send_recap_emailnowhtml.escape()ssession_title,campaign_name, andsummarybefore thechr(10)→<br>conversion (so the inserted<br>survive), and strips CR/LF from the title before theSubject:header./ask(routers/bot.py) returns a generic"LLM request failed"detail andlogger.exception(...)s the real error server-side. Tests:<script>alert(1)</script>arrives escaped in the body, summary escaped with newlines still becoming<br>, header injection neutralized into subject text, and/askreturns the generic detail with the endpoint URL absent from the response but present in the log. Backend suite green (358 passed), ruff clean.Separately filed #129 for an incidental latent bug found here (Celery tasks using the pooled
AsyncSessionLocalinstead oftask_session()— out of scope for this issue, deferred to v3.4.0).