security: replace denylist sanitize_share_html with allowlist el_html_sanitize
Deploy marketing to Cloud Run / deploy (push) Failing after 5s

A real attacker probed /api/share earlier today with <script>alert(1),
<iframe src=evil>, <img onerror>, <a href="javascript:...">, and a
<form action="/steal"> payload. Nothing executed because the chat
bubble at /share/<id> renders the served HTML inside marked.js's
already-escaped output, but the prior denylist sanitizer was fragile:

  - It comment-wrapped dangerous tags ("<!--script>...-->") which a
    literal "-->" inside an attacker-supplied attribute value can close
    early, re-exposing the original payload.
  - It renamed on*= attributes to data-x-on*= which left attack
    indicators visible in the served HTML.
  - It was a denylist; every new attack vector required a code change.
  - It didn't validate <a href> URL schemes properly.

The replacement is a runtime-level state-machine allowlist parser
(foundation/el af480f6: el_html_sanitize). The product just specifies
the JSON allowlist of allowed tags + attributes; the runtime drops
everything else, validates href/src URL schemes (http/https/mailto/
fragment/relative only), and drops whole subtrees of script/style/
iframe/object/embed/form regardless of the allowlist.

Phase 4 of bl-dc55ae07: deletes sanitize_share_html (main.el) and
gal_sanitize_html (gallery.el); replaces 3 call sites with
el_html_sanitize(html, allowlist). Defines default_share_allowlist
in main.el and the identical gallery_share_allowlist in gallery.el
(separate bindings to avoid a forward-reference at build-concat
order — gallery is concatenated before main).

Phase 5: migrations/20260502185500_backfill_resanitize_share_cards.sql
nulls answer_html for any share_cards row older than 1 hour. Applied
via the Supabase Management API; 0 rows in scope (the column was
added today and existing rows pre-date its first write).

Also fixes an orthogonal duplicate-symbol bug: unix_timestamp() was
defined in both dist/web_stubs.c and the runtime (the latter is a
recent runtime addition picked up by the runtime sync). Removed the
stub.

