nx_http_template.nx source
↩ module page · 292 lines · 10688 B
1// nx_http_template.nx -- sealed-enum context-typed HTML templates.
2//
3// Production-class template engine with STRUCTURAL XSS prevention.
4// Every variable substitution flows through nx_html_render's
5// typed-context escape primitive (which closes CWE-79 at the
6// substrate boundary). Substrate doesn't trust callers to remember
7// to escape -- the template syntax FORCES context declaration.
8//
9// Template syntax:
10//
11// <h1>Hello, {{ name }}!</h1> (default TEXT context)
12// <a href="{{ url | url }}">click</a> (URL context)
13// <div class="{{ cls | attr }}">... (ATTR_DQ context)
14// <p>{{ html_fragment | raw }}</p> (RAW; audited bypass)
15//
16// Unknown modifiers reject the entire template (BAD_MODIFIER verdict).
17// This makes "I forgot to escape" structurally impossible.
18//
19// Per cardinal feedback-no-third-party-trust-native-or-nothing:
20// substrate's own template engine; no Twig, no Handlebars, no
21// Jinja, no Mustache. ~300 LOC; auditable.
22//
23// Per cardinal feedback-defensive-at-boundaries-trusting-internally:
24// the template VAR LOOKUP is at the boundary; once a variable is
25// substituted with the correct escape, the resulting bytes are
26// trusted as safe HTML.
27//
28// nx_capability_claims:
29// needs: [sealed_enum, byte_ops, html_render_typed_context]
30// provides: [template_emit_with_context_typed_substitution,
31// structural_xss_prevention_at_template_layer]
32// safety: [no_unchecked_deref, no_floating_point, no_syscall,
33// bounded_iteration, xss_prevented_by_construction]
34// verdict: [sealed_enum_6_state]
35// license: ORIGINAL
36// kind: racing_crew_specialist
37// layer: L4 (composite over L2 nx_html_render)
38
39import "nx_syscalls_x86_64.nx"
40import "nx_html_render.nx"
41
42// ---- Sealed enum: template verdict -------------------------------
43
44const NXT_OK: i64 = 0
45const NXT_OOM_BUFFER: i64 = 1
46const NXT_BAD_TEMPLATE: i64 = 2 // missing }} or other syntax error
47const NXT_BAD_MODIFIER: i64 = 3 // unknown | modifier
48const NXT_VAR_NOT_FOUND: i64 = 4 // template references unknown var
49const NXT_BAD_ARG: i64 = 5
50const NXT_VERDICT_N: i64 = 6
51
52func nxt_verdict_is_valid(v: i64) -> i64 {
53 if v < 0 { return 0 }
54 if v >= NXT_VERDICT_N { return 0 }
55 return 1
56}
57
58func nxt_verdict_name(v: i64) -> *u8 {
59 if v == NXT_OK { return "OK" as *u8 }
60 if v == NXT_OOM_BUFFER { return "OOM_BUFFER" as *u8 }
61 if v == NXT_BAD_TEMPLATE { return "BAD_TEMPLATE" as *u8 }
62 if v == NXT_BAD_MODIFIER { return "BAD_MODIFIER" as *u8 }
63 if v == NXT_VAR_NOT_FOUND { return "VAR_NOT_FOUND" as *u8 }
64 if v == NXT_BAD_ARG { return "BAD_ARG" as *u8 }
65 return "INVALID" as *u8
66}
67
68// ---- Var table struct --------------------------------------------
69//
70// Caller-allocated. Parallel arrays for var_name + var_value.
71// Lookup is linear scan -- substrate's templates are small enough
72// (typically < 50 vars per render) for this to be fine.
73
74struct NxTemplateVars {
75 n: i64,
76 names: *u8,
77 name_offs: *i64,
78 name_lens: *i64,
79 values: *u8,
80 value_offs: *i64,
81 value_lens: *i64,
82}
83
84const NX_TEMPLATE_VARS_BYTES: i64 = 56
85
86func nx_template_vars_init(
87 vars: *NxTemplateVars,
88 names: *u8, name_offs: *i64, name_lens: *i64,
89 values: *u8, value_offs: *i64, value_lens: *i64,
90 n: i64) -> i64 {
91 if vars == (0 as *NxTemplateVars) { return NXT_BAD_ARG }
92 if n < 0 { return NXT_BAD_ARG }
93 vars.n = n
94 vars.names = names
95 vars.name_offs = name_offs
96 vars.name_lens = name_lens
97 vars.values = values
98 vars.value_offs = value_offs
99 vars.value_lens = value_lens
100 return NXT_OK
101}
102
103// ---- Var lookup --------------------------------------------------
104//
105// Returns 1 if found + sets *out_off / *out_len. Returns 0 if absent.
106
107func nxt_find_var(vars: *NxTemplateVars,
108 name: *u8, name_n: i64,
109 out_off: *i64, out_len: *i64) -> i64 {
110 if vars == (0 as *NxTemplateVars) { return 0 }
111 var i: i64 = 0
112 while i < vars.n {
113 let ln: i64 = vars.name_lens[i]
114 if ln == name_n {
115 let lo: i64 = vars.name_offs[i]
116 var eq: i64 = 1
117 var j: i64 = 0
118 while j < ln {
119 if vars.names[lo + j] != name[j] { eq = 0; j = ln }
120 j = j + 1
121 }
122 if eq == 1 {
123 *out_off = vars.value_offs[i]
124 *out_len = vars.value_lens[i]
125 return 1
126 }
127 }
128 i = i + 1
129 }
130 return 0
131}
132
133// ---- Whitespace + identifier scanning ----------------------------
134
135func nxt_is_space(b: i64) -> i64 {
136 if b == 0x20 { return 1 }
137 if b == 0x09 { return 1 }
138 return 0
139}
140
141func nxt_is_ident_char(b: i64) -> i64 {
142 if b >= 0x30 && b <= 0x39 { return 1 } // 0-9
143 if b >= 0x41 && b <= 0x5a { return 1 } // A-Z
144 if b >= 0x61 && b <= 0x7a { return 1 } // a-z
145 if b == 0x5f { return 1 } // _
146 if b == 0x2e { return 1 } // . (allow dotted names like page.title)
147 return 0
148}
149
150func nxt_skip_ws(tmpl: *u8, end: i64, start: i64) -> i64 {
151 var p: i64 = start
152 while p < end {
153 if nxt_is_space(tmpl[p] as i64) == 0 { return p }
154 p = p + 1
155 }
156 return end
157}
158
159// Scan an identifier; returns offset of the byte AFTER the identifier.
160func nxt_scan_ident(tmpl: *u8, end: i64, start: i64) -> i64 {
161 var p: i64 = start
162 while p < end {
163 if nxt_is_ident_char(tmpl[p] as i64) == 0 { return p }
164 p = p + 1
165 }
166 return end
167}
168
169func nxt_bytes_eq(a: *u8, b: *u8, n: i64) -> i64 {
170 var i: i64 = 0
171 while i < n {
172 if a[i] != b[i] { return 0 }
173 i = i + 1
174 }
175 return 1
176}
177
178// ---- Modifier name -> nx_html_render context ---------------------
179//
180// Returns NXH_CTX_* value, or -1 if unknown.
181
182func nxt_modifier_to_ctx(mod: *u8, mod_n: i64) -> i64 {
183 if mod_n == 4 {
184 if nxt_bytes_eq(mod, "text" as *u8, 4) == 1 { return NXH_CTX_TEXT }
185 if nxt_bytes_eq(mod, "attr" as *u8, 4) == 1 { return NXH_CTX_ATTR_DQ }
186 }
187 if mod_n == 3 {
188 if nxt_bytes_eq(mod, "url" as *u8, 3) == 1 { return NXH_CTX_URL }
189 if nxt_bytes_eq(mod, "raw" as *u8, 3) == 1 { return NXH_CTX_RAW }
190 }
191 return -1
192}
193
194// ---- Template render --------------------------------------------
195//
196// Walks template[0..tmpl_n], copying literal bytes to out and
197// substituting {{ var_name [| modifier] }} via nx_html_render.
198//
199// Caller pattern:
200//
201// let vars: *NxTemplateVars = ...
202// nx_template_vars_init(vars, ...)
203// nx_http_template_render(tmpl, tmpl_n, vars, out, out_cap, &out_n)
204
205func nx_http_template_render(
206 tmpl: *u8, tmpl_n: i64,
207 vars: *NxTemplateVars,
208 out: *u8, out_cap: i64,
209 out_n: *i64) -> i64 {
210 if tmpl == (0 as *u8) { return NXT_BAD_ARG }
211 if vars == (0 as *NxTemplateVars) { return NXT_BAD_ARG }
212 if out == (0 as *u8) { return NXT_BAD_ARG }
213 if out_n == (0 as *i64) { return NXT_BAD_ARG }
214 if tmpl_n < 0 { return NXT_BAD_ARG }
215 if out_cap <= 0 { return NXT_BAD_ARG }
216
217 var p: i64 = 0
218 var off: i64 = 0
219 while p < tmpl_n {
220 // Detect `{{` opener.
221 if p + 1 < tmpl_n {
222 if tmpl[p] == 0x7b as u8 {
223 if tmpl[p + 1] == 0x7b as u8 {
224 // Found `{{`. Parse: ws? ident ws? (`|` ws? mod ws?)? `}}`
225 let after_open: i64 = nxt_skip_ws(tmpl, tmpl_n, p + 2)
226 if after_open >= tmpl_n { return NXT_BAD_TEMPLATE }
227 let name_end: i64 = nxt_scan_ident(tmpl, tmpl_n, after_open)
228 if name_end == after_open { return NXT_BAD_TEMPLATE }
229 let name_ptr: *u8 = ((tmpl as i64) + after_open) as *u8
230 let name_len: i64 = name_end - after_open
231 // Optional modifier.
232 var ctx: i64 = NXH_CTX_TEXT
233 var after_mod: i64 = nxt_skip_ws(tmpl, tmpl_n, name_end)
234 if after_mod >= tmpl_n { return NXT_BAD_TEMPLATE }
235 if tmpl[after_mod] == 0x7c as u8 {
236 // `|` -- modifier.
237 let mod_start: i64 = nxt_skip_ws(tmpl, tmpl_n, after_mod + 1)
238 if mod_start >= tmpl_n { return NXT_BAD_TEMPLATE }
239 let mod_end: i64 = nxt_scan_ident(tmpl, tmpl_n, mod_start)
240 let mod_ptr: *u8 = ((tmpl as i64) + mod_start) as *u8
241 let mod_n: i64 = mod_end - mod_start
242 let ctx_id: i64 = nxt_modifier_to_ctx(mod_ptr, mod_n)
243 if ctx_id < 0 { return NXT_BAD_MODIFIER }
244 ctx = ctx_id
245 after_mod = nxt_skip_ws(tmpl, tmpl_n, mod_end)
246 }
247 // Expect `}}`.
248 if after_mod + 2 > tmpl_n { return NXT_BAD_TEMPLATE }
249 if tmpl[after_mod] != 0x7d as u8 { return NXT_BAD_TEMPLATE }
250 if tmpl[after_mod + 1] != 0x7d as u8 { return NXT_BAD_TEMPLATE }
251 let after_close: i64 = after_mod + 2
252
253 // Resolve var.
254 let v_off: *i64 = sys_mmap(8) as *i64
255 let v_len: *i64 = sys_mmap(8) as *i64
256 if nxt_find_var(vars, name_ptr, name_len, v_off, v_len) != 1 {
257 return NXT_VAR_NOT_FOUND
258 }
259 let v_ptr: *u8 = ((vars.values as i64) + v_off[0]) as *u8
260
261 // Substitute via nx_html_render typed-context escape.
262 let esc_rc: i64 = nx_html_escape(out, &off, out_cap,
263 v_ptr, v_len[0], ctx)
264 if esc_rc != NXH_OK { return NXT_OOM_BUFFER }
265
266 p = after_close
267 } else {
268 // Single `{`; literal.
269 if off >= out_cap { return NXT_OOM_BUFFER }
270 out[off] = tmpl[p]
271 off = off + 1
272 p = p + 1
273 }
274 } else {
275 if off >= out_cap { return NXT_OOM_BUFFER }
276 out[off] = tmpl[p]
277 off = off + 1
278 p = p + 1
279 }
280 } else {
281 if off >= out_cap { return NXT_OOM_BUFFER }
282 out[off] = tmpl[p]
283 off = off + 1
284 p = p + 1
285 }
286 }
287 *out_n = off
288 return NXT_OK
289}
290
291// (sys_mmap used internally for nxt_find_var's out-pointer scratch;
292// imported at top of file.)