add el_html_sanitize allowlist runtime primitive

Replaces the need for product-level denylist sanitizers. Small
state-machine parser; tag-and-attribute allowlist passed as JSON;
URL scheme validation on href/src attrs (http, https, mailto,
fragment, relative); whole-subtree drop for script/style/iframe/
object/embed/form (plus rarer media containers). No comment-
wrapping (was fragile to comment-injection bypass via a literal
--> inside an attacker-supplied attribute value).

Also picks up the codegen and parser changes for first-class
Instant/Duration types (postfix-literal time values, typed binop
dispatch) that were sitting in tree alongside this work.

Test corpus at tests/html_sanitizer/ covers the live attacker
probes (script, iframe, form, javascript:, about:, data:, img
onerror, onclick) plus structural attacks (comment-injection
bypass, tab-in-scheme bypass, encoded payloads, malformed input,
empty input, plain text). 29 cases, all green.

Self-host fixed point holds at 5720 lines via the canonical
el-compiler/src/compiler.el entry. Snapshot tagged at
dist/platform/elc.20260502-1249-self-host.

Backlog: bl-dc55ae07
This commit is contained in:
Will Anderson
2026-05-02 12:49:41 -05:00
parent 2e9d3247a6
commit af480f6266
7 changed files with 724 additions and 0 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+438
View File
@@ -99,6 +99,29 @@ fn binop_to_c(op: String) -> String {
//
// cg_expr returns a C expression string (not a statement).
// duration_unit_nanos multiplier from a postfix-literal unit name to
// nanoseconds. Singular and plural forms collapse to the same multiplier;
// the parser already restricted `unit` to the set is_duration_unit accepts.
// Returns the multiplier as a decimal string suitable for splicing into
// the generated C as a literal int64 expression.
fn duration_unit_nanos(unit: String) -> String {
if str_eq(unit, "nano") { return "1LL" }
if str_eq(unit, "nanos") { return "1LL" }
if str_eq(unit, "milli") { return "1000000LL" }
if str_eq(unit, "millis") { return "1000000LL" }
if str_eq(unit, "millisecond") { return "1000000LL" }
if str_eq(unit, "milliseconds") { return "1000000LL" }
if str_eq(unit, "second") { return "1000000000LL" }
if str_eq(unit, "seconds") { return "1000000000LL" }
if str_eq(unit, "minute") { return "60000000000LL" }
if str_eq(unit, "minutes") { return "60000000000LL" }
if str_eq(unit, "hour") { return "3600000000000LL" }
if str_eq(unit, "hours") { return "3600000000000LL" }
if str_eq(unit, "day") { return "86400000000000LL" }
if str_eq(unit, "days") { return "86400000000000LL" }
"1LL"
}
fn cg_expr(expr: Map<String, Any>) -> String {
let kind: String = expr["expr"]
@@ -107,6 +130,17 @@ fn cg_expr(expr: Map<String, Any>) -> String {
return v
}
// DurationLit postfix-literal time value (e.g. 30.seconds, 1.hour).
// Lowered to a literal int64 nanosecond count, wrapped in the runtime
// entry point so the intent is explicit at the C level. The arithmetic
// is fully constant-folded by any optimising C compiler.
if kind == "DurationLit" {
let count: String = expr["count"]
let unit: String = expr["unit"]
let mult: String = duration_unit_nanos(unit)
return "el_duration_from_nanos((el_val_t)(" + count + "LL * " + mult + "))"
}
if kind == "Float" {
// Wrap Float literals in el_from_float() so the bit pattern is
// preserved through the el_val_t (int64) slot. Without this,
@@ -157,6 +191,153 @@ fn cg_expr(expr: Map<String, Any>) -> String {
let left_kind: String = left["expr"]
let right_kind: String = right["expr"]
// Temporal-type dispatch (Instant + Duration first-class)
// Run BEFORE the int / string / generic paths so typed temporal
// operands route through the runtime wrappers and invalid combos
// become #error directives rather than silently falling through to
// raw int arithmetic. The wrappers are no-op casts at the C level
// but make the intent explicit and centralise future changes (e.g.
// saturating arithmetic, overflow guards).
let left_is_inst: Bool = is_instant_expr(left)
let right_is_inst: Bool = is_instant_expr(right)
let left_is_dur: Bool = is_duration_expr(left)
let right_is_dur: Bool = is_duration_expr(right)
let any_temporal: Bool = false
if left_is_inst { let any_temporal = true }
if right_is_inst { let any_temporal = true }
if left_is_dur { let any_temporal = true }
if right_is_dur { let any_temporal = true }
if any_temporal {
if op == "Plus" {
if left_is_inst {
if right_is_dur {
return "el_instant_add_dur(" + left_c + ", " + right_c + ")"
}
if right_is_inst {
time_record_violation("instant_plus_instant", "Instant + Instant is not allowed")
return "0 /* TIME_TYPE_ERROR: Instant + Instant */"
}
}
if left_is_dur {
if right_is_inst {
return "el_instant_add_dur(" + right_c + ", " + left_c + ")"
}
if right_is_dur {
return "el_duration_add(" + left_c + ", " + right_c + ")"
}
if is_int_expr(right) {
time_record_violation("duration_plus_int", "Duration + Int is not allowed (use duration_seconds(n) or N.seconds)")
return "0 /* TIME_TYPE_ERROR: Duration + Int */"
}
}
if right_is_dur {
if is_int_expr(left) {
time_record_violation("duration_plus_int", "Int + Duration is not allowed")
return "0 /* TIME_TYPE_ERROR: Int + Duration */"
}
}
}
if op == "Minus" {
if left_is_inst {
if right_is_dur {
return "el_instant_sub_dur(" + left_c + ", " + right_c + ")"
}
if right_is_inst {
return "el_instant_diff(" + left_c + ", " + right_c + ")"
}
}
if left_is_dur {
if right_is_dur {
return "el_duration_sub(" + left_c + ", " + right_c + ")"
}
if is_int_expr(right) {
time_record_violation("duration_minus_int", "Duration - Int is not allowed")
return "0 /* TIME_TYPE_ERROR: Duration - Int */"
}
}
}
if op == "Star" {
if left_is_dur {
if is_int_expr(right) {
return "el_duration_scale(" + left_c + ", " + right_c + ")"
}
}
if right_is_dur {
if is_int_expr(left) {
return "el_duration_scale(" + right_c + ", " + left_c + ")"
}
}
}
if op == "Slash" {
if left_is_dur {
if is_int_expr(right) {
return "el_duration_div(" + left_c + ", " + right_c + ")"
}
}
}
// Comparisons. Cross-type comparisons are forbidden.
if op == "Lt" {
if left_is_inst {
if right_is_inst { return "el_instant_lt(" + left_c + ", " + right_c + ")" }
if right_is_dur {
time_record_violation("instant_cmp_duration", "Instant < Duration is not allowed")
return "0 /* TIME_TYPE_ERROR: Instant < Duration */"
}
}
if left_is_dur {
if right_is_dur { return "el_duration_lt(" + left_c + ", " + right_c + ")" }
if right_is_inst {
time_record_violation("duration_cmp_instant", "Duration < Instant is not allowed")
return "0 /* TIME_TYPE_ERROR: Duration < Instant */"
}
}
}
if op == "LtEq" {
if left_is_inst {
if right_is_inst { return "el_instant_le(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_le(" + left_c + ", " + right_c + ")" }
}
}
if op == "Gt" {
if left_is_inst {
if right_is_inst { return "el_instant_gt(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_gt(" + left_c + ", " + right_c + ")" }
}
}
if op == "GtEq" {
if left_is_inst {
if right_is_inst { return "el_instant_ge(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_ge(" + left_c + ", " + right_c + ")" }
}
}
if op == "EqEq" {
if left_is_inst {
if right_is_inst { return "el_instant_eq(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_eq(" + left_c + ", " + right_c + ")" }
}
}
if op == "NotEq" {
if left_is_inst {
if right_is_inst { return "el_instant_ne(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_ne(" + left_c + ", " + right_c + ")" }
}
}
// Fall through let the existing path handle anything we
// didn't explicitly cover (typically string-concat with a
// typed temporal value, e.g. for debug prints, which works
// because both share the int64 slot).
}
if op == "Plus" {
// If either side is a string literal, always concat
if left_kind == "Str" {
@@ -365,6 +546,21 @@ fn cg_expr(expr: Map<String, Any>) -> String {
// handler)`). User-defined fns and variadic builtins pass
// through (builtin_arity returns -1).
arity_check_call(fn_name, arity)
// sleep(Duration) Phase 1 of the typed-time work. When the
// single arg is provably a Duration we lower to el_sleep_duration
// so the runtime sees nanos directly. Existing sleep() callers
// that pass an Int still emit `sleep(<int>)`, which falls through
// to the no-such-symbol path those call sites must migrate to
// a typed Duration. Acceptable: the spec marks them out for an
// audit pass during Phase 1.
if str_eq(fn_name, "sleep") {
if arity == 1 {
let only_arg = native_list_get(args, 0)
if is_duration_expr(only_arg) {
return "el_sleep_duration(" + args_c + ")"
}
}
}
return fn_name + "(" + args_c + ")"
}
@@ -672,6 +868,23 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
if str_eq(ltype, "Int") {
add_int_name(name)
}
// Temporal type annotations register the name with the matching
// typed-set so BinOp / comparison codegen routes through the
// typed wrappers and forbids cross-type ops.
if str_eq(ltype, "Instant") {
add_instant_name(name)
}
if str_eq(ltype, "Duration") {
add_duration_name(name)
}
// Inference from RHS duration literals and known-typed calls
// propagate even when the let is unannotated.
if is_instant_expr(val) {
add_instant_name(name)
}
if is_duration_expr(val) {
add_duration_name(name)
}
let vk: String = val["expr"]
if str_eq(vk, "Int") {
add_int_name(name)
@@ -928,6 +1141,23 @@ fn is_int_name(name: String) -> Bool {
return str_contains(csv, "," + name + ",")
}
// Same shape as is_int_name, for Instant- and Duration-typed bindings.
// Used by the BinOp/comparison codegen to dispatch arithmetic through the
// typed runtime wrappers (el_instant_add_dur, el_duration_lt, ) and to
// surface mismatches (Instant + Instant, Duration + Int) as #error
// directives at the top of the generated C.
fn is_instant_name(name: String) -> Bool {
let csv: String = state_get("__instant_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_duration_name(name: String) -> Bool {
let csv: String = state_get("__duration_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
// Known runtime builtins that return Int. Used to dispatch arithmetic vs
// string-concat on `+` when one side is a Call. New builtins must be added
// here when they return Int and may participate in arithmetic.
@@ -956,9 +1186,161 @@ fn is_int_call(call_expr: Map<String, Any>) -> Bool {
if str_eq(name, "el_max") { return true }
if str_eq(name, "el_min") { return true }
if str_eq(name, "float_to_int") { return true }
if str_eq(name, "unix_timestamp") { return true }
if str_eq(name, "instant_to_unix_seconds") { return true }
if str_eq(name, "instant_to_unix_millis") { return true }
if str_eq(name, "duration_to_seconds") { return true }
if str_eq(name, "duration_to_millis") { return true }
if str_eq(name, "duration_to_nanos") { return true }
return false
}
// Builtins that return an Instant. Used by is_instant_expr and the BinOp
// dispatch `now() + 5.seconds` types as Instant only because we can see
// that now() is an Instant-returning Call.
fn is_instant_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "now") { return true }
if str_eq(name, "el_now_instant") { return true }
if str_eq(name, "unix_seconds") { return true }
if str_eq(name, "unix_millis") { return true }
if str_eq(name, "instant_from_iso8601") { return true }
if str_eq(name, "el_instant_add_dur") { return true }
if str_eq(name, "el_instant_sub_dur") { return true }
return false
}
// Builtins that return a Duration. Same role as is_instant_call.
fn is_duration_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "el_duration_from_nanos") { return true }
if str_eq(name, "duration_seconds") { return true }
if str_eq(name, "duration_millis") { return true }
if str_eq(name, "duration_nanos") { return true }
if str_eq(name, "el_instant_diff") { return true }
if str_eq(name, "el_duration_add") { return true }
if str_eq(name, "el_duration_sub") { return true }
if str_eq(name, "el_duration_scale") { return true }
if str_eq(name, "el_duration_div") { return true }
if str_eq(name, "ttl_cache_age") { return true }
return false
}
// Recursive type predicates for Instant / Duration. Mirror is_int_expr.
// is_instant_expr / is_duration_expr return true only when the expression
// is provably of that type at codegen time. Anything ambiguous returns
// false the BinOp dispatcher then leaves the expression on the
// untyped-int path, which is the safest fallback because at the runtime
// level all three types share the int64 slot.
fn is_instant_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") {
let name: String = expr["name"]
return is_instant_name(name)
}
if str_eq(k, "Call") {
return is_instant_call(expr)
}
if str_eq(k, "BinOp") {
let op: String = expr["op"]
if str_eq(op, "Plus") {
// Instant + Duration Instant
// Duration + Instant Instant
if is_instant_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
if is_duration_expr(expr["left"]) {
if is_instant_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Minus") {
// Instant - Duration Instant
if is_instant_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
return false
}
return false
}
return false
}
fn is_duration_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "DurationLit") { return true }
if str_eq(k, "Ident") {
let name: String = expr["name"]
return is_duration_name(name)
}
if str_eq(k, "Call") {
return is_duration_call(expr)
}
if str_eq(k, "Neg") {
return is_duration_expr(expr["inner"])
}
if str_eq(k, "BinOp") {
let op: String = expr["op"]
if str_eq(op, "Plus") {
// Duration + Duration Duration
if is_duration_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Minus") {
// Duration - Duration Duration
// Instant - Instant Duration (caught here, not in is_instant_expr)
if is_duration_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
if is_instant_expr(expr["left"]) {
if is_instant_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Star") {
// Duration * Int Duration
// Int * Duration Duration
if is_duration_expr(expr["left"]) {
if is_int_expr(expr["right"]) { return true }
}
if is_int_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Slash") {
// Duration / Int Duration
if is_duration_expr(expr["left"]) {
if is_int_expr(expr["right"]) { return true }
}
return false
}
return false
}
return false
}
// Record a temporal-type violation. Surfaced as `#error` directives at the
// top of the generated C, identical machinery to cap_record_violation.
// kinds: "instant_plus_instant", "duration_plus_int", etc.
fn time_record_violation(kind: String, detail: String) -> Bool {
let csv: String = state_get("__time_violations")
if str_eq(csv, "") { let csv = "," }
let entry: String = kind + ":" + detail
let key: String = "," + entry + ","
if str_contains(csv, key) { return true }
state_set("__time_violations", csv + entry + ",")
return true
}
// Recursive type-propagation: is `expr` known-Int at codegen time?
// This unifies the BinOp(+) dispatch so chained arithmetic over Int
// operands stays arithmetic. Without recursion, a wrapping `+` between
@@ -1138,6 +1520,30 @@ fn emit_cap_violations() -> Void {
}
}
// Surface temporal-type violations as #error directives. The cg_expr BinOp
// dispatcher records each violation (Instant + Instant, Duration + Int, )
// as a CSV entry "kind:detail" via time_record_violation. Each entry maps
// to a single #error so downstream cc fails the build with a clear El-
// source-level message before the bogus C even links.
fn emit_time_violations() -> Void {
let csv: String = state_get("__time_violations")
if str_eq(csv, "") { return }
if str_eq(csv, ",") { return }
let n: Int = str_len(csv)
let i: Int = 1
while i < n {
let next_comma: Int = str_index_of(str_slice(csv, i, n), ",")
if next_comma < 0 { return }
let entry: String = str_slice(csv, i, i + next_comma)
let colon: Int = str_index_of(entry, ":")
if colon > 0 {
let detail: String = str_slice(entry, colon + 1, str_len(entry))
emit_line("#error \"temporal type error: " + detail + "\"")
}
let i = i + next_comma + 1
}
}
// Builtin arity table
//
// El programs sometimes call runtime builtins with the wrong number of
@@ -1180,6 +1586,8 @@ fn builtin_arity(name: String) -> Int {
if str_eq(name, "str_format") { return 2 }
if str_eq(name, "str_lower") { return 1 }
if str_eq(name, "str_upper") { return 1 }
// HTML sanitizer
if str_eq(name, "el_html_sanitize") { return 2 }
// Math
if str_eq(name, "el_abs") { return 1 }
if str_eq(name, "el_max") { return 2 }
@@ -1384,8 +1792,28 @@ fn add_int_name(name: String) -> Bool {
return true
}
fn add_instant_name(name: String) -> Bool {
let csv: String = state_get("__instant_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__instant_names", csv + name + ",")
return true
}
fn add_duration_name(name: String) -> Bool {
let csv: String = state_get("__duration_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__duration_names", csv + name + ",")
return true
}
fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
state_set("__int_names", ",")
state_set("__instant_names", ",")
state_set("__duration_names", ",")
let np: Int = native_list_len(params)
let pi = 0
while pi < np {
@@ -1395,6 +1823,12 @@ fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
if str_eq(ptype, "Int") {
add_int_name(pname)
}
if str_eq(ptype, "Instant") {
add_instant_name(pname)
}
if str_eq(ptype, "Duration") {
add_duration_name(pname)
}
let pi = pi + 1
}
return true
@@ -1661,6 +2095,8 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
state_set("__cap_violations", "")
// Clear arity-violation accumulator from any prior compile.
state_set("__arity_violations", "")
// Clear temporal-type-violation accumulator from any prior compile.
state_set("__time_violations", "")
// Preamble
emit_line("#include <stdint.h>")
@@ -1831,6 +2267,8 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
// so a misuse of a known builtin (wrong arg count) fails the build
// with a clear message naming the builtin and its expected arity.
emit_arity_violations()
// Temporal-type violations (Instant + Instant, Duration + Int, ).
emit_time_violations()
// Return empty string output was streamed via println
""
+45
View File
@@ -474,10 +474,55 @@ fn parse_block(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
// Postfix expressions (calls, field access, index)
// is_duration_unit recognise the postfix unit suffix on a numeric literal.
// Used by parse_postfix to detect `30.seconds`-shape time literals before
// falling back to the generic `obj.field` field-access lowering. Singular
// and plural forms map to the same nanosecond multiplier; codegen does the
// arithmetic at compile time.
fn is_duration_unit(name: String) -> Bool {
if name == "nanos" { return true }
if name == "nano" { return true }
if name == "millis" { return true }
if name == "milli" { return true }
if name == "millisecond" { return true }
if name == "milliseconds" { return true }
if name == "second" { return true }
if name == "seconds" { return true }
if name == "minute" { return true }
if name == "minutes" { return true }
if name == "hour" { return true }
if name == "hours" { return true }
if name == "day" { return true }
if name == "days" { return true }
false
}
fn parse_postfix(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
let r = parse_primary(tokens, pos)
let node = r["node"]
let p = r["pos"]
// Postfix duration literal: `<Int>.<unit>` where <unit> is one of
// nanos | millis | seconds | minutes | hours | days (each with an
// optional plural). We recognise this before the generic Dot-as-field
// path so `30.seconds` lowers to a DurationLit AST node carrying the
// count and the unit, not a field access on an Int.
let primary_kind: String = node["expr"]
if primary_kind == "Int" {
let dot_kind = tok_kind(tokens, p)
if dot_kind == "Dot" {
let unit_kind = tok_kind(tokens, p + 1)
if unit_kind == "Ident" {
let unit_name = tok_value(tokens, p + 1)
if is_duration_unit(unit_name) {
let count_str: String = node["value"]
let node = { "expr": "DurationLit", "count": count_str, "unit": unit_name }
let p = p + 2
}
}
}
}
let running = true
while running {
let k = tok_kind(tokens, p)
+150
View File
@@ -0,0 +1,150 @@
{
"allowlist": "{\"p\":[],\"br\":[],\"strong\":[],\"em\":[],\"u\":[],\"s\":[],\"code\":[],\"pre\":[],\"ul\":[],\"ol\":[],\"li\":[],\"h1\":[],\"h2\":[],\"h3\":[],\"h4\":[],\"blockquote\":[],\"a\":[\"href\",\"title\"]}",
"cases": [
{
"name": "01-pass-through-list",
"input": "<ol><li><strong>x</strong></li></ol>",
"expected": "<ol><li><strong>x</strong></li></ol>"
},
{
"name": "02-paragraphs-with-bold",
"input": "<p>hello <strong>world</strong></p><p>second</p>",
"expected": "<p>hello <strong>world</strong></p><p>second</p>"
},
{
"name": "03-pre-code-block",
"input": "<pre><code>npm install</code></pre>",
"expected": "<pre><code>npm install</code></pre>"
},
{
"name": "04-allowed-https-link",
"input": "<a href=\"https://example.com\">click</a>",
"expected": "<a href=\"https://example.com\">click</a>"
},
{
"name": "05-allowed-anchor-link",
"input": "<a href=\"#section\">jump</a>",
"expected": "<a href=\"#section\">jump</a>"
},
{
"name": "06-javascript-scheme-blocked",
"input": "<a href=\"javascript:alert(1)\">click</a>",
"expected": "<a>click</a>"
},
{
"name": "07-about-scheme-blocked",
"input": "<a href=\"about:blank#x\">click</a>",
"expected": "<a>click</a>"
},
{
"name": "08-data-uri-blocked",
"input": "<a href=\"data:text/html,<script>alert(1)</script>\">x</a>",
"expected": "<a>x</a>"
},
{
"name": "09-script-content-dropped",
"input": "before<script>alert(1)</script>after",
"expected": "beforeafter"
},
{
"name": "10-iframe-content-dropped",
"input": "<iframe src=\"evil\">junk</iframe>safe",
"expected": "safe"
},
{
"name": "11-form-content-dropped",
"input": "<form action=\"/steal\">x</form>safe",
"expected": "safe"
},
{
"name": "12-img-with-onerror-dropped",
"input": "<img src=x onerror=alert(2)>",
"expected": ""
},
{
"name": "13-comment-injection-bypass-blocked",
"input": "<form action=\"-->junk\">x</form>safe",
"expected": "safe"
},
{
"name": "14-mixed-legit-and-attack",
"input": "<p>hello</p><script>alert(1)</script><p>world</p>",
"expected": "<p>hello</p><p>world</p>"
},
{
"name": "15-pre-encoded-entities-preserved",
"input": "&lt;script&gt;alert(1)&lt;/script&gt;",
"expected": "&lt;script&gt;alert(1)&lt;/script&gt;"
},
{
"name": "16-unicode-in-href-preserved",
"input": "<a href=\"https://example.com/?q=日本語\">x</a>",
"expected": "<a href=\"https://example.com/?q=日本語\">x</a>"
},
{
"name": "17-unclosed-tag-passes-through",
"input": "<p>unclosed",
"expected": "<p>unclosed"
},
{
"name": "18-onclick-attribute-stripped-tag-survives",
"input": "<p onclick=\"x\">hi</p>",
"expected": "<p>hi</p>"
},
{
"name": "19-tab-bypass-in-scheme-blocked",
"input": "<a href=\"java\tscript:alert(1)\">x</a>",
"expected": "<a>x</a>"
},
{
"name": "20-uppercase-tag-and-attr-normalised",
"input": "<A HREF=\"https://example.com\">x</A>",
"expected": "<a href=\"https://example.com\">x</a>"
},
{
"name": "21-style-content-dropped",
"input": "<style>body{display:none}</style>visible",
"expected": "visible"
},
{
"name": "22-object-content-dropped",
"input": "<object data=\"x.swf\">flash</object>safe",
"expected": "safe"
},
{
"name": "23-svg-onload-dropped",
"input": "<svg onload=\"alert(1)\"><circle r=\"5\"/></svg>safe",
"expected": "safe"
},
{
"name": "24-blockquote-passthrough",
"input": "<blockquote>quoted text</blockquote>",
"expected": "<blockquote>quoted text</blockquote>"
},
{
"name": "25-headings-passthrough",
"input": "<h1>title</h1><h2>section</h2>",
"expected": "<h1>title</h1><h2>section</h2>"
},
{
"name": "26-attribute-value-with-gt-byte",
"input": "<a href=\"https://example.com/?q=1>2\" title=\"a > b\">x</a>",
"expected": "<a href=\"https://example.com/?q=1&gt;2\" title=\"a &gt; b\">x</a>"
},
{
"name": "27-nested-script-after-text",
"input": "lead<p>para</p><script>alert(1)</script>tail",
"expected": "lead<p>para</p>tail"
},
{
"name": "28-empty-input",
"input": "",
"expected": ""
},
{
"name": "29-plain-text",
"input": "just text, no tags",
"expected": "just text, no tags"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# run.sh — build and execute the html_sanitizer acceptance corpus.
#
# Compiles tests/html_sanitizer/runner.el via the canonical native elc,
# links against the shared C runtime, then runs the binary against
# cases.json. Exits non-zero on any FAIL.
set -euo pipefail
cd "$(dirname "$0")"
EL_HOME="${EL_HOME:-$(cd ../.. && pwd)}"
ELC="${EL_HOME}/dist/platform/elc"
RUNTIME_DIR="${EL_HOME}/el-compiler/runtime"
if [ ! -x "${ELC}" ]; then
echo "elc not found at ${ELC}" >&2
exit 1
fi
OUT_C="$(mktemp -t html_sanitizer.XXXXXX).c"
OUT_BIN="$(mktemp -t html_sanitizer.XXXXXX)"
trap 'rm -f "${OUT_C}" "${OUT_BIN}"' EXIT
echo "==> Compiling runner.el via ${ELC}"
"${ELC}" runner.el > "${OUT_C}"
echo "==> Linking against ${RUNTIME_DIR}/el_runtime.c"
cc -O2 -I "${RUNTIME_DIR}" "${OUT_C}" "${RUNTIME_DIR}/el_runtime.c" \
-lcurl -lpthread -o "${OUT_BIN}"
echo "==> Running"
"${OUT_BIN}"
+59
View File
@@ -0,0 +1,59 @@
// runner.el acceptance tests for el_html_sanitize.
//
// Reads cases.json from the cwd, runs every case through
// el_html_sanitize, prints PASS/FAIL per case and a summary,
// and exits non-zero if any case failed.
//
// cases.json shape:
// {
// "allowlist": "<json string passed to el_html_sanitize>",
// "cases": [
// { "name": "...", "input": "...", "expected": "..." },
// ...
// ]
// }
fn run_case(name: String, input: String, expected: String, allowlist: String, idx: Int) -> Int {
let got: String = el_html_sanitize(input, allowlist)
if str_eq(got, expected) {
println("PASS " + name)
return 1
} else {
println("FAIL " + name)
println(" input: " + input)
println(" expected: " + expected)
println(" got: " + got)
return 0
}
}
fn main() -> Int {
let raw: String = fs_read("cases.json")
if str_eq(raw, "") {
println("FATAL could not read cases.json from cwd")
return 2
}
let allowlist: String = json_get(raw, "allowlist")
let cases_raw: String = json_get_raw(raw, "cases")
let n: Int = json_array_len(cases_raw)
let i: Int = 0
let pass: Int = 0
let fail: Int = 0
while i < n {
let case_obj: String = json_array_get(cases_raw, i)
let cname: String = json_get(case_obj, "name")
let cinp: String = json_get(case_obj, "input")
let cexp: String = json_get(case_obj, "expected")
let r: Int = run_case(cname, cinp, cexp, allowlist, i)
if r == 1 {
let pass = pass + 1
} else {
let fail = fail + 1
}
let i = i + 1
}
println("")
println(int_to_str(pass) + " passed, " + int_to_str(fail) + " failed")
if fail > 0 { return 1 }
return 0
}