procurement-ingest/lambdas/po/web_ui/handler.py
Adam Moussa e97e740c5a Gate web UIs behind auth and escape currency XSS
The po-web-ui and workorder-web-ui handlers had no auth: any
invocation path returned the full PO/WO DB. Add a fail-closed
shared-secret gate (X-Auth-Token / Bearer, constant-time compared to
WEB_UI_AUTH_TOKEN) so a future re-attached Function URL cannot
re-expose the data (URLs removed under INFRA-74). Wire the token from
the SSM String param /procurement-ingest/web-ui-auth-token.

Also fix stored XSS in po-web-ui fmt_currency: the non-numeric
fallback returned str(val) unescaped, so a prompt-injected email
could make Claude emit total_amount as <script>. Escape it.

Refs: INFRA-74
2026-06-17 11:37:49 -04:00

321 lines
13 KiB
Python

"""
Web UI Lambda.
Serves a simple HTML dashboard for viewing purchase orders.
Accessed via Lambda Function URL.
"""
import hmac
import json
import os
from decimal import Decimal
from html import escape as esc
import boto3
dynamodb = boto3.resource("dynamodb")
PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders")
# Defense-in-depth auth gate. The public Function URL was removed (INFRA-74), but
# the handler must still refuse unauthenticated requests so any future invocation
# path (re-attached Function URL, API Gateway, etc.) does not re-expose the whole
# PO DB. Callers must present the shared secret in the X-Auth-Token header (or
# Authorization: Bearer <token>). The secret is supplied via the WEB_UI_AUTH_TOKEN
# env var (wire to Secrets Manager / SSM at deploy time). If it is unset, the
# handler fails closed and denies every request.
WEB_UI_AUTH_TOKEN = os.environ.get("WEB_UI_AUTH_TOKEN")
def _header(event: dict, name: str) -> str:
"""Case-insensitive header lookup from a Lambda Function URL / APIGW event."""
headers = event.get("headers") or {}
name_lower = name.lower()
for key, value in headers.items():
if key.lower() == name_lower:
return value or ""
return ""
def is_authenticated(event: dict) -> bool:
"""Constant-time check of the request's shared secret against the configured
token. Fails closed when no token is configured."""
if not WEB_UI_AUTH_TOKEN:
return False
presented = _header(event, "x-auth-token")
if not presented:
auth = _header(event, "authorization")
if auth.lower().startswith("bearer "):
presented = auth[7:].strip()
if not presented:
return False
return hmac.compare_digest(presented, WEB_UI_AUTH_TOKEN)
def get_purchase_orders(limit=500):
table = dynamodb.Table(PO_TABLE)
items = []
response = table.scan()
items.extend(response.get("Items", []))
while "LastEvaluatedKey" in response:
response = table.scan(ExclusiveStartKey=response["LastEvaluatedKey"])
items.extend(response.get("Items", []))
items.sort(key=lambda x: x.get("processed_at", ""), reverse=True)
return items[:limit]
def render_badge(value, color_map):
if not value:
value = "unknown"
color = color_map.get(value.lower(), "#9ca3af")
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>'
STATUS_COLORS = {
"issued": "#3b82f6",
"pending buyer action": "#f59e0b",
"open": "#3b82f6",
"closed": "#10b981",
"cancelled": "#ef4444",
"soft closed": "#6b7280",
}
EMAIL_TYPE_COLORS = {
"new_po": "#3b82f6",
"revision": "#f59e0b",
"cancellation": "#ef4444",
}
def fmt_currency(val):
if val is None:
return ""
if isinstance(val, Decimal):
val = float(val)
if isinstance(val, (int, float)):
return f"${val:,.2f}"
# Non-numeric fallback: a prompt-injected email can make Claude return
# total_amount/amount as an arbitrary string. Escape it before it is
# interpolated raw into the HTML to prevent stored XSS.
return esc(str(val))
def render_po_detail(po):
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", esc(po.get("currency", "")) or None),
("Supplier", esc((po.get("supplier") or {}).get("name") or "") 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(esc(ship_to["name"]))
if ship_to.get("address"):
ship_parts.append(esc(ship_to["address"]))
if ship_to.get("location_code"):
ship_parts.append(f"Location: {esc(ship_to['location_code'])}")
if ship_to.get("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 and view_url.startswith(("https://", "http://")):
escaped_url = esc(view_url, quote=True)
fields.append(
(
"Coupa Link",
f'<a href="{escaped_url}" target="_blank" style="color:#3b82f6;">View in Coupa</a>',
)
)
details_html = ""
for label, value in fields:
if value:
details_html += f"""
<div style="display:flex;padding:8px 0;border-bottom:1px solid #f1f5f9;">
<div style="width:140px;font-size:13px;color:#64748b;font-weight:500;">{label}</div>
<div style="flex:1;font-size:14px;color:#1e293b;">{value}</div>
</div>"""
# Line items
line_items = po.get("line_items") or []
items_html = ""
if line_items:
rows = ""
for item in line_items:
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;">{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;">{esc(str(item.get("need_by", "") or ""))}</td>
</tr>"""
items_html = f"""
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h2 style="font-size:16px;margin-bottom:16px;">Line Items ({len(line_items)})</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Description</th>
<th style="text-align:right;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Qty</th>
<th style="text-align:left;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Unit</th>
<th style="text-align:right;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Price</th>
<th style="text-align:right;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Amount</th>
<th style="text-align:left;padding:10px;font-size:12px;text-transform:uppercase;color:#64748b;border-bottom:2px solid #e2e8f0;">Need By</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PO {po_number} - Sea Haven</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
</style>
</head>
<body>
<div style="max-width:800px;margin:0 auto;padding:20px;">
<div style="margin-bottom:20px;">
<a href="/" style="color:#3b82f6;text-decoration:none;font-size:14px;">&larr; All Purchase Orders</a>
</div>
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);margin-bottom:20px;">
<h1 style="font-size:20px;margin-bottom:16px;">Purchase Order {po_number}</h1>
{details_html}
</div>
{items_html}
</div>
</body>
</html>"""
def render_po_list(purchase_orders):
rows = ""
for po in purchase_orders:
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 = esc((po.get("processed_at") or "")[:16])
rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location={esc(json.dumps(f"/po?id={po.get("po_number", "")}"), quote=True)}">
<td style="padding:12px;font-weight:500;color:#3b82f6;">{po_number}</td>
<td style="padding:12px;">{supplier}</td>
<td style="padding:12px;font-weight:500;">{site_code}</td>
<td style="padding:12px;font-size:13px;">{trade}</td>
<td style="padding:12px;">{render_badge(status, STATUS_COLORS)}</td>
<td style="padding:12px;text-align:right;">{total}</td>
<td style="padding:12px;color:#64748b;font-size:13px;">{processed}</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Purchase Orders - Sea Haven</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
table {{ width: 100%; border-collapse: collapse; }}
tr:hover {{ background: #f8fafc; }}
th {{ text-align: left; padding: 12px; font-size: 12px; text-transform: uppercase; color: #64748b; border-bottom: 2px solid #e2e8f0; }}
</style>
</head>
<body>
<div style="max-width:1100px;margin:0 auto;padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h1 style="font-size:22px;">Purchase Orders</h1>
<span style="color:#64748b;font-size:14px;">{len(purchase_orders)} most recent</span>
</div>
<div style="background:#fff;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,0.08);overflow:hidden;">
<table>
<thead>
<tr>
<th>PO #</th>
<th>Supplier</th>
<th>Site</th>
<th>Trade</th>
<th>Status</th>
<th style="text-align:right;">Amount</th>
<th>Processed</th>
</tr>
</thead>
<tbody>
{rows if rows else '<tr><td colspan="7" style="padding:40px;text-align:center;color:#94a3b8;">No purchase orders yet.</td></tr>'}
</tbody>
</table>
</div>
</div>
</body>
</html>"""
def handler(event, context):
if not is_authenticated(event):
return {
"statusCode": 401,
"headers": {"Content-Type": "text/html"},
"body": "<h1>401 Unauthorized</h1>",
}
path = event.get("rawPath", "/")
qs = event.get("queryStringParameters") or {}
if path == "/po" and "id" in qs:
po_number = qs["id"]
table = dynamodb.Table(PO_TABLE)
result = table.get_item(Key={"po_number": po_number})
po = result.get("Item")
if not po:
return {
"statusCode": 404,
"headers": {"Content-Type": "text/html"},
"body": "<h1>Purchase order not found</h1>",
}
html = render_po_detail(po)
else:
purchase_orders = get_purchase_orders()
html = render_po_list(purchase_orders)
return {
"statusCode": 200,
"headers": {"Content-Type": "text/html"},
"body": html,
}