security: replace denylist sanitize_share_html with allowlist el_html_sanitize
Deploy marketing to Cloud Run / deploy (push) Failing after 5s
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:
+6
-38
@@ -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, "&", "&"), "<", "<"), ">", ">")
|
||||
} 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
@@ -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, "&", "&"), "<", "<"), ">", ">")
|
||||
// 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, "&", "&"), "<", "<"), ">", ">")
|
||||
} 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 + "}"
|
||||
|
||||
Reference in New Issue
Block a user