Escape HTML in both web UI dashboards to prevent XSS

Both Function URLs are public (auth_type=NONE) and render
email-derived content via f-strings. Attacker-crafted emails
could inject scripts. Added html.escape() on all interpolated
values in both PO and WO dashboards.
This commit is contained in:
Adam Moussa 2026-05-12 14:32:01 -04:00
parent d663983fb1
commit 78eb8de067
2 changed files with 58 additions and 55 deletions

View file

@ -7,6 +7,7 @@ Accessed via Lambda Function URL.
import os
from decimal import Decimal
from html import escape as esc
import boto3
@ -31,7 +32,7 @@ def render_badge(value, color_map):
if not value:
value = "unknown"
color = color_map.get(value.lower(), "#9ca3af")
label = value.replace("_", " ").title()
label = esc(value.replace("_", " ").title())
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500;">{label}</span>'
@ -62,50 +63,51 @@ def fmt_currency(val):
def render_po_detail(po):
po_number = po.get("po_number", "")
po_number = esc(po.get("po_number", ""))
fields = [
("PO Number", po_number),
("Status", render_badge(po.get("po_status", ""), STATUS_COLORS)),
("Email Type", render_badge(po.get("email_type", ""), EMAIL_TYPE_COLORS)),
("Total Amount", fmt_currency(po.get("total_amount"))),
("Currency", po.get("currency")),
("Supplier", (po.get("supplier") or {}).get("name")),
("Site Code", po.get("site_code")),
("State", po.get("state")),
("Trade", po.get("trade")),
("Fiscal Year", po.get("fiscal_year")),
("Coupa Category", po.get("coupa_category")),
("Submitted By", po.get("submitted_by")),
("On Behalf Of", po.get("on_behalf_of")),
("Order Date", po.get("order_date")),
("Revision Date", po.get("revision_date")),
("Payment Terms", po.get("payment_terms")),
("Requisition #", po.get("requisition_number")),
("Department", po.get("department")),
("Data Source", po.get("data_source")),
("Processed At", po.get("processed_at")),
("Currency", esc(po.get("currency", "")) or None),
("Supplier", esc((po.get("supplier") or {}).get("name", "")) or None),
("Site Code", esc(po.get("site_code", "")) or None),
("State", esc(po.get("state", "")) or None),
("Trade", esc(po.get("trade", "")) or None),
("Fiscal Year", esc(po.get("fiscal_year", "")) or None),
("Coupa Category", esc(po.get("coupa_category", "")) or None),
("Submitted By", esc(po.get("submitted_by", "")) or None),
("On Behalf Of", esc(po.get("on_behalf_of", "")) or None),
("Order Date", esc(po.get("order_date", "")) or None),
("Revision Date", esc(po.get("revision_date", "")) or None),
("Payment Terms", esc(po.get("payment_terms", "")) or None),
("Requisition #", esc(po.get("requisition_number", "")) or None),
("Department", esc(po.get("department", "")) or None),
("Data Source", esc(po.get("data_source", "")) or None),
("Processed At", esc(po.get("processed_at", "")) or None),
]
ship_to = po.get("ship_to") or {}
if any(ship_to.values()):
ship_parts = []
if ship_to.get("name"):
ship_parts.append(ship_to["name"])
ship_parts.append(esc(ship_to["name"]))
if ship_to.get("address"):
ship_parts.append(ship_to["address"])
ship_parts.append(esc(ship_to["address"]))
if ship_to.get("location_code"):
ship_parts.append(f"Location: {ship_to['location_code']}")
ship_parts.append(f"Location: {esc(ship_to['location_code'])}")
if ship_to.get("attn"):
ship_parts.append(f"Attn: {ship_to['attn']}")
ship_parts.append(f"Attn: {esc(ship_to['attn'])}")
fields.append(("Ship To", "<br>".join(ship_parts)))
view_url = po.get("view_order_url")
if view_url:
escaped_url = esc(view_url, quote=True)
fields.append(
(
"Coupa Link",
f'<a href="{view_url}" target="_blank" style="color:#3b82f6;">View in Coupa</a>',
f'<a href="{escaped_url}" target="_blank" style="color:#3b82f6;">View in Coupa</a>',
)
)
@ -124,17 +126,17 @@ def render_po_detail(po):
if line_items:
rows = ""
for item in line_items:
qty = item.get("quantity", "") or ""
unit = item.get("unit", "") or ""
price = item.get("price", "") or ""
qty = esc(str(item.get("quantity", "") or ""))
unit = esc(str(item.get("unit", "") or ""))
price = esc(str(item.get("price", "") or ""))
rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:10px;font-size:14px;">{item.get("description", "")}</td>
<td style="padding:10px;font-size:14px;">{esc(str(item.get("description", "")))}</td>
<td style="padding:10px;font-size:13px;text-align:right;">{qty}</td>
<td style="padding:10px;font-size:13px;">{unit}</td>
<td style="padding:10px;font-size:13px;text-align:right;">{price}</td>
<td style="padding:10px;font-size:14px;text-align:right;">{fmt_currency(item.get("amount"))}</td>
<td style="padding:10px;font-size:13px;color:#64748b;">{item.get("need_by", "") or ""}</td>
<td style="padding:10px;font-size:13px;color:#64748b;">{esc(str(item.get("need_by", "") or ""))}</td>
</tr>"""
items_html = f"""
@ -184,13 +186,13 @@ def render_po_detail(po):
def render_po_list(purchase_orders):
rows = ""
for po in purchase_orders:
po_number = po.get("po_number", "")
supplier = (po.get("supplier") or {}).get("name", "")
site_code = po.get("site_code", "")
trade = po.get("trade", "")
po_number = esc(po.get("po_number", ""))
supplier = esc((po.get("supplier") or {}).get("name", ""))
site_code = esc(po.get("site_code", ""))
trade = esc(po.get("trade", ""))
status = po.get("po_status", "")
total = fmt_currency(po.get("total_amount"))
processed = (po.get("processed_at") or "")[:16]
processed = esc((po.get("processed_at") or "")[:16])
rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/po?id={po_number}'">