Backlog: bl-dc55ae07
This commit is contained in:
Will Anderson
2026-05-02 12:56:33 -05:00
parent 4629796a75
commit 46f93fd6eb
6 changed files with 1144 additions and 127 deletions
+6 -38
View File
@@ -1,45 +1,13 @@
// components/gallery.el - "Things Neuron Said" gallery page.
// Per-card auth-gated voting via supabase-js + /api/vote.
// gal_sanitize_html defence-in-depth strip of dangerous HTML for the
// gallery preview. Mirrors sanitize_share_html in main.el exactly so the
// /said gallery and /share/<id> render the same allowlist. The DB column
// gallery_share_allowlist same allowlist as default_share_allowlist in
// main.el. Inlined here so this component is self-contained: the build
// concat order puts gallery.el before main.el, so a top-level reference to
// main.el's binding would forward-reference at the C level. The DB column
// is already sanitized at write time; this is belt-and-braces in case a
// row was inserted out-of-band.
fn gal_sanitize_html(html: String) -> String {
let s1: String = str_replace(str_replace(html, "<script", "<!--script"), "<SCRIPT", "<!--script")
let s2: String = str_replace(str_replace(s1, "</script>", "/script-->"), "</SCRIPT>", "/script-->")
let s3: String = str_replace(str_replace(s2, "<iframe", "<!--iframe"), "<IFRAME", "<!--iframe")
let s4: String = str_replace(str_replace(s3, "</iframe>", "/iframe-->"), "</IFRAME>", "/iframe-->")
let s5: String = str_replace(str_replace(s4, "<style", "<!--style"), "<STYLE", "<!--style")
let s6: String = str_replace(str_replace(s5, "</style>", "/style-->"), "</STYLE>", "/style-->")
let s7: String = str_replace(str_replace(s6, "<object", "<!--object"), "<OBJECT", "<!--object")
let s8: String = str_replace(str_replace(s7, "</object>", "/object-->"), "</OBJECT>", "/object-->")
let s9: String = str_replace(str_replace(s8, "<embed", "<!--embed"), "<EMBED", "<!--embed")
let s10: String = str_replace(s9, "<form", "<!--form")
let s11: String = str_replace(s10, "</form>", "/form-->")
let s12: String = str_replace(str_replace(s11, "<link", "<!--link"), "<LINK", "<!--link")
let s13: String = str_replace(str_replace(s12, "<meta", "<!--meta"), "<META", "<!--meta")
let s14: String = str_replace(str_replace(s13, "<base", "<!--base"), "<BASE", "<!--base")
let e1: String = str_replace(str_replace(s14, " onclick=", " data-x-onclick="), " ONCLICK=", " data-x-onclick=")
let e2: String = str_replace(str_replace(e1, " onload=", " data-x-onload="), " ONLOAD=", " data-x-onload=")
let e3: String = str_replace(str_replace(e2, " onerror=", " data-x-onerror="), " ONERROR=", " data-x-onerror=")
let e4: String = str_replace(str_replace(e3, " onmouseover=", " data-x-onmouseover="), " ONMOUSEOVER=", " data-x-onmouseover=")
let e5: String = str_replace(str_replace(e4, " onfocus=", " data-x-onfocus="), " ONFOCUS=", " data-x-onfocus=")
let e6: String = str_replace(str_replace(e5, " onblur=", " data-x-onblur="), " ONBLUR=", " data-x-onblur=")
let e7: String = str_replace(str_replace(e6, " onsubmit=", " data-x-onsubmit="), " ONSUBMIT=", " data-x-onsubmit=")
let e8: String = str_replace(str_replace(e7, " onchange=", " data-x-onchange="), " ONCHANGE=", " data-x-onchange=")
let e9: String = str_replace(str_replace(e8, " onkeydown=", " data-x-onkeydown="), " ONKEYDOWN=", " data-x-onkeydown=")
let e10: String = str_replace(str_replace(e9, " onkeyup=", " data-x-onkeyup="), " ONKEYUP=", " data-x-onkeyup=")
let e11: String = str_replace(str_replace(e10, " onkeypress=", " data-x-onkeypress="), " ONKEYPRESS=", " data-x-onkeypress=")
let e12: String = str_replace(str_replace(e11, " onmouseenter=", " data-x-onmouseenter="), " ONMOUSEENTER=", " data-x-onmouseenter=")
let e13: String = str_replace(str_replace(e12, " onmouseleave=", " data-x-onmouseleave="), " ONMOUSELEAVE=", " data-x-onmouseleave=")
let e14: String = str_replace(str_replace(e13, " ontoggle=", " data-x-ontoggle="), " ONTOGGLE=", " data-x-ontoggle=")
let e15: String = str_replace(str_replace(e14, " onanimationend=", " data-x-onanimationend="), " ONANIMATIONEND=", " data-x-onanimationend=")
let j1: String = str_replace(str_replace(e15, "javascript:", "about:blank#"), "JAVASCRIPT:", "about:blank#")
let j2: String = str_replace(str_replace(j1, "data:text/html", "about:blank#"), "DATA:text/html", "about:blank#")
return j2
}
let gallery_share_allowlist: String = "{\"p\":[],\"br\":[],\"strong\":[],\"em\":[],\"u\":[],\"s\":[],\"code\":[],\"pre\":[],\"ul\":[],\"ol\":[],\"li\":[],\"h1\":[],\"h2\":[],\"h3\":[],\"h4\":[],\"blockquote\":[],\"a\":[\"href\",\"title\"]}"
fn gallery_page(cards_json: String, supabase_url: String, supabase_anon_key: String) -> String {
let i: Int = 0
@@ -66,7 +34,7 @@ fn gallery_page(cards_json: String, supabase_url: String, supabase_anon_key: Str
let a_html: String = if !has_html {
str_replace(str_replace(str_replace(ca, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
} else {
let s: String = gal_sanitize_html(ca_html_raw)
let s: String = el_html_sanitize(ca_html_raw, gallery_share_allowlist)
if str_len(s) > 600 { str_slice(s, 0, 600) + "..." } else { s }
}
let ts_raw: String = json_get(card, "created_at")