feat: sprint 6 — engagement export (md/csv/pdf) #9
@@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
- 2026-06-08 (post-refactor, pre-merge) — **Two MEDIUM security regressions fixed** in the 7-column refactor (`3a9d9d3`), flagged by `security-guidance@claude-code-plugins`:
|
- 2026-06-08 (post-refactor, pre-merge) — **Two MEDIUM security regressions fixed** in the 7-column refactor (`3a9d9d3`), flagged by `security-guidance@claude-code-plugins`:
|
||||||
1. **CSV formula injection inside the multi-line `Exécution` cell**: `_csv_safe` only checks `cell[0]`. With `executed_at` non-null, the cell starts with a safe date digit, but inner lines (commands, execution_result) starting with `=`/`+`/`-`/`@` evaded defense. Fix: `_format_execution_csv()` applies `_csv_safe` per user-controlled component BEFORE the multi-line concat. Outer `_csv_safe` on the assembled cell retained as belt-and-braces.
|
1. **CSV formula injection inside the multi-line `Exécution` cell**: `_csv_safe` only checks `cell[0]`. With `executed_at` non-null, the cell starts with a safe date digit, but inner lines (commands, execution_result) starting with `=`/`+`/`-`/`@` evaded defense. Fix: `_format_execution_csv()` applies `_csv_safe` per user-controlled component BEFORE the multi-line concat. Outer `_csv_safe` on the assembled cell retained as belt-and-braces.
|
||||||
2. **Stored XSS in Markdown table cells**: the new GFM table allows inline HTML (we use it for `<br/>`). A `sim.commands = "<script>alert(1)</script>"` would be rendered raw by MD viewers that interpret inline HTML (Notion, Obsidian, GitHub preview). Fix: `_cell()` now calls `html.escape()` on each value BEFORE the pipe-escape and `\n` → `<br/>` substitution — mirrors the `_render_engagement_html` PDF defense. The `<br/>` we insert ourselves stays unescaped (it's not user-controlled). 2 dedicated regression tests added.
|
2. **Stored XSS in Markdown table cells**: the new GFM table allows inline HTML (we use it for `<br/>`). A `sim.commands = "<script>alert(1)</script>"` would be rendered raw by MD viewers that interpret inline HTML (Notion, Obsidian, GitHub preview). Fix: `_cell()` now calls `html.escape()` on each value BEFORE the pipe-escape and `\n` → `<br/>` substitution — mirrors the `_render_engagement_html` PDF defense. The `<br/>` we insert ourselves stays unescaped (it's not user-controlled). 2 dedicated regression tests added.
|
||||||
|
- 2026-06-09 (post-merge-review) — PDF export: A4 landscape orientation (user feedback post-merge-review). `@page { size: A4 landscape; }` added to `_CSS`; `font-size` reduced to 11px and `table-layout: fixed; word-break: break-word` added to prevent 7-column overflow on narrower portrait layout.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -198,11 +198,12 @@ def render_engagement_csv(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_CSS = """
|
_CSS = """
|
||||||
body { font-family: sans-serif; font-size: 13px; color: #1a1a1a; margin: 40px; }
|
@page { size: A4 landscape; margin: 20mm; }
|
||||||
h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; }
|
body { font-family: sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
|
||||||
h2 { font-size: 17px; margin-top: 32px; color: #333; }
|
h1 { font-size: 20px; border-bottom: 2px solid #333; padding-bottom: 6px; }
|
||||||
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; }
|
h2 { font-size: 15px; margin-top: 32px; color: #333; }
|
||||||
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top; white-space: pre-wrap; }
|
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; table-layout: fixed; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 3px 6px; text-align: left; vertical-align: top; white-space: pre-wrap; word-break: break-word; }
|
||||||
th { background: #e0e0e0; }
|
th { background: #e0e0e0; }
|
||||||
.meta { color: #555; margin-bottom: 16px; }
|
.meta { color: #555; margin-bottom: 16px; }
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -291,6 +291,15 @@ def test_render_engagement_pdf_contains_simulation_table(app) -> None:
|
|||||||
assert header in html, f"Expected French header '{header}' in HTML"
|
assert header in html, f"Expected French header '{header}' in HTML"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_html_has_landscape_page_rule(app) -> None:
|
||||||
|
from backend.app.services.export import _render_engagement_html
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
html = _render_engagement_html(eng, [])
|
||||||
|
assert "landscape" in html, "HTML must include A4 landscape @page rule for PDF output"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Defense-in-depth: filename header injection
|
# Defense-in-depth: filename header injection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user