View file

@ -6,6 +6,7 @@ Accessed via Lambda Function URL.
"""
import os
from html import escape as esc
import boto3
@ -37,7 +38,7 @@ def get_comments(work_order_id):
def render_badge(value, color_map):
color = color_map.get(value, "#9ca3af")
label = value.replace("_", " ").title()
label = esc(value.replace("_", " ").title())
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500;">{label}</span>'
@ -65,9 +66,9 @@ def render_work_order_detail(wo, events):
if events:
for e in events:
record_type = e.get("record_type", "unknown")
commenter = e.get("commenter", "")
created = e.get("created_at", "")
text = e.get("text", "")
commenter = esc(e.get("commenter", ""))
created = esc(e.get("created_at", ""))
text = esc(e.get("text", ""))
badge = render_badge(record_type, RECORD_TYPE_COLORS)
commenter_str = (
f"<strong>{commenter}</strong> &mdash; " if commenter else ""
@ -91,25 +92,25 @@ def render_work_order_detail(wo, events):
else:
events_html = '<p style="color:#94a3b8;font-style:italic;">No events yet.</p>'
wo_id = wo.get("work_order_id", "")
wo_id = esc(wo.get("work_order_id", ""))
fields = [
("Description", wo.get("description")),
("Description", esc(wo.get("description", "")) or None),
("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)),
(
"Record Type",
render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS),
),
("Site Code", wo.get("site_code")),
("Building", wo.get("building")),
("Address", wo.get("address")),
("Severity", wo.get("severity")),
("Priority", wo.get("priority")),
("Due Date", wo.get("due_date")),
("Date Reported", wo.get("date_reported")),
("Scheduled Start", wo.get("scheduled_start")),
("Assigned To", wo.get("assigned_to")),
("Created", wo.get("created_at")),
("Last Updated", wo.get("updated_at")),
("Site Code", esc(wo.get("site_code", "")) or None),
("Building", esc(wo.get("building", "")) or None),
("Address", esc(wo.get("address", "")) or None),
("Severity", esc(wo.get("severity", "")) or None),
("Priority", esc(wo.get("priority", "")) or None),
("Due Date", esc(wo.get("due_date", "")) or None),
("Date Reported", esc(wo.get("date_reported", "")) or None),
("Scheduled Start", esc(wo.get("scheduled_start", "")) or None),
("Assigned To", esc(wo.get("assigned_to", "")) or None),
("Created", esc(wo.get("created_at", "")) or None),
("Last Updated", esc(wo.get("updated_at", "")) or None),
]
details_html = ""
@ -153,13 +154,13 @@ def render_work_order_detail(wo, events):
def render_work_orders_list(work_orders):
rows = ""
for wo in work_orders:
wo_id = wo.get("work_order_id", "")
desc = wo.get("description", "")
site = wo.get("site_code", "")
wo_id = esc(wo.get("work_order_id", ""))
desc = esc(wo.get("description", ""))
site = esc(wo.get("site_code", ""))
status = wo.get("wo_status", "unknown")
record_type = wo.get("record_type", "unknown")
due = wo.get("due_date", "")
updated = wo.get("updated_at", "")[:16]
due = esc(wo.get("due_date", ""))
updated = esc(wo.get("updated_at", "")[:16])
rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/wo?id={wo_id}'">