nx_api_edge.nx source
↩ module page · 299 lines · 16999 B
1// nx_api_edge.nx -- the REAL s-class hardening edge for the live agent-facing API (nx_tools_api on :18096,
2// public as <domain>/api/tools + /mcp). WIRES the API-hardening capabilities that existed only as unwired
3// island primitives + a hardcoded-secret DRAFT gateway (nx_api_gateway) into ONE real HTTP middleware that
4// wraps ta_handle. NON-BREAKING by construction: it does NOT touch the cap-token execution auth (capt_verify
5// still guards tools/call) nor block the open read-only catalog -- it ADDS, per request:
6// * CORS deny-by-default (OPTIONS preflight -> 204; Access-Control-* only for allowlisted origins)
7// * security headers on every response (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, HSTS)
8// * a REAL token-bucket rate limit (monotonic-clock refill; 429 problem+json over budget) = DoS guard
9// * RFC 9457 application/problem+json for edge errors (429, 413)
10// This is the composition the apistack gates proved in isolation, now on the LIVE request path.
11// license_tier: ORIGINAL genealogy_id: international-research-sources/ietf/rfc_6454_cors + rfc_9457_problem
12import "nx_syscalls.nx"
13import "nx_tools_api.nx" // ta_handle + ta_reqline + ta_streq_n + ta_cat + ta_catn + ta_slen + ta_emit_lit
14const EDGE_MAGIC_1000000000: i64 = 1000000000
15const EDGE_MAGIC_1000000: i64 = 1000000
16
17const EDGE_RESP_CAP: i64 = 1048576
18// THE ONE REQUEST CEILING (2026-08-23). This was 65535, a hand copy of the serve loop's single-read WINDOW
19// ("matches the serve loop's read cap") -- two numbers for one envelope, and a read window mistaken for a
20// limit: every tools/call above 64 KiB answered 413 "the 64KiB edge limit", and the laptop wire rendered that
21// 413 as a bare {} so a whole-file source write read as "dropped". The serve loop now reads the WHOLE body by
22// Content-Length; the only ceiling left is this one, DERIVED: a request may be as large as the response
23// reserve this edge already declares (EDGE_RESP_CAP), memory the edge has already committed per request on
24// the way out. Above it the answer is 413 naming BOTH numbers; a body the peer cut short is 400 naming
25// got/need; a truncated request is never handed on as if it were the request.
26const EDGE_REQ_MAX: i64 = EDGE_RESP_CAP
27
28// Token bucket (per-process, monotonic-clock refill). Generous: 300 burst, 30/s sustained (1800/min) -- well
29// above any legit companion/UI rate, but caps a flood. Fixed-point milli-tokens to avoid fractional loss.
30const TB_CAP_MILLI: i64 = 300000 // 300 tokens * 1000
31const TB_RATE_PER_S: i64 = 30 // sustained refill tokens/second
32static tb_tokens_milli: i64
33static tb_last_ns: i64
34
35func edge_now_ns() -> i64 {
36 let ts: *i64 = sys_mmap(16) as *i64
37 __syscall(SYS_CLOCK_GETTIME, 1, ts as i64, 0, 0, 0, 0) // CLOCK_MONOTONIC
38 return ts[0] * EDGE_MAGIC_1000000000 + ts[1]
39}
40
41// Returns 1 if a token was available (allow), 0 if the bucket is empty (rate-limited).
42func edge_tb_allow() -> i64 {
43 let now: i64 = edge_now_ns()
44 if tb_last_ns == 0 { tb_last_ns = now; tb_tokens_milli = TB_CAP_MILLI }
45 let dt_ns: i64 = now - tb_last_ns
46 if dt_ns > 0 {
47 let refill_milli: i64 = (dt_ns * TB_RATE_PER_S) / EDGE_MAGIC_1000000 // (dt/1e9)*rate*1000
48 tb_tokens_milli = tb_tokens_milli + refill_milli
49 if tb_tokens_milli > TB_CAP_MILLI { tb_tokens_milli = TB_CAP_MILLI }
50 tb_last_ns = now
51 }
52 if tb_tokens_milli >= 1000 { tb_tokens_milli = tb_tokens_milli - 1000; return 1 }
53 return 0
54}
55
56func edge_cpy(dst: *u8, doff: i64, src: *u8, n: i64) -> i64 { var i: i64 = 0; while i < n { dst[doff + i] = src[i]; i = i + 1 } return doff + n }
57
58// Find a header value: scan for "\n" + name + ":" (headers are line-delimited); skip optional spaces; value runs
59// to the next \r or \n. Sets vo[0]=absolute offset into req, vo[1]=len. Returns 1 if found, 0 otherwise.
60// Header names are ASCII case-insensitive; values retain their original bytes.
61// Return occurrence count so security-sensitive callers can reject duplicates.
62func edge_header_lower(c: i64) -> i64 {
63 if c >= 65 { if c <= 90 { return c + 32 } }
64 return c
65}
66func edge_find_header(req: *u8, req_n: i64, name: *u8, name_len: i64, vo: *i64) -> i64 {
67 vo[0]=0; vo[1]=0
68 var start: i64 = 0
69 while start < req_n { if req[start] == (10 as u8) { start=start+1; break }; start=start+1 }
70 var count: i64 = 0
71 while start < req_n {
72 var end: i64 = start
73 while end < req_n { if req[end] == (10 as u8) { break }; end=end+1 }
74 var content_end: i64 = end
75 if content_end > start { if req[content_end-1] == (13 as u8) { content_end=content_end-1 } }
76 if content_end == start { return count }
77 if start+name_len < content_end {
78 var same: i64 = 1
79 var m: i64 = 0
80 while m < name_len {
81 if edge_header_lower(req[start+m] as i64) != edge_header_lower(name[m] as i64) { same=0 }
82 m=m+1
83 }
84 if same == 1 { if req[start+name_len] == (58 as u8) {
85 var p: i64 = start+name_len+1
86 while p < content_end { if req[p] != (32 as u8) { if req[p] != (9 as u8) { break } }; p=p+1 }
87 var v_end: i64 = content_end
88 while v_end > p { if req[v_end-1] != (32 as u8) { if req[v_end-1] != (9 as u8) { break } }; v_end=v_end-1 }
89 if count == 0 { vo[0]=p; vo[1]=v_end-p }
90 count=count+1
91 } }
92 }
93 start=end+1
94 }
95 return count
96}
97
98// Is the request Origin allowlisted? Deny-by-default. Ecosystem origins only. Sets vo to the origin bytes.
99func edge_origin_allowed(req: *u8, req_n: i64, vo: *i64) -> i64 {
100 if edge_find_header(req, req_n, "Origin" as *u8, 6, vo) != 1 { return 0 }
101 let o: *u8 = ((req as i64) + vo[0]) as *u8
102 let n: i64 = vo[1]
103 if ta_streq_n(o, n, "https://nishifamily.com" as *u8) == 1 { return 1 }
104 if ta_streq_n(o, n, "https://andelinwest.com" as *u8) == 1 { return 1 }
105 if ta_streq_n(o, n, "https://www.nishifamily.com" as *u8) == 1 { return 1 }
106 return 0
107}
108
109// Append the 4 always-on security headers (each ends with \r\n).
110func edge_sec_headers(out: *u8, o: i64) -> i64 {
111 o = ta_cat(out, o, "X-Content-Type-Options: nosniff\r\n" as *u8)
112 o = ta_cat(out, o, "X-Frame-Options: DENY\r\n" as *u8)
113 o = ta_cat(out, o, "Referrer-Policy: no-referrer\r\n" as *u8)
114 o = ta_cat(out, o, "Strict-Transport-Security: max-age=63072000; includeSubDomains\r\n" as *u8)
115 return o
116}
117
118// Inject security + (if allowed) CORS headers into an already-built response, right after its status line.
119func edge_inject(resp: *u8, resp_len: i64, out: *u8, req: *u8, req_n: i64) -> i64 {
120 // locate the end of the status line (first CRLF)
121 var p: i64 = 0
122 var found: i64 = 0
123 while p + 1 < resp_len { if resp[p] == (13 as u8) { if resp[p+1] == (10 as u8) { found = 1; break } } p = p + 1 }
124 if found == 0 { return edge_cpy(out, 0, resp, resp_len) } // malformed -> pass through untouched
125 var o: i64 = edge_cpy(out, 0, resp, p + 2) // status line incl CRLF
126 o = edge_sec_headers(out, o)
127 let vo: *i64 = sys_mmap(16) as *i64
128 if edge_origin_allowed(req, req_n, vo) == 1 {
129 o = ta_cat(out, o, "Access-Control-Allow-Origin: " as *u8)
130 o = edge_cpy(out, o, ((req as i64) + vo[0]) as *u8, vo[1])
131 o = ta_cat(out, o, "\r\nAccess-Control-Allow-Credentials: true\r\nVary: Origin\r\n" as *u8)
132 }
133 // rest of the original response (headers tail + body)
134 o = edge_cpy(out, o, ((resp as i64) + p + 2) as *u8, resp_len - (p + 2))
135 return o
136}
137
138// problem+json (RFC 9457) response builder.
139func edge_problem(out: *u8, status_line: *u8, ptype: *u8, title: *u8, status: i64, detail: *u8) -> i64 {
140 let jb: *u8 = sys_mmap(512)
141 var b: i64 = ta_cat(jb, 0, "{\"type\":\"" as *u8); b = ta_cat(jb, b, ptype)
142 b = ta_cat(jb, b, "\",\"title\":\"" as *u8); b = ta_cat(jb, b, title)
143 b = ta_cat(jb, b, "\",\"status\":" as *u8); b = ta_catn(jb, b, status)
144 b = ta_cat(jb, b, ",\"detail\":\"" as *u8); b = ta_cat(jb, b, detail); b = ta_cat(jb, b, "\"}" as *u8)
145 // build response with problem+json content type
146 var o: i64 = ta_cat(out, 0, "HTTP/1.1 " as *u8); o = ta_cat(out, o, status_line)
147 o = ta_cat(out, o, "\r\nContent-Type: application/problem+json\r\n" as *u8)
148 o = edge_sec_headers(out, o)
149 o = ta_cat(out, o, "Content-Length: " as *u8); o = ta_catn(out, o, b)
150 o = ta_cat(out, o, "\r\nConnection: close\r\n\r\n" as *u8)
151 o = edge_cpy(out, o, jb, b)
152 return o
153}
154
155// 413 naming both numbers: what the request declared and the ceiling it exceeded.
156func edge_problem_too_large(out: *u8, declared: i64, limit: i64) -> i64 {
157 let d: *u8 = sys_mmap(256)
158 var o: i64 = ta_cat(d, 0, "request of " as *u8); o = ta_catn(d, o, declared)
159 o = ta_cat(d, o, " bytes exceeds the edge limit of " as *u8); o = ta_catn(d, o, limit)
160 o = ta_cat(d, o, " bytes (EDGE_REQ_MAX, derived from the response reserve); body not read" as *u8)
161 return edge_problem(out, "413 Payload Too Large" as *u8, "about:blank" as *u8, "Payload Too Large" as *u8, 413, d)
162}
163// 400 naming got/need: the peer stopped sending before the bytes its own headers declared.
164func edge_problem_short_body(out: *u8, got: i64, need: i64) -> i64 {
165 let d: *u8 = sys_mmap(256)
166 var o: i64 = ta_cat(d, 0, "truncated request: received " as *u8); o = ta_catn(d, o, got)
167 o = ta_cat(d, o, " of the " as *u8); o = ta_catn(d, o, need)
168 o = ta_cat(d, o, " bytes declared (headers + Content-Length); not dispatched" as *u8)
169 return edge_problem(out, "400 Bad Request" as *u8, "about:blank" as *u8, "Bad Request" as *u8, 400, d)
170}
171
172// THE edge: hardened front for one request. Returns response length in `out` (cap EDGE_RESP_CAP).
173func nx_api_edge_handle(req: *u8, req_n: i64, out: *u8) -> i64 {
174 // 413: a request over the ONE ceiling -> reject cleanly, naming the numbers
175 if req_n > EDGE_REQ_MAX { return edge_problem_too_large(out, req_n, EDGE_REQ_MAX) }
176
177 let mo: *i64 = sys_mmap(16) as *i64
178 let po: *i64 = sys_mmap(16) as *i64
179 if ta_reqline(req, req_n, mo, po) == 0 {
180 return edge_problem(out, "400 Bad Request" as *u8, "about:blank" as *u8, "Bad Request" as *u8, 400, "malformed request line" as *u8)
181 }
182 let mp: *u8 = ((req as i64) + mo[0]) as *u8
183 let ml: i64 = mo[1]
184
185 // MCP origin enforcement happens before preflight or any tool dispatch.
186 let path: *u8 = ((req as i64)+po[0]) as *u8
187 var path_len: i64 = 0
188 while path_len < po[1] { if path[path_len] == (63 as u8) { break }; path_len=path_len+1 }
189 if ta_streq_n(path,path_len,"/mcp") == 1 {
190 let origin: *i64 = sys_mmap(16) as *i64
191 if edge_find_header(req,req_n,"Origin",6,origin) > 0 {
192 if edge_origin_allowed(req,req_n,origin) != 1 {
193 return edge_problem(out,"403 Forbidden","about:blank","Forbidden",403,"MCP origin is not uniquely allowlisted")
194 }
195 }
196 }
197
198 if ta_streq_n(path,path_len,"/mcp") == 1 {
199 let version: *i64 = sys_mmap(16) as *i64
200 let occurrences: i64 = edge_find_header(req,req_n,"MCP-Protocol-Version",20,version)
201 if occurrences > 1 { return edge_problem(out,"400 Bad Request","about:blank","Bad Request",400,"duplicate MCP protocol version") }
202 if occurrences == 1 {
203 let vp: *u8 = ((req as i64)+version[0]) as *u8
204 var supported: i64 = 0
205 if ta_streq_n(vp,version[1],"2024-11-05") == 1 { supported=1 }
206 if ta_streq_n(vp,version[1],"2025-03-26") == 1 { supported=1 }
207 if ta_streq_n(vp,version[1],"2025-06-18") == 1 { supported=1 }
208 if supported == 0 { return edge_problem(out,"400 Bad Request","about:blank","Bad Request",400,"unsupported MCP protocol version") }
209 }
210 }
211
212 // Other API routes retain their shared preflight behavior.
213 if ta_streq_n(mp, ml, "OPTIONS" as *u8) == 1 {
214 var o: i64 = ta_cat(out, 0, "HTTP/1.1 204 No Content\r\n" as *u8)
215 o = edge_sec_headers(out, o)
216 let vo: *i64 = sys_mmap(16) as *i64
217 if edge_origin_allowed(req, req_n, vo) == 1 {
218 o = ta_cat(out, o, "Access-Control-Allow-Origin: " as *u8)
219 o = edge_cpy(out, o, ((req as i64) + vo[0]) as *u8, vo[1])
220 o = ta_cat(out, o, "\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, X-Nishi-Cap, Authorization\r\nAccess-Control-Max-Age: 600\r\nVary: Origin\r\n" as *u8)
221 }
222 o = ta_cat(out, o, "Content-Length: 0\r\nConnection: close\r\n\r\n" as *u8)
223 return o
224 }
225
226 // Rate limit (real token bucket). Over budget -> 429 problem+json.
227 if edge_tb_allow() == 0 {
228 return edge_problem(out, "429 Too Many Requests" as *u8, "about:blank" as *u8, "Too Many Requests" as *u8, 429, "rate limit exceeded; retry shortly" as *u8)
229 }
230
231 // Dispatch to the real handler, then inject security + CORS headers into its response.
232 let inner: *u8 = sys_mmap(EDGE_RESP_CAP)
233 let rn: i64 = ta_handle(req, req_n, inner)
234 return edge_inject(inner, rn, out, req, req_n)
235}
236
237// ---- gate: real HTTP requests through the edge ----
238func edge_w(s: *u8) -> i64 { sys_write(1, s, ta_slen(s)); return 0 }
239func edge_row(name: *u8, ok: i64) -> i64 { if ok == 1 { edge_w(" PASS " as *u8) } else { edge_w(" FAIL " as *u8) } edge_w(name); edge_w("\n" as *u8); return ok }
240// substring search: does haystack[0..hn] contain needle?
241func edge_has(hay: *u8, hn: i64, needle: *u8) -> i64 {
242 let nn: i64 = ta_slen(needle)
243 if nn == 0 { return 1 }
244 var i: i64 = 0
245 while i + nn <= hn {
246 var m: i64 = 0; var ok: i64 = 1
247 while m < nn { if hay[i + m] != needle[m] { ok = 0; m = nn } else { m = m + 1 } }
248 if ok == 1 { return 1 }
249 i = i + 1
250 }
251 return 0
252}
253
254func main() -> i64 {
255 edge_w("nx_api_edge gate (CORS deny-by-default + security headers + token-bucket rate-limit + problem+json)\n" as *u8)
256 let out: *u8 = sys_mmap(EDGE_RESP_CAP)
257 var pass: i64 = 0
258
259 // T1: GET /api/tools with an allowlisted Origin -> 200 + security headers + ACAO reflects the origin
260 let r1: *u8 = "GET /api/tools HTTP/1.1\r\nHost: nishifamily.com\r\nOrigin: https://nishifamily.com\r\nConnection: close\r\n\r\n" as *u8
261 let l1: i64 = nx_api_edge_handle(r1, ta_slen(r1), out)
262 var t1: i64 = 1
263 if edge_has(out, l1, "200 OK" as *u8) == 0 { t1 = 0 }
264 if edge_has(out, l1, "X-Content-Type-Options: nosniff" as *u8) == 0 { t1 = 0 }
265 if edge_has(out, l1, "Strict-Transport-Security:" as *u8) == 0 { t1 = 0 }
266 if edge_has(out, l1, "Access-Control-Allow-Origin: https://nishifamily.com" as *u8) == 0 { t1 = 0 }
267 pass = pass + edge_row("T1 GET /api/tools + allowed Origin -> 200, security headers, ACAO reflected" as *u8, t1)
268
269 // T2: same GET with a DISALLOWED Origin -> 200 + security headers but NO Access-Control-Allow-Origin (deny)
270 let r2: *u8 = "GET /api/tools HTTP/1.1\r\nHost: nishifamily.com\r\nOrigin: https://evil.example\r\nConnection: close\r\n\r\n" as *u8
271 let l2: i64 = nx_api_edge_handle(r2, ta_slen(r2), out)
272 var t2: i64 = 1
273 if edge_has(out, l2, "200 OK" as *u8) == 0 { t2 = 0 }
274 if edge_has(out, l2, "X-Content-Type-Options: nosniff" as *u8) == 0 { t2 = 0 }
275 if edge_has(out, l2, "Access-Control-Allow-Origin:" as *u8) == 1 { t2 = 0 } // must be ABSENT
276 pass = pass + edge_row("T2 disallowed Origin -> 200 + headers but NO ACAO (CORS deny-by-default)" as *u8, t2)
277
278 // T3: OPTIONS preflight from an allowed origin -> 204 + Access-Control-Allow-Methods
279 let r3: *u8 = "OPTIONS /mcp HTTP/1.1\r\nHost: nishifamily.com\r\nOrigin: https://andelinwest.com\r\nAccess-Control-Request-Method: POST\r\nConnection: close\r\n\r\n" as *u8
280 let l3: i64 = nx_api_edge_handle(r3, ta_slen(r3), out)
281 var t3: i64 = 1
282 if edge_has(out, l3, "204 No Content" as *u8) == 0 { t3 = 0 }
283 if edge_has(out, l3, "Access-Control-Allow-Methods: GET, POST, OPTIONS" as *u8) == 0 { t3 = 0 }
284 if edge_has(out, l3, "Access-Control-Allow-Origin: https://andelinwest.com" as *u8) == 0 { t3 = 0 }
285 pass = pass + edge_row("T3 OPTIONS preflight (allowed origin) -> 204 + Allow-Methods + ACAO" as *u8, t3)
286
287 // T4: drain the 300-token burst directly (rapid, ~0 refill), then a real request must get 429 problem+json.
288 var i: i64 = 0
289 while i < 320 { let _d: i64 = edge_tb_allow(); i = i + 1 }
290 let l4: i64 = nx_api_edge_handle(r1, ta_slen(r1), out)
291 var t4: i64 = 1
292 if edge_has(out, l4, "429 Too Many Requests" as *u8) == 0 { t4 = 0 }
293 if edge_has(out, l4, "application/problem+json" as *u8) == 0 { t4 = 0 }
294 pass = pass + edge_row("T4 drained token bucket -> request gets 429 application/problem+json" as *u8, t4)
295
296 if pass == 4 { edge_w("NX-API-EDGE GATE GREEN 4/4 (hardening composed on the live request path)\n" as *u8); sys_exit(0) }
297 edge_w("NX-API-EDGE GATE RED\n" as *u8); sys_exit(1)
298 return 1
299}