nx_html_escape.nx source
↩ module page · 287 lines · 9533 B
1// html_escape.nx -- HTML entity encoder + decoder.
2//
3// Escapes the five characters that are NEVER safe in HTML text
4// content or attribute values:
5// < <
6// > >
7// & &
8// " "
9// ' ' (apostrophe -- ' is XML only, not HTML4)
10//
11// This is the XSS-prevention primitive. Any user-supplied text
12// rendered into an HTML template MUST go through this first.
13// Nishi-pages, every sovereign web handler, and any template
14// engine built on top all rely on correct escaping.
15//
16// Decoder handles the five named entities + numeric character
17// references (&#NN; and &#xHH;) bounded to the ASCII range.
18// Full Unicode entity decoding is out of scope (would need the
19// HTML5 named-character-reference table of ~2000 entries);
20// callers with that need should pre-process server-side into
21// one of the five safe forms.
22//
23// Invariants:
24// HE1 Encoding output is never larger than 6x input (worst
25// case: every byte is '>' which becomes ">" = 4 bytes).
26// Caller sizes the output buffer at src_len * 6 + 1.
27// HE2 Encode is idempotent on already-safe bytes (ASCII alnum,
28// space, punctuation except the five).
29// HE3 Decode rejects malformed entities by leaving them
30// literal (per WHATWG tolerant parsing); unknown named
31// entities also pass through literal.
32// HE4 Round trip: decode(encode(x)) == x for any ASCII x.
33
34// nx_safety_envelope:
35// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
36// sil_target: SIL1
37// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
38// verdict: NOT_YET_EVALUATED
39
40import "nx_syscalls.nx"
41
42const HE_ERR_SHORT: i64 = -1
43
44// Is this ASCII byte "safe" (doesn't need escaping)?
45func he_is_safe(b: i64) -> i64 {
46 if b == 0x3C { return 0 } // '<'
47 if b == 0x3E { return 0 } // '>'
48 if b == 0x26 { return 0 } // '&'
49 if b == 0x22 { return 0 } // '"'
50 if b == 0x27 { return 0 } // "'"
51 return 1
52}
53
54// Write literal bytes src[0..n] into out at offset. Returns new
55// offset or HE_ERR_SHORT.
56func he_put(out: *u8, cap: i64, off: i64, src: *u8, n: i64) -> i64 {
57 if off + n > cap { return HE_ERR_SHORT }
58 var i: i64 = 0
59 while i < n {
60 out[off + i] = src[i]
61 i = i + 1
62 }
63 return off + n
64}
65
66// Encode src[0..n] into out. Returns bytes written or HE_ERR_SHORT.
67func html_escape(out: *u8, cap: i64, src: *u8, n: i64) -> i64 {
68 var off: i64 = 0
69 var i: i64 = 0
70 while i < n {
71 let b: i64 = src[i]
72 if he_is_safe(b) == 1 {
73 if off >= cap { return HE_ERR_SHORT }
74 out[off] = b
75 off = off + 1
76 }
77 if b == 0x3C {
78 off = he_put(out, cap, off, "<", 4)
79 if off < 0 { return off }
80 }
81 if b == 0x3E {
82 off = he_put(out, cap, off, ">", 4)
83 if off < 0 { return off }
84 }
85 if b == 0x26 {
86 off = he_put(out, cap, off, "&", 5)
87 if off < 0 { return off }
88 }
89 if b == 0x22 {
90 off = he_put(out, cap, off, """, 6)
91 if off < 0 { return off }
92 }
93 if b == 0x27 {
94 off = he_put(out, cap, off, "'", 5)
95 if off < 0 { return off }
96 }
97 i = i + 1
98 }
99 return off
100}
101
102// Helper: lowercase a hex digit.
103func he_hex_digit(b: i64) -> i64 {
104 if b >= 0x30 {
105 if b <= 0x39 { return b - 0x30 }
106 }
107 if b >= 0x61 {
108 if b <= 0x66 { return b - 0x61 + 10 }
109 }
110 if b >= 0x41 {
111 if b <= 0x46 { return b - 0x41 + 10 }
112 }
113 return -1
114}
115
116// Decode src[0..n] into out. Returns bytes written. Malformed
117// or unknown entities pass through literal per WHATWG tolerance.
118func html_unescape(out: *u8, cap: i64, src: *u8, n: i64) -> i64 {
119 var off: i64 = 0
120 var i: i64 = 0
121 while i < n {
122 if src[i] == 0x26 {
123 // '&' -- possible entity start.
124 // Find the terminating ';' within the next 8 bytes (any
125 // longer is almost certainly not an entity).
126 var end: i64 = -1
127 var j: i64 = i + 1
128 while j < n {
129 if src[j] == 0x3B {
130 end = j
131 break
132 }
133 if j - i > 8 { break }
134 j = j + 1
135 }
136 if end < 0 {
137 // No ';' -- pass literal '&'.
138 if off >= cap { return HE_ERR_SHORT }
139 out[off] = src[i]
140 off = off + 1
141 i = i + 1
142 continue
143 }
144
145 let entity_len: i64 = end - (i + 1)
146 var decoded: i64 = -1
147
148 // Numeric: &#NN; or &#xHH;
149 if entity_len >= 2 {
150 if src[i + 1] == 0x23 {
151 // '#'
152 var k: i64 = i + 2
153 var val: i64 = 0
154 var ok: i64 = 1
155 if src[k] == 0x78 {
156 // Hex
157 k = k + 1
158 while k < end {
159 let d: i64 = he_hex_digit(src[k])
160 if d < 0 { ok = 0; break }
161 val = val * 16 + d
162 k = k + 1
163 }
164 } else {
165 while k < end {
166 if src[k] < 0x30 { ok = 0; break }
167 if src[k] > 0x39 { ok = 0; break }
168 val = val * 10 + (src[k] - 0x30)
169 k = k + 1
170 }
171 }
172 if ok == 1 {
173 if val >= 0 {
174 if val <= 127 {
175 decoded = val
176 }
177 }
178 }
179 }
180 }
181
182 // Named entities -- just the five we produced.
183 if decoded < 0 {
184 if entity_len == 2 {
185 if src[i+1] == 0x6C { // 'l'
186 if src[i+2] == 0x74 { // 't'
187 decoded = 0x3C
188 }
189 }
190 if src[i+1] == 0x67 { // 'g'
191 if src[i+2] == 0x74 { // 't'
192 decoded = 0x3E
193 }
194 }
195 }
196 if entity_len == 3 {
197 if src[i+1] == 0x61 { // 'a'
198 if src[i+2] == 0x6D { // 'm'
199 if src[i+3] == 0x70 { // 'p'
200 decoded = 0x26
201 }
202 }
203 }
204 }
205 if entity_len == 4 {
206 if src[i+1] == 0x71 { // 'q'
207 if src[i+2] == 0x75 { // 'u'
208 if src[i+3] == 0x6F { // 'o'
209 if src[i+4] == 0x74 { // 't'
210 decoded = 0x22
211 }
212 }
213 }
214 }
215 if src[i+1] == 0x61 { // 'a'
216 if src[i+2] == 0x70 { // 'p'
217 if src[i+3] == 0x6F { // 'o'
218 if src[i+4] == 0x73 { // 's'
219 decoded = 0x27
220 }
221 }
222 }
223 }
224 }
225 }
226
227 if decoded >= 0 {
228 if off >= cap { return HE_ERR_SHORT }
229 out[off] = decoded
230 off = off + 1
231 i = end + 1
232 continue
233 }
234 // Unknown entity: pass '&' literal; caller sees the rest
235 // on the next loop iterations.
236 if off >= cap { return HE_ERR_SHORT }
237 out[off] = src[i]
238 off = off + 1
239 i = i + 1
240 continue
241 }
242 if off >= cap { return HE_ERR_SHORT }
243 out[off] = src[i]
244 off = off + 1
245 i = i + 1
246 }
247 return off
248}
249
250// Compile-only smoke: encode, decode, round-trip.
251func main() -> i64 {
252 let out: *u8 = sys_mmap(128)
253 let src: *u8 = "a<b&c>d\"e'f"
254 let n: i64 = html_escape(out, 128, src, 11)
255 // Expected: "a<b&c>d"e'f"
256 // a + 4 + b + 5 + c + 4 + d + 6 + e + 5 + f = 28
257 if n != 28 { return 1 }
258 if out[0] != 0x61 { return 2 } // 'a'
259 if out[1] != 0x26 { return 3 } // '&'
260
261 // Round-trip.
262 let out2: *u8 = sys_mmap(128)
263 let n2: i64 = html_unescape(out2, 128, out, n)
264 if n2 != 11 { return 4 }
265 // Verify equality.
266 var i: i64 = 0
267 while i < 11 {
268 if out2[i] != src[i] { return 5 }
269 i = i + 1
270 }
271
272 // Numeric entity decode: "A" -> "A".
273 let n3: i64 = html_unescape(out2, 128, "A", 5)
274 if n3 != 1 { return 6 }
275 if out2[0] != 0x41 { return 7 }
276
277 // Hex entity: "A" -> "A".
278 let n4: i64 = html_unescape(out2, 128, "A", 6)
279 if n4 != 1 { return 8 }
280 if out2[0] != 0x41 { return 9 }
281
282 // Unknown entity: "&unknown;" passes literal.
283 let n5: i64 = html_unescape(out2, 128, "&unknown;", 9)
284 if n5 != 9 { return 10 }
285
286 return 0
287}