Fix XSS risks and add closed-form UX to order page

Add escapeHtml() for all scraped content in innerHTML, fix script
injection via </script> in JSON, fix JWT base64url decoding, match
backend two-step rounding in JS employeePrice(), disable qty buttons
and submit when form is closed, add server-driven countdown from
reopen_at, add duplicate order warning via localStorage, add back
button after submission, embed favicon, use :g format for fractional
discounts, and exclude dead loadRoster code when Google auth enabled.
This commit is contained in:
Adam Moussa 2026-05-13 12:59:06 -04:00
parent bcce15b9e6
commit 763da24134

View file

@ -15,8 +15,6 @@ import sys
from datetime import datetime
from pathlib import Path
import boto3
PROJECT_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_DIR = PROJECT_ROOT / "output"
CONFIG_PATH = PROJECT_ROOT / "config.json"
@ -45,15 +43,15 @@ def generate_form(
company_subsidy: float = 0,
google_client_id: str = "",
) -> str:
meals_json = json.dumps(menu["meals"])
roster_json = json.dumps(config.get("roster", []))
meals_json = json.dumps(menu["meals"]).replace("</", "<\\/")
roster_json = json.dumps(config.get("roster", [])).replace("</", "<\\/")
deadline = config.get("order_deadline", "Thursday 11:59 PM")
week = datetime.now().strftime("%Y-W%U")
scraped_at = menu.get("scraped_at", "unknown")
submit_url = f"{api_url}/api/submit-order" if api_url else "/api/submit-order"
status_url = f"{api_url}/api/form-status/{week}" if api_url else f"/api/form-status/{week}"
roster_url = f"{api_url}/api/roster" if api_url else "/api/roster"
api_key_json = json.dumps(api_key)
api_key_json = json.dumps(api_key).replace("</", "<\\/")
has_discount = bulk_discount > 0 or company_subsidy > 0
use_google_auth = bool(google_client_id)
google_client_id_json = json.dumps(google_client_id)
@ -103,7 +101,8 @@ function initGoogleAuth() {
function handleCredentialResponse(response) {
googleCredential = response.credential;
const payload = JSON.parse(atob(response.credential.split('.')[1]));
const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(b64));
googleUser = { name: payload.name, email: payload.email };
document.getElementById('auth-overlay').style.display = 'none';
@ -119,6 +118,7 @@ function handleCredentialResponse(response) {
}
updateTotal();
checkDuplicateOrder();
}
function signOut() {
@ -137,6 +137,7 @@ function signOut() {
if use_google_auth:
submit_order_js = """
async function submitOrder() {
if (formClosed) { alert('Orders are closed.'); return; }
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
const items = Object.entries(quantities).map(([i, qty]) => ({
@ -161,8 +162,9 @@ async function submitOrder() {
if (res.ok) {
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
document.getElementById('success').style.display = 'block';
document.getElementById('success-detail').textContent = `${googleUser.name} — $${data.total.toFixed(2)} total. You're all set!`;
document.getElementById('success-detail').textContent = `${googleUser.name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
document.querySelector('.sticky-footer').style.display = 'none';
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
} else {
alert(data.error || 'Something went wrong.');
btn.disabled = false;
@ -178,6 +180,7 @@ async function submitOrder() {
else:
submit_order_js = """
async function submitOrder() {
if (formClosed) { alert('Orders are closed.'); return; }
const name = document.getElementById('emp-name').value.trim();
const email = document.getElementById('emp-email').value.trim();
if (!name) { alert('Please enter your name.'); return; }
@ -205,8 +208,9 @@ async function submitOrder() {
if (res.ok) {
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
document.getElementById('success').style.display = 'block';
document.getElementById('success-detail').textContent = `${name} — $${data.total.toFixed(2)} total. You're all set!`;
document.getElementById('success-detail').textContent = `${name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
document.querySelector('.sticky-footer').style.display = 'none';
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
} else {
alert(data.error || 'Something went wrong.');
btn.disabled = false;
@ -226,6 +230,7 @@ async function submitOrder() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sea Haven — Meal Order ({week})</title>
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABX0lEQVRYhe3TPWsUURjF8d+dnSxhoxA1xEJnQQsJGbBTrIIIvoEDNqkNiJ9BrPwI4jeY2kmVoKWkSVALK6MgwWLsJIWIBBFZLGaLTTLrJrX3X96X5znnPPcSiUQikf+dcHAhr+qLuIrT2MV77Owv9ycWy6u6i3tnOu5maTg/FUzjOz5gHe/KImsXkFf1ZTzH9ZbaNdbwElvby/0fwzsJFnANN3uJO/00zJ5MxmrcwqOyyD7uE5BX9Q08xh5O4BL+ZXkXvzQp9RKcS4OzaUukh9nDg7LIVkcFGI04r2pDZw+xgrlx1U516KdB93DnGhvYxBd8w08M0MXnssgmCx7O9RZuYzEw30sszATpXCeY2R/3AC/wTMu420gnHbgyHeY1sb3FH9wfc28DT8oiezOx63EE4DeeYqllb4BXGsevj+L4IEd4M6ysf6X5HUu4oEniE9bKIts5dtdIJBKJREb4C8ngTD+C9DtvAAAAAElFTkSuQmCC">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }}
@ -262,6 +267,9 @@ header p {{ font-size: 0.85rem; opacity: 0.8; }}
.submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
.success-msg {{ text-align: center; padding: 60px 20px; }}
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }}
.back-btn {{ background: transparent; color: #1a1a2e; border: 2px solid #1a1a2e; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 20px; }}
.back-btn:hover {{ background: #1a1a2e; color: #fff; }}
.duplicate-warning {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; border: 1px solid #fcd34d; }}
.meal-desc {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
.search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
body {{ padding-bottom: 80px; }}
@ -311,7 +319,8 @@ body {{ padding-bottom: 80px; }}
</header>
<div class="deadline">Order deadline: {deadline}</div>
{'<div class="discount-banner">Prices reflect employee cost after ' + (f"{int(bulk_discount)}% bulk discount" if bulk_discount > 0 else "") + (" + " if bulk_discount > 0 and company_subsidy > 0 else "") + (f"{int(company_subsidy)}% company subsidy" if company_subsidy > 0 else "") + "</div>" if has_discount else ""}
<div class="duplicate-warning" id="duplicate-warning" style="display:none;">You've already submitted an order this week. Submitting again will replace your previous order.</div>
{'<div class="discount-banner">Prices reflect employee cost after ' + (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "") + (" + " if bulk_discount > 0 and company_subsidy > 0 else "") + (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "") + "</div>" if has_discount else ""}
{auth_section_html}
@ -334,6 +343,7 @@ body {{ padding-bottom: 80px; }}
<div id="success" class="success-msg" style="display:none;">
<h2>Order submitted!</h2>
<p id="success-detail"></p>
<button class="back-btn" onclick="location.reload()">Back to Menu</button>
</div>
</div>
@ -351,15 +361,34 @@ const quantities = {{}};
let formClosed = false;
{'const GOOGLE_CLIENT_ID = ' + google_client_id_json + ';\nlet googleCredential = null;\nlet googleUser = null;' if use_google_auth else ''}
function escapeHtml(str) {{
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}}
function employeePrice(retail) {{
if (!retail) return 0;
const bulk = retail * (1 - BULK_DISCOUNT / 100);
return Math.round(bulk * (1 - COMPANY_SUBSIDY / 100) * 100) / 100;
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100) / 100;
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100) / 100;
return empPrice;
}}
{google_auth_js}
function checkDuplicateOrder() {{
try {{
if (localStorage.getItem('lastOrderWeek') === WEEK) {{
document.getElementById('duplicate-warning').style.display = 'block';
}}
}} catch (e) {{}}
}}
function init() {{
checkFormStatus();
{'waitForGoogleAuth();' if use_google_auth else 'loadRoster();'}
{'waitForGoogleAuth();' if use_google_auth else 'loadRoster(); checkDuplicateOrder();'}
// Build filter buttons
const tags = new Set();
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
@ -399,27 +428,29 @@ function renderMeals() {{
let tagsHtml = '';
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>';
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{t}}</span>`; }});
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{escapeHtml(t)}}</span>`; }});
const descHtml = meal.description ? `<div class="meal-desc">${{meal.description}}</div>` : '';
const descHtml = meal.description ? `<div class="meal-desc">${{escapeHtml(meal.description)}}</div>` : '';
const hasDiscount = (BULK_DISCOUNT > 0 || COMPANY_SUBSIDY > 0) && meal.price;
const empPrice = employeePrice(meal.price);
const priceHtml = hasDiscount
? `<span class="price-retail">$${{meal.price.toFixed(2)}}</span><span class="price-employee">$${{empPrice.toFixed(2)}}</span>`
: (meal.price ? `$${{meal.price.toFixed(2)}}` : '—');
const safeName = escapeHtml(meal.name);
const safeImageUrl = escapeHtml(meal.image_url);
card.innerHTML = `
${{meal.image_url ? `<img class="meal-img" src="${{meal.image_url}}" alt="${{meal.name}}" loading="lazy">` : ''}}
${{meal.image_url ? `<img class="meal-img" src="${{safeImageUrl}}" alt="${{safeName}}" loading="lazy">` : ''}}
<div class="meal-info">
<div class="meal-name">${{meal.name}}</div>
<div class="meal-name">${{safeName}}</div>
${{descHtml}}
<div class="meal-meta">${{priceHtml}} &middot; ${{meal.calories || '?'}} cal &middot; ${{meal.protein || '?'}} protein</div>
<div class="meal-meta">${{priceHtml}} &middot; ${{escapeHtml(meal.calories || '?')}} cal &middot; ${{escapeHtml(meal.protein || '?')}} protein</div>
<div class="meal-tags">${{tagsHtml}}</div>
</div>
<div class="qty-control">
<button onclick="changeQty(${{i}}, -1)">&minus;</button>
<button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>&minus;</button>
<input class="qty" type="text" value="${{qty}}" readonly>
<button onclick="changeQty(${{i}}, 1)">+</button>
<button onclick="changeQty(${{i}}, 1)" ${{formClosed ? 'disabled' : ''}}>+</button>
</div>
`;
list.appendChild(card);
@ -448,7 +479,7 @@ function updateTotal() {{
{submit_order_js}
async function loadRoster() {{
{"" if use_google_auth else """async function loadRoster() {{
try {{
const res = await fetch(ROSTER_URL);
const data = await res.json();
@ -477,7 +508,7 @@ async function loadRoster() {{
}});
}}
}}
"""}
async function checkFormStatus() {{
if (!STATUS_URL) return;
try {{
@ -486,31 +517,45 @@ async function checkFormStatus() {{
if (data.status === 'closed') {{
formClosed = true;
document.getElementById('closed-overlay').style.display = 'flex';
startCountdown();
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = true);
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.disabled = true;
startCountdown(data);
}}
}} catch (e) {{}}
}}
function startCountdown() {{
let reopenAtMs = null;
function startCountdown(data) {{
if (data && data.reopen_at) {{
reopenAtMs = data.reopen_at * 1000;
}}
updateCountdown();
setInterval(updateCountdown, 1000);
}}
function updateCountdown() {{
const now = new Date();
const etStr = now.toLocaleString('en-US', {{ timeZone: 'America/New_York', hour12: false }});
const etDate = new Date(etStr);
const day = etDate.getDay();
const hour = etDate.getHours();
let target;
let daysUntil = (1 - day + 7) % 7;
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
if (reopenAtMs) {{
target = new Date(reopenAtMs);
}} else {{
const etStr = now.toLocaleString('en-US', {{ timeZone: 'America/New_York', hour12: false }});
const etDate = new Date(etStr);
const day = etDate.getDay();
const hour = etDate.getHours();
const target = new Date(etDate);
target.setDate(target.getDate() + daysUntil);
target.setHours(8, 0, 0, 0);
let daysUntil = (1 - day + 7) % 7;
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
let diff = Math.max(0, Math.floor((target - etDate) / 1000));
target = new Date(etDate);
target.setDate(target.getDate() + daysUntil);
target.setHours(8, 0, 0, 0);
}}
let diff = Math.max(0, Math.floor((target - now) / 1000));
const d = Math.floor(diff / 86400); diff %= 86400;
const h = Math.floor(diff / 3600); diff %= 3600;
const m = Math.floor(diff / 60);
@ -570,6 +615,7 @@ def main():
google_client_id = args.google_client_id or config.get("google_client_id", "")
if not google_client_id:
try:
import boto3
ssm = boto3.client("ssm")
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
google_client_id = resp["Parameter"]["Value"]