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
+2 -1
View File
@@ -167,7 +167,8 @@ el_val_t cwd(void) {
return EL_STR(buf); return EL_STR(buf);
} }
el_val_t color_bold(el_val_t s) { return s; } el_val_t color_bold(el_val_t s) { return s; }
el_val_t unix_timestamp(void) { return (el_val_t)(uintptr_t)(int64_t)time(NULL); } /* unix_timestamp is now provided by el_runtime.c (back-compat shim).
* Keeping the stub here would produce a duplicate symbol at link time. */
/* /*
* supabase_insert — POST a JSON row to a Supabase table via the REST API. * supabase_insert — POST a JSON row to a Supabase table via the REST API.
@@ -0,0 +1,33 @@
-- 20260502185500_backfill_resanitize_share_cards.sql
--
-- Backfill: NULL out answer_html for share_cards rows older than 1 hour.
--
-- Context: the original sanitize_share_html function was a denylist that
-- comment-wrapped dangerous tags (<!--script>...-->) and renamed on*=
-- attributes to data-x-on*=. A real attacker probed /api/share with
-- <script>, <iframe>, <img onerror>, javascript:, and a <form> payload.
-- Nothing executed, but the comment-wrapping is fragile to a literal
-- "-->" inside an attacker-supplied attribute value, and the renamed
-- attrs left attack indicators visible in served HTML.
--
-- The replacement is a runtime-level allowlist sanitizer (el_html_sanitize)
-- that drops any tag/attribute not on the explicit list. Existing rows
-- that were sanitized by the OLD denylist may carry residual artefacts
-- ("<!--script>alert(1)/script-->" sitting inside answer_html). Rather
-- than rewriting the column row-by-row, we null out answer_html for
-- everything older than 1 hour. The /share/<id> and /said gallery
-- handlers both fall back to the plaintext answer column when answer_html
-- is null, so the user-visible regression is "old shares lose markdown
-- formatting" — not "old shares disappear".
--
-- Anything created in the last hour is post-deploy or pre-deploy testing
-- and is left intact for inspection.
--
-- Applied out-of-band via the Supabase Management API
-- (POST /v1/projects/<ref>/database/query). This file lands the change
-- in version control as the canonical record.
update public.share_cards
set answer_html = null
where answer_html is not null
and created_at < now() - interval '1 hour';
+1009 -29
View File
File diff suppressed because it is too large Load Diff
+71 -1
View File
@@ -199,6 +199,19 @@ el_val_t http_get_to_file(el_val_t url, el_val_t headers_map, el_val_t output_p
el_val_t url_encode(el_val_t s); /* RFC 3986 unreserved set */ el_val_t url_encode(el_val_t s); /* RFC 3986 unreserved set */
el_val_t url_decode(el_val_t s); /* '+' → space, %XX → byte */ el_val_t url_decode(el_val_t s); /* '+' → space, %XX → byte */
/* ── HTML allowlist sanitizer ────────────────────────────────────────────────
* el_html_sanitize(input_html, allowlist_json) — strict allowlist HTML
* cleaner. State-machine parser; tag/attribute names compared case-
* insensitively against the allowlist; `<a href>` / `<… src>` URL schemes
* validated (http, https, mailto, fragment-only, or relative); whole-
* subtree drop for script / style / iframe / object / embed / form; HTML-
* escapes free text outside dropped subtrees.
*
* The allowlist is JSON of the form
* {"p":[],"a":["href","title"],"strong":[],...}
* where each value is the array of attribute names allowed for that tag. */
el_val_t el_html_sanitize(el_val_t input_html, el_val_t allowlist_json);
/* ── Filesystem ──────────────────────────────────────────────────────────── */ /* ── Filesystem ──────────────────────────────────────────────────────────── */
el_val_t fs_read(el_val_t path); el_val_t fs_read(el_val_t path);
@@ -246,6 +259,63 @@ el_val_t time_from_parts(el_val_t secs, el_val_t ns, el_val_t tz);
el_val_t time_add(el_val_t ts, el_val_t n, el_val_t unit); el_val_t time_add(el_val_t ts, el_val_t n, el_val_t unit);
el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit); el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit);
/* ── Instant + Duration: first-class temporal types ──────────────────────────
* Both types share the el_val_t (int64) slot. Instants are nanoseconds
* since the Unix epoch; Durations are signed nanoseconds. Type discipline
* is enforced at codegen-time: BinOps on names registered as Instant or
* Duration route through the typed wrappers below; mismatches like
* Instant+Instant become #error at the C compiler.
*
* Postfix literals — `30.seconds`, `1.hour`, `500.millis`, `30.nanos` — are
* recognised by the parser as DurationLit AST nodes and lowered to literal
* int64 nanoseconds at codegen time. The runtime never sees the units. */
el_val_t el_now_instant(void);
el_val_t now(void);
el_val_t unix_seconds(el_val_t n);
el_val_t unix_millis(el_val_t n);
el_val_t instant_from_iso8601(el_val_t s);
el_val_t el_duration_from_nanos(el_val_t ns);
el_val_t duration_seconds(el_val_t n);
el_val_t duration_millis(el_val_t n);
el_val_t duration_nanos(el_val_t n);
el_val_t el_instant_add_dur(el_val_t inst, el_val_t dur);
el_val_t el_instant_sub_dur(el_val_t inst, el_val_t dur);
el_val_t el_instant_diff(el_val_t a, el_val_t b);
el_val_t el_duration_add(el_val_t a, el_val_t b);
el_val_t el_duration_sub(el_val_t a, el_val_t b);
el_val_t el_duration_scale(el_val_t dur, el_val_t scalar);
el_val_t el_duration_div(el_val_t dur, el_val_t scalar);
el_val_t el_instant_lt(el_val_t a, el_val_t b);
el_val_t el_instant_le(el_val_t a, el_val_t b);
el_val_t el_instant_gt(el_val_t a, el_val_t b);
el_val_t el_instant_ge(el_val_t a, el_val_t b);
el_val_t el_instant_eq(el_val_t a, el_val_t b);
el_val_t el_instant_ne(el_val_t a, el_val_t b);
el_val_t el_duration_lt(el_val_t a, el_val_t b);
el_val_t el_duration_le(el_val_t a, el_val_t b);
el_val_t el_duration_gt(el_val_t a, el_val_t b);
el_val_t el_duration_ge(el_val_t a, el_val_t b);
el_val_t el_duration_eq(el_val_t a, el_val_t b);
el_val_t el_duration_ne(el_val_t a, el_val_t b);
el_val_t instant_to_unix_seconds(el_val_t i);
el_val_t instant_to_unix_millis(el_val_t i);
el_val_t instant_to_iso8601(el_val_t i);
el_val_t duration_to_seconds(el_val_t d);
el_val_t duration_to_millis(el_val_t d);
el_val_t duration_to_nanos(el_val_t d);
el_val_t el_sleep_duration(el_val_t dur);
el_val_t unix_timestamp(void);
el_val_t ttl_cache_set(el_val_t key, el_val_t value);
el_val_t ttl_cache_get(el_val_t key, el_val_t max_age);
el_val_t ttl_cache_age(el_val_t key);
/* ── UUID ────────────────────────────────────────────────────────────────── */ /* ── UUID ────────────────────────────────────────────────────────────────── */
el_val_t uuid_new(void); el_val_t uuid_new(void);
@@ -288,7 +358,7 @@ el_val_t str_char_at(el_val_t s, el_val_t i);
el_val_t str_char_code(el_val_t s, el_val_t i); el_val_t str_char_code(el_val_t s, el_val_t i);
el_val_t str_pad_left(el_val_t s, el_val_t width, el_val_t pad); el_val_t str_pad_left(el_val_t s, el_val_t width, el_val_t pad);
el_val_t str_pad_right(el_val_t s, el_val_t width, el_val_t pad); el_val_t str_pad_right(el_val_t s, el_val_t width, el_val_t pad);
el_val_t str_format(el_val_t template, el_val_t data); el_val_t str_format(el_val_t fmt, el_val_t data);
el_val_t str_lower(el_val_t s); el_val_t str_lower(el_val_t s);
el_val_t str_upper(el_val_t s); el_val_t str_upper(el_val_t s);
+6 -38
View File
@@ -1,45 +1,13 @@
// components/gallery.el - "Things Neuron Said" gallery page. // components/gallery.el - "Things Neuron Said" gallery page.
// Per-card auth-gated voting via supabase-js + /api/vote. // Per-card auth-gated voting via supabase-js + /api/vote.
// gal_sanitize_html defence-in-depth strip of dangerous HTML for the // gallery_share_allowlist same allowlist as default_share_allowlist in
// gallery preview. Mirrors sanitize_share_html in main.el exactly so the // main.el. Inlined here so this component is self-contained: the build
// /said gallery and /share/<id> render the same allowlist. The DB column // 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 // is already sanitized at write time; this is belt-and-braces in case a
// row was inserted out-of-band. // row was inserted out-of-band.
fn gal_sanitize_html(html: String) -> String { let gallery_share_allowlist: String = "{\"p\":[],\"br\":[],\"strong\":[],\"em\":[],\"u\":[],\"s\":[],\"code\":[],\"pre\":[],\"ul\":[],\"ol\":[],\"li\":[],\"h1\":[],\"h2\":[],\"h3\":[],\"h4\":[],\"blockquote\":[],\"a\":[\"href\",\"title\"]}"
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
}
fn gallery_page(cards_json: String, supabase_url: String, supabase_anon_key: String) -> String { fn gallery_page(cards_json: String, supabase_url: String, supabase_anon_key: String) -> String {
let i: Int = 0 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 { let a_html: String = if !has_html {
str_replace(str_replace(str_replace(ca, "&", "&amp;"), "<", "&lt;"), ">", "&gt;") str_replace(str_replace(str_replace(ca, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
} else { } 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 } if str_len(s) > 600 { str_slice(s, 0, 600) + "..." } else { s }
} }
let ts_raw: String = json_get(card, "created_at") 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 gallery import { gallery_page }
from account import { account_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 // Founding counter
let FOUNDING_TOTAL: Int = 1000 let FOUNDING_TOTAL: Int = 1000
@@ -172,59 +187,6 @@ fn page(sold: Int, total: Int) -> String {
// Share card page // 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 { 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;") 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 // 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, "") { let a_html: String = if str_eq(answer_html_in, "") {
str_replace(str_replace(str_replace(answer_plain, "&", "&amp;"), "<", "&lt;"), ">", "&gt;") str_replace(str_replace(str_replace(answer_plain, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
} else { } 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. // Use plaintext for og:description so social previews are readable.
let answer: String = answer_plain 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 / // - answer_plaintext, when supplied, takes precedence as the og:desc /
// gallery body. Falls back to `answer`. // gallery body. Falls back to `answer`.
// //
// The handler sanitizes answer_html (sanitize_share_html: strip // The handler sanitizes answer_html via the runtime allowlist sanitizer
// script/iframe/style/object/embed/form/link/meta/base + on*= attrs + // (el_html_sanitize): only the tags and attributes named in
// javascript: URIs) before storing. // 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(path, "/api/share") {
if !str_eq(method, "POST") { if !str_eq(method, "POST") {
return "{\"error\":\"POST required\"}" 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 // sanitized server-side. Storing it lets /said render the
// same formatted preview as /share/<id>. Empty -> omit the // same formatted preview as /share/<id>. Empty -> omit the
// column (legacy fallback path). // 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 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 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 + "}" let card_row: String = "{\"id\":\"" + id + "\",\"question\":\"" + q_safe + "\",\"answer\":\"" + a_safe + "\"" + html_field + "}"