Inline styled content.
diff --git a/mailbox-app/tests/fixtures/html_malformed_css.eml b/mailbox-app/tests/fixtures/html_malformed_css.eml new file mode 100644 index 0000000..a6588df --- /dev/null +++ b/mailbox-app/tests/fixtures/html_malformed_css.eml @@ -0,0 +1,14 @@ +From: SenderMalformed CSS must not break the message.
diff --git a/mailbox-app/tests/fixtures/html_media_style.eml b/mailbox-app/tests/fixtures/html_media_style.eml new file mode 100644 index 0000000..40aa0da --- /dev/null +++ b/mailbox-app/tests/fixtures/html_media_style.eml @@ -0,0 +1,14 @@ +From: SenderSafe styled content
+ diff --git a/mailbox-app/tests/fixtures/html_style_unsafe.eml b/mailbox-app/tests/fixtures/html_style_unsafe.eml new file mode 100644 index 0000000..8d9f62c --- /dev/null +++ b/mailbox-app/tests/fixtures/html_style_unsafe.eml @@ -0,0 +1,14 @@ +From: SenderUnsafe CSS filtered, content retained.
diff --git a/mailbox-app/tests/fixtures/html_table_layout.eml b/mailbox-app/tests/fixtures/html_table_layout.eml new file mode 100644 index 0000000..088de73 --- /dev/null +++ b/mailbox-app/tests/fixtures/html_table_layout.eml @@ -0,0 +1,11 @@ +From: Sender| Table cell. |
Safe body
' + ) + message.save(update_fields=["sanitized_html_body", "updated_at"]) html_response = client.get(reverse("messages:safe_html", args=[message.uuid])) assert html_response.status_code == 200 assert b"Safe body" in html_response.content + assert b".brand{color:red;padding:8px;}" in html_response.content assert b"Remote content" not in html_response.content assert "default-src 'none'" in html_response["Content-Security-Policy"] + assert "img-src data:" in html_response["Content-Security-Policy"] + assert "style-src 'unsafe-inline'" in html_response["Content-Security-Policy"] + assert "form-action 'none'" in html_response["Content-Security-Policy"] + assert "navigate-to 'none'" in html_response["Content-Security-Policy"] assert html_response["X-Frame-Options"] == "SAMEORIGIN" message.refresh_from_db() diff --git a/mailbox-app/tests/integration/test_message_body_repair.py b/mailbox-app/tests/integration/test_message_body_repair.py index 373a7de..4b73817 100644 --- a/mailbox-app/tests/integration/test_message_body_repair.py +++ b/mailbox-app/tests/integration/test_message_body_repair.py @@ -67,7 +67,12 @@ def test_repair_message_bodies_dry_run_then_mutation_preserves_state(mailbox, fi message.refresh_from_db() assert "Welcome to Harpoon!" in message.sanitized_html_body - assert "#outlook" not in message.sanitized_html_body + lowered = message.sanitized_html_body.lower() + assert "" + '' + "Safe
" + ).lower() + assert ".safe{color:red;padding:8px;}" in cleaned + assert "@media screen and (max-width:600px)" in cleaned + assert "font-weight:700;" in cleaned + assert "tracker.test" not in cleaned + assert "@import" not in cleaned + assert "background-image" not in cleaned + assert "position:fixed" not in cleaned + assert "transform" not in cleaned + assert "expression(" not in cleaned + assert "var(" not in cleaned + assert "--mail-width" not in cleaned + assert "data:image" not in cleaned + + @pytest.mark.django_db def test_stored_and_reflected_xss_are_escaped(client, admin_user, mailbox, message): message.subject = 'Readable email content
" "Visible body
" + ) + lowered = cleaned.lower() + assert "head secret" not in lowered + assert "hidden title" not in lowered + assert "description" not in lowered + assert "alert(" not in lowered + assert "tracker.test" not in lowered + assert ".brand{color:#123456;}" in lowered + assert "Visible body" in cleaned + + +def test_sanitize_html_preserves_safe_inline_css_and_drops_unsafe_declarations(): + cleaned = sanitize_html( + 'Styled
' + ) + lowered = cleaned.lower() + assert "color:red;" in lowered + assert "padding:12px;" in lowered + assert "width:calc(100% - 20px);" in lowered + assert "background-image" not in lowered + assert "tracker.test" not in lowered + assert "position:fixed" not in lowered + + +def test_sanitize_html_allows_bounded_media_rules_and_drops_other_at_rules(): + cleaned = sanitize_html( + "Still readable
') + assert "Still readable" in cleaned + assert 'style=""' in cleaned or "style" not in cleaned + + oversized_stylesheet = ".safe{color:red;}" * 10_000 + cleaned = sanitize_html(f"Body survives
") + assert "Body survives" in cleaned + assert "", "", lowered, flags=re.DOTALL) + assert "#outlook" not in visible_markup + assert ".readmsgbody" not in visible_markup + + +def test_parse_message_css_fixture_contract(fixtures_dir: Path): + safe = parse_message((fixtures_dir / "html_style_safe.eml").read_bytes()).sanitized_html_body.lower() + unsafe = parse_message((fixtures_dir / "html_style_unsafe.eml").read_bytes()).sanitized_html_body.lower() + inline = parse_message((fixtures_dir / "html_inline_style.eml").read_bytes()).sanitized_html_body.lower() + media = parse_message((fixtures_dir / "html_media_style.eml").read_bytes()).sanitized_html_body.lower() + table = parse_message((fixtures_dir / "html_table_layout.eml").read_bytes()).sanitized_html_body.lower() + malformed = parse_message( + (fixtures_dir / "html_malformed_css.eml").read_bytes() + ).sanitized_html_body.lower() + + assert ".brand{color:#123456;font-size:18px;padding:12px;}" in safe + assert "font-weight:700 !important;" in safe + + assert ".safe{color:blue;}" in unsafe + assert "@import" not in unsafe + assert "@font-face" not in unsafe + assert "tracker.example.test" not in unsafe + assert "position:fixed" not in unsafe + assert "z-index" not in unsafe + + assert "color:rgb(10, 20, 30);" in inline + assert "padding:16px;" in inline + assert "width:calc(100% - 20px);" in inline + assert "background-image" not in inline + assert "position:fixed" not in inline + + assert "@media screen and (max-width: 600px)" in media + assert "prefers-color-scheme" not in media + assert "@media only" not in media + + assert "width:600px;" in table + assert "border-collapse:collapse;" in table + assert "table-layout:fixed;" in table + assert "background-color:#f4f4f4;" in table + assert "vertical-align:top;" in table + + assert "malformed css must not break the message." in malformed + assert "padding:10px;" in malformed + assert "expression(" not in malformed def test_parse_message_extracts_headers_bodies_and_attachments(fixtures_dir: Path):