nx_html_render.nx source
↩ module page · 418 lines · 15426 B
1// nx_html_render.nx -- typed-context HTML render primitive.
2//
3// Named by nx_bug_tape_intelligence.sh F1 rule (2026-05-16) as the
4// rank-2 next ship-order: CWE-79 Cross-Site Scripting. Phase-2
5// critical primitive for the audit-dashboard arc (per
6// docs/NISHI_AUDIT_DASHBOARD_ROADMAP.md).
7//
8// Why "typed context" instead of "escape this string":
9//
10// The XSS bug class isn't "you forgot to escape". It's "you
11// escaped for the wrong context." HTML body text needs `<` -> `<`;
12// attribute-value-double-quoted needs `"` -> `"`;
13// URL context needs %-encoding (RFC 3986); JavaScript string
14// context needs `\` + control-char escaping; <style> CSS context
15// needs yet another escape table. A primitive that takes a
16// single "value" parameter without specifying which context it's
17// being emitted into is a perpetual XSS-vector machine.
18//
19// Substrate's structural prevention: sealed-enum NxHtmlContext +
20// every emit takes a context + the escape is dispatched per context
21// at the boundary, never inferred from string content.
22//
23// Sealed enum NxHtmlContext (closes the XSS class):
24// NXH_CTX_TEXT <body> text, between tags
25// NXH_CTX_ATTR_DQ inside an attribute="..." (double-quoted)
26// NXH_CTX_ATTR_SQ inside an attribute='...' (single-quoted)
27// NXH_CTX_URL href / src / action attribute (URL value)
28// NXH_CTX_COMMENT inside <!-- ... -->
29// NXH_CTX_CDATA inside <![CDATA[...]]> (XHTML)
30// NXH_CTX_RAW EXPLICIT raw-html bypass; caller asserts
31// the bytes are already safe (e.g., a
32// pre-rendered nested fragment). Sealed-
33// enum status: AUDIT-WHITELIST.
34//
35// Sealed enum NxHtmlNodeKind:
36// NXH_NODE_ELEMENT <tag attrs>children</tag>
37// NXH_NODE_TEXT text content (auto-escaped per ctx)
38// NXH_NODE_RAW caller-asserted-safe raw bytes (audited)
39// NXH_NODE_COMMENT <!-- ... --> (text auto-escaped)
40//
41// Per cardinal feedback-licensing-absorb-vs-copy-discipline: this
42// is ORIGINAL substrate; no copyrighted templates copied.
43//
44// Per cardinal feedback-user-owns-every-bit: every output goes to
45// a caller-provided buffer. No implicit allocation. No globals.
46//
47// nx_capability_claims:
48// needs: [sealed_enum, integer_compare, byte_ops]
49// provides: [html_render_typed_context, xss_structural_prevention]
50// safety: [no_unchecked_deref, no_floating_point, no_syscall,
51// bit_equal_reproducible, target_agnostic,
52// context_validated_at_emit]
53// verdict: [sealed_enum_7_context, sealed_enum_4_nodekind]
54// license: ORIGINAL
55// kind: racing_crew_specialist
56// layer: L2 (transform over L1 byte-buffer container)
57// sss: [S6 sealed-enum verdicts; S11 timing-mode declared]
58// cwe: [CWE-79 XSS structural prevention]
59
60// ---- Sealed enum: NxHtmlContext -----------------------------------
61
62const NXH_CTX_TEXT: i64 = 0
63const NXH_CTX_ATTR_DQ: i64 = 1
64const NXH_CTX_ATTR_SQ: i64 = 2
65const NXH_CTX_URL: i64 = 3
66const NXH_CTX_COMMENT: i64 = 4
67const NXH_CTX_CDATA: i64 = 5
68const NXH_CTX_RAW: i64 = 6
69const NXH_CTX_N: i64 = 7
70
71func nxh_ctx_is_valid(c: i64) -> i64 {
72 if c < 0 { return 0 }
73 if c >= NXH_CTX_N { return 0 }
74 return 1
75}
76
77func nxh_ctx_name(c: i64) -> *u8 {
78 if c == NXH_CTX_TEXT { return "TEXT" as *u8 }
79 if c == NXH_CTX_ATTR_DQ { return "ATTR_DQ" as *u8 }
80 if c == NXH_CTX_ATTR_SQ { return "ATTR_SQ" as *u8 }
81 if c == NXH_CTX_URL { return "URL" as *u8 }
82 if c == NXH_CTX_COMMENT { return "COMMENT" as *u8 }
83 if c == NXH_CTX_CDATA { return "CDATA" as *u8 }
84 if c == NXH_CTX_RAW { return "RAW" as *u8 }
85 return "INVALID" as *u8
86}
87
88// ---- Sealed enum: NxHtmlNodeKind ----------------------------------
89
90const NXH_NODE_ELEMENT: i64 = 0
91const NXH_NODE_TEXT: i64 = 1
92const NXH_NODE_RAW: i64 = 2
93const NXH_NODE_COMMENT: i64 = 3
94const NXH_NODE_N: i64 = 4
95
96func nxh_node_is_valid(k: i64) -> i64 {
97 if k < 0 { return 0 }
98 if k >= NXH_NODE_N { return 0 }
99 return 1
100}
101
102// ---- Render verdict (sealed enum) ---------------------------------
103
104const NXH_OK: i64 = 0
105const NXH_OOM_BUFFER: i64 = 1
106const NXH_BAD_CTX: i64 = 2
107const NXH_BAD_INPUT: i64 = 3
108const NXH_BAD_URL: i64 = 4
109const NXH_VERDICT_N: i64 = 5
110
111func nxh_verdict_is_valid(v: i64) -> i64 {
112 if v < 0 { return 0 }
113 if v >= NXH_VERDICT_N { return 0 }
114 return 1
115}
116
117// ---- Internal helpers ---------------------------------------------
118
119// Append a single byte to out[*off] with bounds-check. Returns 0 on
120// success, NXH_OOM_BUFFER if the write would exceed out_cap.
121func nxh_put(out: *u8, off: *i64, cap: i64, b: i64) -> i64 {
122 if *off >= cap { return NXH_OOM_BUFFER }
123 out[*off] = b as u8
124 *off = *off + 1
125 return NXH_OK
126}
127
128// Append a NUL-terminated literal to out[*off..]; bounds-checked.
129func nxh_put_lit(out: *u8, off: *i64, cap: i64, lit: *u8) -> i64 {
130 var i: i64 = 0
131 while lit[i] != 0 {
132 let rc: i64 = nxh_put(out, off, cap, lit[i] as i64)
133 if rc != NXH_OK { return rc }
134 i = i + 1
135 }
136 return NXH_OK
137}
138
139// Append a sized buffer (no NUL required).
140func nxh_put_bytes(out: *u8, off: *i64, cap: i64, src: *u8, n: i64) -> i64 {
141 var i: i64 = 0
142 while i < n {
143 let rc: i64 = nxh_put(out, off, cap, src[i] as i64)
144 if rc != NXH_OK { return rc }
145 i = i + 1
146 }
147 return NXH_OK
148}
149
150// Predicate: byte safe for URL context (RFC 3986 unreserved + reserved
151// punctuation). ASCII letters / digits / `-_.~` + a tightly-bounded
152// punctuation set used in URL path segments and query keys. Anything
153// else gets %HH-encoded.
154func nxh_url_safe(b: i64) -> i64 {
155 if b >= 0x30 && b <= 0x39 { return 1 } // 0-9
156 if b >= 0x41 && b <= 0x5a { return 1 } // A-Z
157 if b >= 0x61 && b <= 0x7a { return 1 } // a-z
158 if b == 0x2d { return 1 } // -
159 if b == 0x5f { return 1 } // _
160 if b == 0x2e { return 1 } // .
161 if b == 0x7e { return 1 } // ~
162 // Reserved-but-commonly-permitted in path segments:
163 if b == 0x2f { return 1 } // /
164 if b == 0x3a { return 1 } // :
165 if b == 0x3f { return 1 } // ?
166 if b == 0x3d { return 1 } // =
167 if b == 0x26 { return 1 } // &
168 if b == 0x23 { return 1 } // #
169 return 0
170}
171
172// Hex nibble -> ASCII (uppercase).
173func nxh_hex_nibble(n: i64) -> i64 {
174 if n < 10 { return 0x30 + n } // '0' + n
175 return 0x41 + (n - 10) // 'A' + (n-10)
176}
177
178// ---- The escape primitive (the load-bearing entry point) ----------
179//
180// Writes `src[0..n]` into `out[*off..]` with the escaping rules for
181// the given context. Returns NXH_OK on success or NXH_OOM_BUFFER /
182// NXH_BAD_CTX / NXH_BAD_INPUT / NXH_BAD_URL on failure.
183//
184// The escape tables (per context):
185//
186// TEXT & < > -> & < >
187// (apostrophe + quote optional but harmless)
188// ATTR_DQ & < > " -> & < > "
189// ATTR_SQ & < > ' -> & < > '
190// URL per nxh_url_safe; unsafe bytes -> %HH (uppercase)
191// COMMENT must not contain `--` sequence; reject as BAD_INPUT
192// CDATA must not contain `]]>` sequence; reject as BAD_INPUT
193// RAW verbatim copy; caller-asserted safe
194//
195// Per substrate cardinal: emission FAILS LOUD on context mismatch
196// (sealed-enum verdict). Never silently "fixes up" input.
197
198func nx_html_escape(out: *u8, off: *i64, cap: i64,
199 src: *u8, n: i64, ctx: i64) -> i64 {
200 if nxh_ctx_is_valid(ctx) != 1 { return NXH_BAD_CTX }
201 if n < 0 { return NXH_BAD_INPUT }
202 if out == (0 as *u8) { return NXH_BAD_INPUT }
203 if off == (0 as *i64) { return NXH_BAD_INPUT }
204 if src == (0 as *u8) && n > 0 { return NXH_BAD_INPUT }
205
206 if ctx == NXH_CTX_RAW {
207 return nxh_put_bytes(out, off, cap, src, n)
208 }
209
210 if ctx == NXH_CTX_COMMENT {
211 // Refuse if `--` appears in src; HTML comments cannot contain it.
212 var i: i64 = 0
213 while i < n - 1 {
214 if src[i] == 0x2d && src[i+1] == 0x2d { return NXH_BAD_INPUT }
215 i = i + 1
216 }
217 return nxh_put_bytes(out, off, cap, src, n)
218 }
219
220 if ctx == NXH_CTX_CDATA {
221 // Refuse if `]]>` appears in src.
222 var i: i64 = 0
223 while i < n - 2 {
224 if src[i] == 0x5d && src[i+1] == 0x5d && src[i+2] == 0x3e {
225 return NXH_BAD_INPUT
226 }
227 i = i + 1
228 }
229 return nxh_put_bytes(out, off, cap, src, n)
230 }
231
232 if ctx == NXH_CTX_URL {
233 var i: i64 = 0
234 while i < n {
235 let b: i64 = src[i] as i64
236 if nxh_url_safe(b) == 1 {
237 let rc: i64 = nxh_put(out, off, cap, b)
238 if rc != NXH_OK { return rc }
239 } else {
240 let rc1: i64 = nxh_put(out, off, cap, 0x25) // %
241 if rc1 != NXH_OK { return rc1 }
242 let rc2: i64 = nxh_put(out, off, cap, nxh_hex_nibble((b >> 4) & 0xf))
243 if rc2 != NXH_OK { return rc2 }
244 let rc3: i64 = nxh_put(out, off, cap, nxh_hex_nibble(b & 0xf))
245 if rc3 != NXH_OK { return rc3 }
246 }
247 i = i + 1
248 }
249 return NXH_OK
250 }
251
252 // TEXT / ATTR_DQ / ATTR_SQ -- ampersand-entity escaping per context.
253 var i: i64 = 0
254 while i < n {
255 let b: i64 = src[i] as i64
256 var rc: i64 = NXH_OK
257 if b == 0x26 {
258 rc = nxh_put_lit(out, off, cap, "&" as *u8)
259 } else {
260 if b == 0x3c {
261 rc = nxh_put_lit(out, off, cap, "<" as *u8)
262 } else {
263 if b == 0x3e {
264 rc = nxh_put_lit(out, off, cap, ">" as *u8)
265 } else {
266 if ctx == NXH_CTX_ATTR_DQ && b == 0x22 {
267 rc = nxh_put_lit(out, off, cap, """ as *u8)
268 } else {
269 if ctx == NXH_CTX_ATTR_SQ && b == 0x27 {
270 rc = nxh_put_lit(out, off, cap, "'" as *u8)
271 } else {
272 rc = nxh_put(out, off, cap, b)
273 }
274 }
275 }
276 }
277 }
278 if rc != NXH_OK { return rc }
279 i = i + 1
280 }
281 return NXH_OK
282}
283
284// ---- Element emission helpers -------------------------------------
285//
286// Caller pattern:
287//
288// var off: i64 = 0
289// let cap: i64 = 4096
290// let buf: *u8 = sys_mmap(cap)
291// nx_html_open_tag(buf, &off, cap, "div" as *u8, 3)
292// nx_html_attr(buf, &off, cap, "class" as *u8, 5, "card" as *u8, 4)
293// nx_html_close_open_tag(buf, &off, cap)
294// nx_html_text(buf, &off, cap, user_supplied, user_len)
295// nx_html_end_tag(buf, &off, cap, "div" as *u8, 3)
296//
297// Sealed-enum NXH_OK / NXH_OOM_BUFFER returned at every step.
298
299// Predicate: ASCII alphanumeric byte (for tag + attribute names).
300func nxh_is_alnum(b: i64) -> i64 {
301 if b >= 0x30 && b <= 0x39 { return 1 }
302 if b >= 0x41 && b <= 0x5a { return 1 }
303 if b >= 0x61 && b <= 0x7a { return 1 }
304 return 0
305}
306
307// Predicate: ASCII alphanumeric OR dash (for attribute names).
308func nxh_is_attr_name_char(b: i64) -> i64 {
309 if nxh_is_alnum(b) == 1 { return 1 }
310 if b == 0x2d { return 1 } // -
311 return 0
312}
313
314// Emit `<tag` (no closing `>` -- attributes follow). Caller calls
315// nx_html_close_open_tag before children.
316func nx_html_open_tag(out: *u8, off: *i64, cap: i64,
317 tag: *u8, tag_n: i64) -> i64 {
318 if tag_n <= 0 { return NXH_BAD_INPUT }
319 let rc1: i64 = nxh_put(out, off, cap, 0x3c) // <
320 if rc1 != NXH_OK { return rc1 }
321 // Tag-name must be ASCII alphanumeric (constrained per HTML5).
322 var i: i64 = 0
323 while i < tag_n {
324 let b: i64 = tag[i] as i64
325 if nxh_is_alnum(b) == 0 { return NXH_BAD_INPUT }
326 let rc: i64 = nxh_put(out, off, cap, b)
327 if rc != NXH_OK { return rc }
328 i = i + 1
329 }
330 return NXH_OK
331}
332
333// Emit ` name="value"` (with attribute-context escaping of value).
334func nx_html_attr(out: *u8, off: *i64, cap: i64,
335 name: *u8, name_n: i64,
336 value: *u8, value_n: i64) -> i64 {
337 if name_n <= 0 { return NXH_BAD_INPUT }
338 let rc_sp: i64 = nxh_put(out, off, cap, 0x20)
339 if rc_sp != NXH_OK { return rc_sp }
340 // Attribute name: ASCII alphanumeric + `-`.
341 var i: i64 = 0
342 while i < name_n {
343 let b: i64 = name[i] as i64
344 if nxh_is_attr_name_char(b) == 0 { return NXH_BAD_INPUT }
345 let rc: i64 = nxh_put(out, off, cap, b)
346 if rc != NXH_OK { return rc }
347 i = i + 1
348 }
349 let rc_eq: i64 = nxh_put(out, off, cap, 0x3d) // =
350 if rc_eq != NXH_OK { return rc_eq }
351 let rc_q1: i64 = nxh_put(out, off, cap, 0x22) // "
352 if rc_q1 != NXH_OK { return rc_q1 }
353 let rc_v: i64 = nx_html_escape(out, off, cap, value, value_n, NXH_CTX_ATTR_DQ)
354 if rc_v != NXH_OK { return rc_v }
355 return nxh_put(out, off, cap, 0x22) // "
356}
357
358// Emit ` href="<url>"` with URL-context escape on the value.
359func nx_html_attr_url(out: *u8, off: *i64, cap: i64,
360 name: *u8, name_n: i64,
361 url: *u8, url_n: i64) -> i64 {
362 if name_n <= 0 { return NXH_BAD_INPUT }
363 let rc_sp: i64 = nxh_put(out, off, cap, 0x20)
364 if rc_sp != NXH_OK { return rc_sp }
365 let rc_n: i64 = nxh_put_bytes(out, off, cap, name, name_n)
366 if rc_n != NXH_OK { return rc_n }
367 let rc_eq: i64 = nxh_put(out, off, cap, 0x3d)
368 if rc_eq != NXH_OK { return rc_eq }
369 let rc_q1: i64 = nxh_put(out, off, cap, 0x22)
370 if rc_q1 != NXH_OK { return rc_q1 }
371 let rc_u: i64 = nx_html_escape(out, off, cap, url, url_n, NXH_CTX_URL)
372 if rc_u != NXH_OK { return rc_u }
373 return nxh_put(out, off, cap, 0x22)
374}
375
376// Close the opening tag with `>`. Use after open_tag + attrs.
377func nx_html_close_open_tag(out: *u8, off: *i64, cap: i64) -> i64 {
378 return nxh_put(out, off, cap, 0x3e) // >
379}
380
381// Self-closing variant: `/>`. Use for void elements (br/hr/img/...).
382func nx_html_self_close(out: *u8, off: *i64, cap: i64) -> i64 {
383 let rc1: i64 = nxh_put(out, off, cap, 0x2f) // /
384 if rc1 != NXH_OK { return rc1 }
385 return nxh_put(out, off, cap, 0x3e)
386}
387
388// Emit </tag>. Tag is bounds-checked to ASCII alphanumeric.
389func nx_html_end_tag(out: *u8, off: *i64, cap: i64,
390 tag: *u8, tag_n: i64) -> i64 {
391 let rc1: i64 = nxh_put(out, off, cap, 0x3c) // <
392 if rc1 != NXH_OK { return rc1 }
393 let rc2: i64 = nxh_put(out, off, cap, 0x2f) // /
394 if rc2 != NXH_OK { return rc2 }
395 var i: i64 = 0
396 while i < tag_n {
397 let b: i64 = tag[i] as i64
398 if nxh_is_alnum(b) == 0 { return NXH_BAD_INPUT }
399 let rc: i64 = nxh_put(out, off, cap, b)
400 if rc != NXH_OK { return rc }
401 i = i + 1
402 }
403 return nxh_put(out, off, cap, 0x3e)
404}
405
406// Emit text content (TEXT-context escaped).
407func nx_html_text(out: *u8, off: *i64, cap: i64,
408 src: *u8, n: i64) -> i64 {
409 return nx_html_escape(out, off, cap, src, n, NXH_CTX_TEXT)
410}
411
412// Emit a raw bypass (caller asserts the bytes are safe). Audit
413// every call site -- this is the XSS bypass we explicitly allow
414// for nested-fragment composition.
415func nx_html_raw(out: *u8, off: *i64, cap: i64,
416 src: *u8, n: i64) -> i64 {
417 return nx_html_escape(out, off, cap, src, n, NXH_CTX_RAW)
418}