mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-02 23:53:13 +00:00
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
This commit is contained in:
parent
5785f39590
commit
e97e740c5a
4 changed files with 113 additions and 1 deletions
|
|
@ -191,6 +191,19 @@ class PoIngestStack(Stack):
|
||||||
log_retention=logs.RetentionDays.TWO_MONTHS,
|
log_retention=logs.RetentionDays.TWO_MONTHS,
|
||||||
environment={
|
environment={
|
||||||
"PO_TABLE": "purchase-orders",
|
"PO_TABLE": "purchase-orders",
|
||||||
|
# Defense-in-depth shared secret for the web UI handler. The
|
||||||
|
# handler fails closed if this is unset, so any future invocation
|
||||||
|
# path cannot re-expose the PO DB unauthenticated. Sourced from the
|
||||||
|
# SSM String parameter /procurement-ingest/web-ui-auth-token; create
|
||||||
|
# it before deploy. NOTE: a plaintext String (not SecureString) is
|
||||||
|
# used deliberately — value_for_string_parameter resolves String
|
||||||
|
# params into Lambda env vars at deploy via {{resolve:ssm:...}};
|
||||||
|
# SecureString dynamic references are NOT resolved in Lambda env
|
||||||
|
# vars and would pass the token literally. This is a defense-in-depth
|
||||||
|
# floor for a currently-detached URL (INFRA-74), not a primary auth.
|
||||||
|
"WEB_UI_AUTH_TOKEN": ssm.StringParameter.value_for_string_parameter(
|
||||||
|
self, "/procurement-ingest/web-ui-auth-token"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,19 @@ class WorkorderIngestStack(Stack):
|
||||||
environment={
|
environment={
|
||||||
"WORK_ORDERS_TABLE": work_orders_table.table_name,
|
"WORK_ORDERS_TABLE": work_orders_table.table_name,
|
||||||
"COMMENTS_TABLE": comments_table.table_name,
|
"COMMENTS_TABLE": comments_table.table_name,
|
||||||
|
# Defense-in-depth shared secret for the web UI handler. The
|
||||||
|
# handler fails closed if this is unset, so any future invocation
|
||||||
|
# path cannot re-expose the WO DB unauthenticated. Sourced from the
|
||||||
|
# SSM String parameter /procurement-ingest/web-ui-auth-token; create
|
||||||
|
# it before deploy. NOTE: a plaintext String (not SecureString) is
|
||||||
|
# used deliberately — value_for_string_parameter resolves String
|
||||||
|
# params into Lambda env vars at deploy via {{resolve:ssm:...}};
|
||||||
|
# SecureString dynamic references are NOT resolved in Lambda env
|
||||||
|
# vars and would pass the token literally. This is a defense-in-depth
|
||||||
|
# floor for a currently-detached URL (INFRA-74), not a primary auth.
|
||||||
|
"WEB_UI_AUTH_TOKEN": ssm.StringParameter.value_for_string_parameter(
|
||||||
|
self, "/procurement-ingest/web-ui-auth-token"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Serves a simple HTML dashboard for viewing purchase orders.
|
||||||
Accessed via Lambda Function URL.
|
Accessed via Lambda Function URL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -16,6 +17,40 @@ dynamodb = boto3.resource("dynamodb")
|
||||||
|
|
||||||
PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders")
|
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):
|
def get_purchase_orders(limit=500):
|
||||||
table = dynamodb.Table(PO_TABLE)
|
table = dynamodb.Table(PO_TABLE)
|
||||||
|
|
@ -60,7 +95,10 @@ def fmt_currency(val):
|
||||||
val = float(val)
|
val = float(val)
|
||||||
if isinstance(val, (int, float)):
|
if isinstance(val, (int, float)):
|
||||||
return f"${val:,.2f}"
|
return f"${val:,.2f}"
|
||||||
return str(val)
|
# 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):
|
def render_po_detail(po):
|
||||||
|
|
@ -250,6 +288,13 @@ def render_po_list(purchase_orders):
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
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", "/")
|
path = event.get("rawPath", "/")
|
||||||
qs = event.get("queryStringParameters") or {}
|
qs = event.get("queryStringParameters") or {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Serves a simple HTML dashboard for viewing work orders and comments.
|
||||||
Accessed via Lambda Function URL.
|
Accessed via Lambda Function URL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from html import escape as esc
|
from html import escape as esc
|
||||||
|
|
@ -16,6 +17,39 @@ dynamodb = boto3.resource("dynamodb")
|
||||||
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
||||||
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
||||||
|
|
||||||
|
# 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 does not re-expose the whole WO 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_work_orders(limit=500):
|
def get_work_orders(limit=500):
|
||||||
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
||||||
|
|
@ -228,6 +262,13 @@ def render_work_orders_list(work_orders):
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
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", "/")
|
path = event.get("rawPath", "/")
|
||||||
qs = event.get("queryStringParameters") or {}
|
qs = event.get("queryStringParameters") or {}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue