Public Access
fix: html-escape recipe/ingredient names in email templates (#P5-a, #P5-b)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -132,7 +132,9 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
vote_url = (
|
vote_url = (
|
||||||
f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}"
|
f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}"
|
||||||
)
|
)
|
||||||
recipe_name = item.recipe.name if item.recipe else str(item.recipe_id)
|
recipe_name = html.escape(
|
||||||
|
item.recipe.name if item.recipe else str(item.recipe_id)
|
||||||
|
)
|
||||||
item_html_parts.append(
|
item_html_parts.append(
|
||||||
f'<li>{recipe_name} — <a href="{vote_url}">Vote</a></li>'
|
f'<li>{recipe_name} — <a href="{vote_url}">Vote</a></li>'
|
||||||
)
|
)
|
||||||
@@ -140,7 +142,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
if not item_html_parts:
|
if not item_html_parts:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
html = (
|
email_html = (
|
||||||
f"<h2>This week's meal suggestions</h2>"
|
f"<h2>This week's meal suggestions</h2>"
|
||||||
f"{stale_banner}"
|
f"{stale_banner}"
|
||||||
f"<p>Hi {member.name}, please vote on this week's meals by Fri 17:00 PT:</p>"
|
f"<p>Hi {member.name}, please vote on this week's meals by Fri 17:00 PT:</p>"
|
||||||
@@ -150,7 +152,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
backend.send(
|
backend.send(
|
||||||
to=member.email,
|
to=member.email,
|
||||||
subject=f"Meal plan for week of {run.week_start_date}",
|
subject=f"Meal plan for week of {run.week_start_date}",
|
||||||
html=html,
|
html=email_html,
|
||||||
)
|
)
|
||||||
logger.info("step_email: sent to %s", member.email)
|
logger.info("step_email: sent to %s", member.email)
|
||||||
|
|
||||||
@@ -177,7 +179,7 @@ def step_deadline(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
family = db.query(FamilyProfile).filter(FamilyProfile.id == run.family_id).first()
|
family = db.query(FamilyProfile).filter(FamilyProfile.id == run.family_id).first()
|
||||||
policy = getattr(family, "pending_approval_policy", "approve") if family else "approve"
|
policy = family.pending_approval_policy if family else "approve"
|
||||||
|
|
||||||
resolved = 0
|
resolved = 0
|
||||||
for item in plan.items:
|
for item in plan.items:
|
||||||
@@ -227,28 +229,28 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
|
|
||||||
if approved_items:
|
if approved_items:
|
||||||
rows_html = "".join(
|
rows_html = "".join(
|
||||||
f"<tr><td>{item.recipe.name}</td>"
|
f"<tr><td>{html.escape(item.recipe.name)}</td>"
|
||||||
f"<td>{ing.get('name', '')}</td>"
|
f"<td>{html.escape(str(ing.get('name', '')))}</td>"
|
||||||
f"<td>{ing.get('qty', '')} {ing.get('unit', '')}</td></tr>"
|
f"<td>{html.escape(str(ing.get('qty', '')))} {html.escape(str(ing.get('unit', '')))}</td></tr>"
|
||||||
for item in approved_items
|
for item in approved_items
|
||||||
if item.recipe
|
if item.recipe
|
||||||
for ing in (item.recipe.ingredients or [])
|
for ing in (item.recipe.ingredients or [])
|
||||||
)
|
)
|
||||||
html = (
|
email_html = (
|
||||||
f"<h2>Shopping list — week of {run.week_start_date}</h2>"
|
f"<h2>Shopping list — week of {run.week_start_date}</h2>"
|
||||||
f"<p>{len(approved_items)} meal(s) approved.</p>"
|
f"<p>{len(approved_items)} meal(s) approved.</p>"
|
||||||
f"<table><thead><tr><th>Recipe</th><th>Ingredient</th><th>Qty</th></tr></thead>"
|
f"<table><thead><tr><th>Recipe</th><th>Ingredient</th><th>Qty</th></tr></thead>"
|
||||||
f"<tbody>{rows_html}</tbody></table>"
|
f"<tbody>{rows_html}</tbody></table>"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
html = f"<p>No meals were approved for week of {run.week_start_date}.</p>"
|
email_html = f"<p>No meals were approved for week of {run.week_start_date}.</p>"
|
||||||
|
|
||||||
backend = get_email_backend()
|
backend = get_email_backend()
|
||||||
for member in members:
|
for member in members:
|
||||||
backend.send(
|
backend.send(
|
||||||
to=member.email,
|
to=member.email,
|
||||||
subject=f"Shopping list — week of {run.week_start_date}",
|
subject=f"Shopping list — week of {run.week_start_date}",
|
||||||
html=html,
|
html=email_html,
|
||||||
)
|
)
|
||||||
logger.info("step_finalize: shopping list sent to %s", member.email)
|
logger.info("step_finalize: shopping list sent to %s", member.email)
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,29 @@ def test_step_email_stale_banner(db, weekly_run_generated, meal_plan, pending_it
|
|||||||
assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower()
|
assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_email_escapes_recipe_name(
|
||||||
|
db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch
|
||||||
|
):
|
||||||
|
pending_item.recipe.name = "<script>alert('xss')</script>"
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class FakeBackend:
|
||||||
|
def send(self, **kwargs):
|
||||||
|
sent.append(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.orchestrator.steps.get_email_backend",
|
||||||
|
lambda: FakeBackend(),
|
||||||
|
)
|
||||||
|
from app.services.orchestrator.steps import step_email
|
||||||
|
step_email(weekly_run_generated, db)
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert "<script>" not in sent[0]["html"]
|
||||||
|
assert "<script>" in sent[0]["html"]
|
||||||
|
|
||||||
|
|
||||||
# ── step_reminder ──────────────────────────────────────────────────────────
|
# ── step_reminder ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user