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 import os
from decimal import Decimal from decimal import Decimal
from html import escape as esc
import boto3 import boto3
@ -31,7 +32,7 @@ def render_badge(value, color_map):
if not value: if not value:
value = "unknown" value = "unknown"
color = color_map.get(value.lower(), "#9ca3af") 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>' 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): def render_po_detail(po):
po_number = po.get("po_number", "") po_number = esc(po.get("po_number", ""))
fields = [ fields = [
("PO Number", po_number), ("PO Number", po_number),
("Status", render_badge(po.get("po_status", ""), STATUS_COLORS)), ("Status", render_badge(po.get("po_status", ""), STATUS_COLORS)),
("Email Type", render_badge(po.get("email_type", ""), EMAIL_TYPE_COLORS)), ("Email Type", render_badge(po.get("email_type", ""), EMAIL_TYPE_COLORS)),
("Total Amount", fmt_currency(po.get("total_amount"))), ("Total Amount", fmt_currency(po.get("total_amount"))),
("Currency", po.get("currency")), ("Currency", esc(po.get("currency", "")) or None),
("Supplier", (po.get("supplier") or {}).get("name")), ("Supplier", esc((po.get("supplier") or {}).get("name", "")) or None),
("Site Code", po.get("site_code")), ("Site Code", esc(po.get("site_code", "")) or None),
("State", po.get("state")), ("State", esc(po.get("state", "")) or None),
("Trade", po.get("trade")), ("Trade", esc(po.get("trade", "")) or None),
("Fiscal Year", po.get("fiscal_year")), ("Fiscal Year", esc(po.get("fiscal_year", "")) or None),
("Coupa Category", po.get("coupa_category")), ("Coupa Category", esc(po.get("coupa_category", "")) or None),
("Submitted By", po.get("submitted_by")), ("Submitted By", esc(po.get("submitted_by", "")) or None),
("On Behalf Of", po.get("on_behalf_of")), ("On Behalf Of", esc(po.get("on_behalf_of", "")) or None),
("Order Date", po.get("order_date")), ("Order Date", esc(po.get("order_date", "")) or None),
("Revision Date", po.get("revision_date")), ("Revision Date", esc(po.get("revision_date", "")) or None),
("Payment Terms", po.get("payment_terms")), ("Payment Terms", esc(po.get("payment_terms", "")) or None),
("Requisition #", po.get("requisition_number")), ("Requisition #", esc(po.get("requisition_number", "")) or None),
("Department", po.get("department")), ("Department", esc(po.get("department", "")) or None),
("Data Source", po.get("data_source")), ("Data Source", esc(po.get("data_source", "")) or None),
("Processed At", po.get("processed_at")), ("Processed At", esc(po.get("processed_at", "")) or None),
] ]
ship_to = po.get("ship_to") or {} ship_to = po.get("ship_to") or {}
if any(ship_to.values()): if any(ship_to.values()):
ship_parts = [] ship_parts = []
if ship_to.get("name"): if ship_to.get("name"):
ship_parts.append(ship_to["name"]) ship_parts.append(esc(ship_to["name"]))
if ship_to.get("address"): if ship_to.get("address"):
ship_parts.append(ship_to["address"]) ship_parts.append(esc(ship_to["address"]))
if ship_to.get("location_code"): 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"): 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))) fields.append(("Ship To", "<br>".join(ship_parts)))
view_url = po.get("view_order_url") view_url = po.get("view_order_url")
if view_url: if view_url:
escaped_url = esc(view_url, quote=True)
fields.append( fields.append(
( (
"Coupa Link", "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: if line_items:
rows = "" rows = ""
for item in line_items: for item in line_items:
qty = item.get("quantity", "") or "" qty = esc(str(item.get("quantity", "") or ""))
unit = item.get("unit", "") or "" unit = esc(str(item.get("unit", "") or ""))
price = item.get("price", "") or "" price = esc(str(item.get("price", "") or ""))
rows += f""" rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;"> <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;text-align:right;">{qty}</td>
<td style="padding:10px;font-size:13px;">{unit}</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: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: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>""" </tr>"""
items_html = f""" items_html = f"""
@ -184,13 +186,13 @@ def render_po_detail(po):
def render_po_list(purchase_orders): def render_po_list(purchase_orders):
rows = "" rows = ""
for po in purchase_orders: for po in purchase_orders:
po_number = po.get("po_number", "") po_number = esc(po.get("po_number", ""))
supplier = (po.get("supplier") or {}).get("name", "") supplier = esc((po.get("supplier") or {}).get("name", ""))
site_code = po.get("site_code", "") site_code = esc(po.get("site_code", ""))
trade = po.get("trade", "") trade = esc(po.get("trade", ""))
status = po.get("po_status", "") status = po.get("po_status", "")
total = fmt_currency(po.get("total_amount")) total = fmt_currency(po.get("total_amount"))
processed = (po.get("processed_at") or "")[:16] processed = esc((po.get("processed_at") or "")[:16])
rows += f""" rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/po?id={po_number}'"> <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 import os
from html import escape as esc
import boto3 import boto3
@ -37,7 +38,7 @@ def get_comments(work_order_id):
def render_badge(value, color_map): def render_badge(value, color_map):
color = color_map.get(value, "#9ca3af") 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>' 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: if events:
for e in events: for e in events:
record_type = e.get("record_type", "unknown") record_type = e.get("record_type", "unknown")
commenter = e.get("commenter", "") commenter = esc(e.get("commenter", ""))
created = e.get("created_at", "") created = esc(e.get("created_at", ""))
text = e.get("text", "") text = esc(e.get("text", ""))
badge = render_badge(record_type, RECORD_TYPE_COLORS) badge = render_badge(record_type, RECORD_TYPE_COLORS)
commenter_str = ( commenter_str = (
f"<strong>{commenter}</strong> &mdash; " if commenter else "" f"<strong>{commenter}</strong> &mdash; " if commenter else ""
@ -91,25 +92,25 @@ def render_work_order_detail(wo, events):
else: else:
events_html = '<p style="color:#94a3b8;font-style:italic;">No events yet.</p>' 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 = [ fields = [
("Description", wo.get("description")), ("Description", esc(wo.get("description", "")) or None),
("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)), ("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)),
( (
"Record Type", "Record Type",
render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS), render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS),
), ),
("Site Code", wo.get("site_code")), ("Site Code", esc(wo.get("site_code", "")) or None),
("Building", wo.get("building")), ("Building", esc(wo.get("building", "")) or None),
("Address", wo.get("address")), ("Address", esc(wo.get("address", "")) or None),
("Severity", wo.get("severity")), ("Severity", esc(wo.get("severity", "")) or None),
("Priority", wo.get("priority")), ("Priority", esc(wo.get("priority", "")) or None),
("Due Date", wo.get("due_date")), ("Due Date", esc(wo.get("due_date", "")) or None),
("Date Reported", wo.get("date_reported")), ("Date Reported", esc(wo.get("date_reported", "")) or None),
("Scheduled Start", wo.get("scheduled_start")), ("Scheduled Start", esc(wo.get("scheduled_start", "")) or None),
("Assigned To", wo.get("assigned_to")), ("Assigned To", esc(wo.get("assigned_to", "")) or None),
("Created", wo.get("created_at")), ("Created", esc(wo.get("created_at", "")) or None),
("Last Updated", wo.get("updated_at")), ("Last Updated", esc(wo.get("updated_at", "")) or None),
] ]
details_html = "" details_html = ""
@ -153,13 +154,13 @@ def render_work_order_detail(wo, events):
def render_work_orders_list(work_orders): def render_work_orders_list(work_orders):
rows = "" rows = ""
for wo in work_orders: for wo in work_orders:
wo_id = wo.get("work_order_id", "") wo_id = esc(wo.get("work_order_id", ""))
desc = wo.get("description", "") desc = esc(wo.get("description", ""))
site = wo.get("site_code", "") site = esc(wo.get("site_code", ""))
status = wo.get("wo_status", "unknown") status = wo.get("wo_status", "unknown")
record_type = wo.get("record_type", "unknown") record_type = wo.get("record_type", "unknown")
due = wo.get("due_date", "") due = esc(wo.get("due_date", ""))
updated = wo.get("updated_at", "")[:16] updated = esc(wo.get("updated_at", "")[:16])
rows += f""" rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/wo?id={wo_id}'"> <tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/wo?id={wo_id}'">