code wiki / _hdl_build / nx_html_sanitize.nx

nx_html_sanitize.nx source

↩ module page · 294 lines · 12430 B

1// nx_html_sanitize.nx -- CMS ladder step 9 (KEYSTONE): KSES-style HTML ALLOWLIST sanitizer, run on 2// WRITE at the /admin/save boundary (global rule 12: defensive at boundaries). Everything not on the 3// allowlist is REMOVED, never blocklisted: unknown tags stripped (inner text kept), ALL attributes 4// dropped except a@href, href accepted ONLY for http:// https:// / # schemes (post-filter, so 5// "java\tscript:" tricks die with the filtered whitespace), <script>/<style> CONTENT stripped too, 6// stray '<' escaped to &lt;. Quote-aware '>' scan (a '>' inside a quoted attribute does not end the 7// tag). hs_escape = full entity escape for plain-text fields (escape-on-render, defense in depth). 8// Allowlist = packed table HS_TAGS (data-driven, rule 11) -- add a tag = add a table entry. 9// NEEDS_TUTOR build (PARSER shape, not yet emitter-covered): Claude authored, Engineer KATs = real 10// XSS vectors in nx_html_sanitize_test.nx. LAWS: struct-free, integer-only, flat ifs. license_tier: ORIGINAL 11import "nx_syscalls.nx" 12 13// inline-safe formatting tags for legal-staff field content. Packed c-strings, double-NUL terminated. 14const HS_TAGS: *u8 = "b\0i\0u\0em\0strong\0p\0br\0ul\0ol\0li\0a\0h2\0h3\0blockquote\0\0" 15 16func hs_lc(c: i64) -> i64 { if c >= 65 { if c <= 90 { return c + 32 } } return c } 17func hs_alpha(c: i64) -> i64 { let l: i64 = hs_lc(c); if l >= 97 { if l <= 122 { return 1 } } return 0 } 18func hs_digit(c: i64) -> i64 { if c >= 48 { if c <= 57 { return 1 } } return 0 } 19 20// allowlist lookup: tag (lowercased, tl bytes) -> entry index, or -1 21func hs_tag_allowed(t: *u8, tl: i64) -> i64 { 22 let tab: *u8 = HS_TAGS 23 var p: i64 = 0 24 var idx: i64 = 0 25 while tab[p] != (0 as u8) { 26 var q: i64 = 0 27 var ok: i64 = 1 28 while tab[p+q] != (0 as u8) { 29 if q >= tl { ok = 0 } 30 if q < tl { if (tab[p+q] as i64) != (t[q] as i64) { ok = 0 } } 31 q = q + 1 32 } 33 if q != tl { ok = 0 } 34 if ok == 1 { return idx } 35 p = p + q + 1 36 idx = idx + 1 37 } 38 return 0 - 1 39} 40 41func hs_emitb(out: *u8, o: i64, cap: i64, c: i64) -> i64 { 42 if o >= cap { return o } 43 out[o] = c as u8 44 return o + 1 45} 46func hs_emit(out: *u8, o: i64, cap: i64, s: *u8) -> i64 { 47 var k: i64 = 0 48 var oo: i64 = o 49 while s[k] != (0 as u8) { oo = hs_emitb(out, oo, cap, s[k] as i64); k = k + 1 } 50 return oo 51} 52 53// gather a tag name at j (lowercased into tbuf, max 31): returns length (0 = not a name) 54func hs_name_at(inp: *u8, n: i64, j: i64, tbuf: *u8) -> i64 { 55 var k: i64 = 0 56 var p: i64 = j 57 var go: i64 = 1 58 while go == 1 { 59 go = 0 60 if p < n { if k < 31 { 61 let c: i64 = inp[p] as i64 62 var nm: i64 = hs_alpha(c) 63 if k > 0 { if hs_digit(c) == 1 { nm = 1 } } 64 if nm == 1 { tbuf[k] = hs_lc(c) as u8; k = k + 1; p = p + 1; go = 1 } 65 } } 66 } 67 return k 68} 69 70// quote-aware scan for the '>' that ends the tag opened at i ('<'). returns its index, or -1. 71func hs_find_gt(inp: *u8, n: i64, i: i64) -> i64 { 72 var p: i64 = i + 1 73 var dq: i64 = 0 74 var sq: i64 = 0 75 while p < n { 76 let c: i64 = inp[p] as i64 77 if c == 34 { if sq == 0 { dq = 1 - dq } } 78 if c == 39 { if dq == 0 { sq = 1 - sq } } 79 if c == 62 { if dq == 0 { if sq == 0 { return p } } } 80 p = p + 1 81 } 82 return 0 - 1 83} 84 85// case-insensitive search for "</name" starting at j; returns index AFTER that close tag's '>', 86// or n if never found (strip to end). Used to drop <script>/<style> CONTENT. 87func hs_skip_close(inp: *u8, n: i64, j: i64, tbuf: *u8, tl: i64) -> i64 { 88 var p: i64 = j 89 while p + 2 + tl <= n { 90 var hit: i64 = 0 91 if (inp[p] as i64) == 60 { if (inp[p+1] as i64) == 47 { 92 hit = 1 93 var q: i64 = 0 94 while q < tl { 95 if hs_lc(inp[p+2+q] as i64) != (tbuf[q] as i64) { hit = 0 } 96 q = q + 1 97 } 98 } } 99 if hit == 1 { 100 let g: i64 = hs_find_gt(inp, n, p) 101 if g < 0 { return n } 102 return g + 1 103 } 104 p = p + 1 105 } 106 return n 107} 108 109// copy the value at v (quoted or bare) into hbuf with the hostile-byte filter; returns filtered length 110func hs_href_value(inp: *u8, gt: i64, v: i64, hbuf: *u8, hcap: i64) -> i64 { 111 var p: i64 = v 112 var endq: i64 = 0 113 let c0: i64 = inp[p] as i64 114 if c0 == 34 { endq = 34; p = p + 1 } 115 if c0 == 39 { endq = 39; p = p + 1 } 116 var h: i64 = 0 117 while p < gt { 118 let c: i64 = inp[p] as i64 119 if endq != 0 { if c == endq { return h } } 120 if endq == 0 { if c <= 32 { return h } } 121 var drop: i64 = 0 122 if c <= 32 { drop = 1 } 123 if c == 34 { drop = 1 } 124 if c == 39 { drop = 1 } 125 if c == 60 { drop = 1 } 126 if c == 62 { drop = 1 } 127 if c == 96 { drop = 1 } 128 if drop == 0 { if h < hcap { hbuf[h] = c as u8; h = h + 1 } } 129 p = p + 1 130 } 131 return h 132} 133 134// extract href value from the tag span (i..gt): filtered copy into hbuf, returns filtered length, 135// -1 if no href attribute present, 0 if href present but valueless/empty (caller drops it either way). 136func hs_href_extract(inp: *u8, n: i64, i: i64, gt: i64, hbuf: *u8, hcap: i64) -> i64 { 137 var p: i64 = i + 1 138 var found: i64 = 0 - 1 139 while p + 4 < gt { 140 var hit: i64 = 1 141 if hs_lc(inp[p] as i64) != 104 { hit = 0 } // h 142 if hs_lc(inp[p+1] as i64) != 114 { hit = 0 } // r 143 if hs_lc(inp[p+2] as i64) != 101 { hit = 0 } // e 144 if hs_lc(inp[p+3] as i64) != 102 { hit = 0 } // f 145 // must be preceded by whitespace (not the tail of another attr name) 146 if hit == 1 { if (inp[p-1] as i64) > 32 { hit = 0 } } 147 if hit == 1 { found = p; p = gt } 148 if found < 0 { p = p + 1 } 149 } 150 if found < 0 { return 0 - 1 } 151 var v: i64 = found + 4 152 var go: i64 = 1 153 while go == 1 { go = 0; if v < gt { if (inp[v] as i64) <= 32 { v = v + 1; go = 1 } } } // ws before '=' 154 if v >= gt { return 0 } 155 if (inp[v] as i64) != 61 { return 0 } // bare href attr, no value 156 v = v + 1 157 go = 1 158 while go == 1 { go = 0; if v < gt { if (inp[v] as i64) <= 32 { v = v + 1; go = 1 } } } // ws after '=' 159 if v >= gt { return 0 } 160 return hs_href_value(inp, gt, v, hbuf, hcap) 161} 162 163// scheme allowlist on the FILTERED value: http:// | https:// | leading / | leading # 164func hs_scheme_ok(h: *u8, hl: i64) -> i64 { 165 if hl <= 0 { return 0 } 166 if (h[0] as i64) == 47 { return 1 } 167 if (h[0] as i64) == 35 { return 1 } 168 if hl >= 7 { 169 var ok: i64 = 1 170 if hs_lc(h[0] as i64) != 104 { ok = 0 } // h 171 if hs_lc(h[1] as i64) != 116 { ok = 0 } // t 172 if hs_lc(h[2] as i64) != 116 { ok = 0 } // t 173 if hs_lc(h[3] as i64) != 112 { ok = 0 } // p 174 if ok == 1 { 175 var q: i64 = 4 176 if hs_lc(h[4] as i64) == 115 { q = 5 } // optional s 177 if hl >= q + 3 { if (h[q] as i64) == 58 { if (h[q+1] as i64) == 47 { if (h[q+2] as i64) == 47 { return 1 } } } } 178 } 179 } 180 return 0 181} 182 183// THE sanitizer: in[0..n) -> out (cap bytes), returns output length. 184func hs_sanitize(inp: *u8, n: i64, out: *u8, cap: i64) -> i64 { 185 let tbuf: *u8 = sys_mmap(32) 186 let hbuf: *u8 = sys_mmap(1024) 187 var i: i64 = 0 188 var o: i64 = 0 189 while i < n { 190 let c: i64 = inp[i] as i64 191 if c != 60 { 192 o = hs_emitb(out, o, cap, c) 193 i = i + 1 194 } 195 if c == 60 { 196 var j: i64 = i + 1 197 var closing: i64 = 0 198 if j < n { if (inp[j] as i64) == 47 { closing = 1; j = j + 1 } } 199 let tl: i64 = hs_name_at(inp, n, j, tbuf) 200 var handled: i64 = 0 201 if tl == 0 { if closing == 0 { 202 // not a tag: escape the '<' and move on 203 o = hs_emit(out, o, cap, "&lt;" as *u8) 204 i = i + 1 205 handled = 1 206 } } 207 if handled == 0 { 208 let gt: i64 = hs_find_gt(inp, n, i) 209 if gt < 0 { 210 // unterminated: escape '<', keep the rest as text 211 o = hs_emit(out, o, cap, "&lt;" as *u8) 212 i = i + 1 213 handled = 1 214 } 215 if handled == 0 { 216 let allowed: i64 = hs_tag_allowed(tbuf, tl) 217 if allowed < 0 { 218 // stripped. script/style additionally lose their CONTENT. 219 var iscrip: i64 = 0 220 if tl == 6 { if (tbuf[0] as i64) == 115 { if (tbuf[1] as i64) == 99 { iscrip = 1 } } } // script 221 if tl == 5 { if (tbuf[0] as i64) == 115 { if (tbuf[1] as i64) == 116 { iscrip = 1 } } } // style 222 i = gt + 1 223 if closing == 0 { if iscrip == 1 { i = hs_skip_close(inp, n, gt + 1, tbuf, tl) } } 224 } 225 if allowed >= 0 { 226 var isa: i64 = 0 227 if tl == 1 { if (tbuf[0] as i64) == 97 { isa = 1 } } 228 o = hs_emitb(out, o, cap, 60) 229 if closing == 1 { o = hs_emitb(out, o, cap, 47) } 230 var k: i64 = 0 231 while k < tl { o = hs_emitb(out, o, cap, tbuf[k] as i64); k = k + 1 } 232 if isa == 1 { if closing == 0 { 233 let hl: i64 = hs_href_extract(inp, n, i, gt, hbuf, 1023) 234 if hl > 0 { if hs_scheme_ok(hbuf, hl) == 1 { 235 o = hs_emit(out, o, cap, " href=\"" as *u8) 236 var m: i64 = 0 237 while m < hl { o = hs_emitb(out, o, cap, hbuf[m] as i64); m = m + 1 } 238 o = hs_emitb(out, o, cap, 34) 239 } } 240 } } 241 o = hs_emitb(out, o, cap, 62) 242 i = gt + 1 243 } 244 } 245 } 246 } 247 } 248 return o 249} 250 251// full entity escape for PLAIN-TEXT fields (title, phone, address): & < > " ' 252// APPEND-AT-OFFSET ADAPTER (seq677, 2026-07-23) -- THE ONE HOME FOR HTML-SINK ESCAPING. 253// Every ecosystem emitter uses the shape `o = <x>_cat_esc(dst, off, src, span_start, span_end)` while 254// hs_escape writes a whole buffer from offset 0, so organs kept minting their OWN escaper -- and several 255// minted a JSON-string escaper (quote/backslash/controls, NO markup neutralisation) and pointed it at an 256// HTML sink. That produced a live STORED INJECTION on /standup: journal callout text reached a public 257// page raw, so any agent writing a note controlled markup (a note containing <script> would have run). 258// This adapter shares hs_emit/hs_emitb and the SAME entity table as hs_escape (rule 15), so a caller 259// gets the KAT'd behaviour by construction. MIGRATE-ON-TOUCH: any organ rendering store/journal text 260// into HTML should call this instead of a local *_cat_esc. 261func hs_cat_esc_span(d: *u8, o: i64, q: *u8, s: i64, e: i64, cap: i64) -> i64 { 262 var w: i64 = o 263 var i: i64 = s 264 while i < e { 265 let c: i64 = q[i] as i64 266 var plain: i64 = 1 267 if c == 38 { w = hs_emit(d, w, cap, "&amp;" as *u8); plain = 0 } 268 if c == 60 { w = hs_emit(d, w, cap, "&lt;" as *u8); plain = 0 } 269 if c == 62 { w = hs_emit(d, w, cap, "&gt;" as *u8); plain = 0 } 270 if c == 34 { w = hs_emit(d, w, cap, "&quot;" as *u8); plain = 0 } 271 if c == 39 { w = hs_emit(d, w, cap, "&#39;" as *u8); plain = 0 } 272 if c < 32 { w = hs_emitb(d, w, cap, 32); plain = 0 } 273 if plain == 1 { w = hs_emitb(d, w, cap, c) } 274 i = i + 1 275 } 276 return w 277} 278 279func hs_escape(inp: *u8, n: i64, out: *u8, cap: i64) -> i64 { 280 var i: i64 = 0 281 var o: i64 = 0 282 while i < n { 283 let c: i64 = inp[i] as i64 284 var plain: i64 = 1 285 if c == 38 { o = hs_emit(out, o, cap, "&amp;" as *u8); plain = 0 } 286 if c == 60 { o = hs_emit(out, o, cap, "&lt;" as *u8); plain = 0 } 287 if c == 62 { o = hs_emit(out, o, cap, "&gt;" as *u8); plain = 0 } 288 if c == 34 { o = hs_emit(out, o, cap, "&quot;" as *u8); plain = 0 } 289 if c == 39 { o = hs_emit(out, o, cap, "&#39;" as *u8); plain = 0 } 290 if plain == 1 { o = hs_emitb(out, o, cap, c) } 291 i = i + 1 292 } 293 return o 294}