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")
+23 -58
View File
@@ -46,6 +46,21 @@ from safety import { safety }
from gallery import { gallery_page }
from account import { account_page }
// Share-card HTML allowlist
//
// Tag-and-attribute allowlist passed to el_html_sanitize for /api/share and
// /share/<id>. Anything not on this list is dropped at the runtime level by
// the strict state-machine parser. The previous denylist sanitizer was
// retired (root-cause replacement, not a bandaid): it could be bypassed by
// a literal --> inside an attacker-supplied attribute value, and every new
// vector required a code change.
//
// Empty array means tag is allowed but no attributes survive. The sanitizer
// also validates `<a href>` schemes (only http/https/mailto/fragment/relative
// pass) and drops the entire subtree of script/style/iframe/object/embed/
// form regardless of allowlist contents.
let default_share_allowlist: String = "{\"p\":[],\"br\":[],\"strong\":[],\"em\":[],\"u\":[],\"s\":[],\"code\":[],\"pre\":[],\"ul\":[],\"ol\":[],\"li\":[],\"h1\":[],\"h2\":[],\"h3\":[],\"h4\":[],\"blockquote\":[],\"a\":[\"href\",\"title\"]}"
// Founding counter
let FOUNDING_TOTAL: Int = 1000
@@ -172,59 +187,6 @@ fn page(sold: Int, total: Int) -> String {
// Share card page
// sanitize_share_html strip dangerous HTML before storing/serving a share
// card. Defence in depth: marked.js client-side already escapes most things,
// but we never trust client-rendered HTML round-tripped through a public API.
// Rules:
// - Lowercase the working copy for tag matching (then operate on the
// original to preserve case-insensitive replacements via case-folded
// dance is overkill here; instead we run the replace on both cases).
// - Strip whole tags (open + close + body) for: script, iframe, style,
// object, embed, form. We replace each opener with a comment marker so
// the closer-stripper sees a tagless region.
// - Strip on*= event-handler attributes (onclick, onload, onerror, ...).
// - Strip javascript: URIs in href/src.
fn sanitize_share_html(html: String) -> String {
// Tag stripper: replace `<script` (case-insens, plus the variants seen in
// practice) with a comment opener so the rest of the tag becomes inert
// text. Belt-and-suspenders for each dangerous tag.
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")
// Event-handler attrs (on...=). marked.js does not emit these, but we
// strip the well-known ones in case a payload leaks through.
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=")
// javascript: URIs in href/src.
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
}
fn share_card_page(question: String, answer_plain: String, answer_html_in: String, id: String) -> String {
let q_html: String = str_replace(str_replace(str_replace(question, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
// answer_html_in is sanitized, marked.js-rendered HTML. Fall back to
@@ -232,7 +194,7 @@ fn share_card_page(question: String, answer_plain: String, answer_html_in: Strin
let a_html: String = if str_eq(answer_html_in, "") {
str_replace(str_replace(str_replace(answer_plain, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
} else {
sanitize_share_html(answer_html_in)
el_html_sanitize(answer_html_in, default_share_allowlist)
}
// Use plaintext for og:description so social previews are readable.
let answer: String = answer_plain
@@ -1365,9 +1327,12 @@ fn handle_request(method: String, path: String, body: String) -> String {
// - answer_plaintext, when supplied, takes precedence as the og:desc /
// gallery body. Falls back to `answer`.
//
// The handler sanitizes answer_html (sanitize_share_html: strip
// script/iframe/style/object/embed/form/link/meta/base + on*= attrs +
// javascript: URIs) before storing.
// The handler sanitizes answer_html via the runtime allowlist sanitizer
// (el_html_sanitize): only the tags and attributes named in
// default_share_allowlist survive; everything else is dropped. Whole
// subtrees of script/style/iframe/object/embed/form are removed
// regardless of the allowlist; <a href> schemes are restricted to
// http/https/mailto/fragment/relative.
if str_eq(path, "/api/share") {
if !str_eq(method, "POST") {
return "{\"error\":\"POST required\"}"
@@ -1403,7 +1368,7 @@ fn handle_request(method: String, path: String, body: String) -> String {
// sanitized server-side. Storing it lets /said render the
// same formatted preview as /share/<id>. Empty -> omit the
// column (legacy fallback path).
let html_sanitized: String = if str_eq(answer_html_raw, "") { "" } else { sanitize_share_html(answer_html_raw) }
let html_sanitized: String = if str_eq(answer_html_raw, "") { "" } else { el_html_sanitize(answer_html_raw, default_share_allowlist) }
let h_safe: String = str_replace(str_replace(str_replace(str_replace(html_sanitized, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
let html_field: String = if str_eq(html_sanitized, "") { "" } else { ",\"answer_html\":\"" + h_safe + "\"" }
let card_row: String = "{\"id\":\"" + id + "\",\"question\":\"" + q_safe + "\",\"answer\":\"" + a_safe + "\"" + html_field + "}"