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 <. 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, "<" 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, "<" 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, "&" as *u8); plain = 0 }
268 if c == 60 { w = hs_emit(d, w, cap, "<" as *u8); plain = 0 }
269 if c == 62 { w = hs_emit(d, w, cap, ">" as *u8); plain = 0 }
270 if c == 34 { w = hs_emit(d, w, cap, """ as *u8); plain = 0 }
271 if c == 39 { w = hs_emit(d, w, cap, "'" 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, "&" as *u8); plain = 0 }
286 if c == 60 { o = hs_emit(out, o, cap, "<" as *u8); plain = 0 }
287 if c == 62 { o = hs_emit(out, o, cap, ">" as *u8); plain = 0 }
288 if c == 34 { o = hs_emit(out, o, cap, """ as *u8); plain = 0 }
289 if c == 39 { o = hs_emit(out, o, cap, "'" as *u8); plain = 0 }
290 if plain == 1 { o = hs_emitb(out, o, cap, c) }
291 i = i + 1
292 }
293 return o
294}