code wiki / (root) / nx_html_escape.nx

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// < &lt; 6// > &gt; 7// & &amp; 8// " &quot; 9// ' &#39; (apostrophe -- &apos; 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 "&gt;" = 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, "&lt;", 4) 79 if off < 0 { return off } 80 } 81 if b == 0x3E { 82 off = he_put(out, cap, off, "&gt;", 4) 83 if off < 0 { return off } 84 } 85 if b == 0x26 { 86 off = he_put(out, cap, off, "&amp;", 5) 87 if off < 0 { return off } 88 } 89 if b == 0x22 { 90 off = he_put(out, cap, off, "&quot;", 6) 91 if off < 0 { return off } 92 } 93 if b == 0x27 { 94 off = he_put(out, cap, off, "&#39;", 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&lt;b&amp;c&gt;d&quot;e&#39;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: "&#65;" -> "A". 273 let n3: i64 = html_unescape(out2, 128, "&#65;", 5) 274 if n3 != 1 { return 6 } 275 if out2[0] != 0x41 { return 7 } 276 277 // Hex entity: "&#x41;" -> "A". 278 let n4: i64 = html_unescape(out2, 128, "&#x41;", 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}