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:
@@ -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": "<script>alert(1)</script>",
|
||||
"expected": "<script>alert(1)</script>"
|
||||
},
|
||||
{
|
||||
"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>2\" title=\"a > 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+32
@@ -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}"
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user