diff --git a/goodnews/api.py b/goodnews/api.py index 31a6286..fdde4b1 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -843,8 +843,9 @@ def create_app() -> FastAPI: @app.post("/api/admin/feedback/{fid}/reply") def admin_feedback_reply(fid: int, body: FeedbackReplyBody, request: Request) -> dict: - # Sanitize the editor HTML to our allowlist; derive the plain-text fallback. - reply_html = sanitize_reply_html(body.html)[:8000] + # Cap the RAW editor HTML first (slicing sanitized output could sever a + # tag), then sanitize the whole thing. + reply_html = sanitize_reply_html((body.html or "")[:20000]) reply_text = reply_html_to_text(reply_html) if not reply_text: raise HTTPException(status_code=422, detail="Reply message is required.") diff --git a/goodnews/markup.py b/goodnews/markup.py index cbd8159..0392fcb 100644 --- a/goodnews/markup.py +++ b/goodnews/markup.py @@ -118,6 +118,11 @@ def sanitize_reply_html(raw: str) -> str: p = _Sanitizer() p.feed(raw) p.close() + # Close any still-open allowed tags (malformed input → never emit a dangling + # or severed tag into stored HTML / the email body). + for canon in reversed(p.open): + if canon: + p.out.append(f"{canon}>") html = "".join(p.out) # If nothing but markup/whitespace survived, treat as empty. if not re.sub(r"<[^>]+>", "", html).strip(): diff --git a/tests/test_markup.py b/tests/test_markup.py index b7fe947..fd056c9 100644 --- a/tests/test_markup.py +++ b/tests/test_markup.py @@ -41,3 +41,13 @@ def test_html_to_text(): assert t2("
hi there
") == "hi there" assert t2("hi") == "
hi
" + assert s("