nx_api_edge.nx source
↩ module page · 230 lines · 13025 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
18const EDGE_REQ_MAX: i64 = 65535 // matches the serve loop's read cap; >= this = truncated -> 413
19
20// Token bucket (per-process, monotonic-clock refill). Generous: 300 burst, 30/s sustained (1800/min) -- well
21// above any legit companion/UI rate, but caps a flood. Fixed-point milli-tokens to avoid fractional loss.
22const TB_CAP_MILLI: i64 = 300000 // 300 tokens * 1000
23const TB_RATE_PER_S: i64 = 30 // sustained refill tokens/second
24static tb_tokens_milli: i64
25static tb_last_ns: i64
26
27func edge_now_ns() -> i64 {
28 let ts: *i64 = sys_mmap(16) as *i64
29 __syscall(SYS_CLOCK_GETTIME, 1, ts as i64, 0, 0, 0, 0) // CLOCK_MONOTONIC
30 return ts[0] * EDGE_MAGIC_1000000000 + ts[1]
31}
32
33// Returns 1 if a token was available (allow), 0 if the bucket is empty (rate-limited).
34func edge_tb_allow() -> i64 {
35 let now: i64 = edge_now_ns()
36 if tb_last_ns == 0 { tb_last_ns = now; tb_tokens_milli = TB_CAP_MILLI }
37 let dt_ns: i64 = now - tb_last_ns
38 if dt_ns > 0 {
39 let refill_milli: i64 = (dt_ns * TB_RATE_PER_S) / EDGE_MAGIC_1000000 // (dt/1e9)*rate*1000
40 tb_tokens_milli = tb_tokens_milli + refill_milli
41 if tb_tokens_milli > TB_CAP_MILLI { tb_tokens_milli = TB_CAP_MILLI }
42 tb_last_ns = now
43 }
44 if tb_tokens_milli >= 1000 { tb_tokens_milli = tb_tokens_milli - 1000; return 1 }
45 return 0
46}
47
48func 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 }
49
50// Find a header value: scan for "\n" + name + ":" (headers are line-delimited); skip optional spaces; value runs
51// to the next \r or \n. Sets vo[0]=absolute offset into req, vo[1]=len. Returns 1 if found, 0 otherwise.
52func edge_find_header(req: *u8, req_n: i64, name: *u8, name_len: i64, vo: *i64) -> i64 {
53 var i: i64 = 0
54 while i + 1 + name_len + 1 < req_n {
55 if req[i] == (10 as u8) { // start of a header line
56 var m: i64 = 0
57 var matched: i64 = 1
58 while m < name_len { if req[i + 1 + m] != name[m] { matched = 0; m = name_len } else { m = m + 1 } }
59 if matched == 1 { if (req[i + 1 + name_len] & 0xff) == 0x3A { // ':'
60 var p: i64 = i + 1 + name_len + 1
61 while p < req_n { if (req[p] & 0xff) == 0x20 { p = p + 1 } else { break } }
62 var e: i64 = p
63 while e < req_n { let c: i64 = req[e] & 0xff; if c == 13 { break } if c == 10 { break } e = e + 1 }
64 vo[0] = p; vo[1] = e - p
65 return 1
66 } }
67 }
68 i = i + 1
69 }
70 return 0
71}
72
73// Is the request Origin allowlisted? Deny-by-default. Ecosystem origins only. Sets vo to the origin bytes.
74func edge_origin_allowed(req: *u8, req_n: i64, vo: *i64) -> i64 {
75 if edge_find_header(req, req_n, "Origin" as *u8, 6, vo) == 0 { return 0 }
76 let o: *u8 = ((req as i64) + vo[0]) as *u8
77 let n: i64 = vo[1]
78 if ta_streq_n(o, n, "https://nishifamily.com" as *u8) == 1 { return 1 }
79 if ta_streq_n(o, n, "https://andelinwest.com" as *u8) == 1 { return 1 }
80 if ta_streq_n(o, n, "https://www.nishifamily.com" as *u8) == 1 { return 1 }
81 return 0
82}
83
84// Append the 4 always-on security headers (each ends with \r\n).
85func edge_sec_headers(out: *u8, o: i64) -> i64 {
86 o = ta_cat(out, o, "X-Content-Type-Options: nosniff\r\n" as *u8)
87 o = ta_cat(out, o, "X-Frame-Options: DENY\r\n" as *u8)
88 o = ta_cat(out, o, "Referrer-Policy: no-referrer\r\n" as *u8)
89 o = ta_cat(out, o, "Strict-Transport-Security: max-age=63072000; includeSubDomains\r\n" as *u8)
90 return o
91}
92
93// Inject security + (if allowed) CORS headers into an already-built response, right after its status line.
94func edge_inject(resp: *u8, resp_len: i64, out: *u8, req: *u8, req_n: i64) -> i64 {
95 // locate the end of the status line (first CRLF)
96 var p: i64 = 0
97 var found: i64 = 0
98 while p + 1 < resp_len { if resp[p] == (13 as u8) { if resp[p+1] == (10 as u8) { found = 1; break } } p = p + 1 }
99 if found == 0 { return edge_cpy(out, 0, resp, resp_len) } // malformed -> pass through untouched
100 var o: i64 = edge_cpy(out, 0, resp, p + 2) // status line incl CRLF
101 o = edge_sec_headers(out, o)
102 let vo: *i64 = sys_mmap(16) as *i64
103 if edge_origin_allowed(req, req_n, vo) == 1 {
104 o = ta_cat(out, o, "Access-Control-Allow-Origin: " as *u8)
105 o = edge_cpy(out, o, ((req as i64) + vo[0]) as *u8, vo[1])
106 o = ta_cat(out, o, "\r\nAccess-Control-Allow-Credentials: true\r\nVary: Origin\r\n" as *u8)
107 }
108 // rest of the original response (headers tail + body)
109 o = edge_cpy(out, o, ((resp as i64) + p + 2) as *u8, resp_len - (p + 2))
110 return o
111}
112
113// problem+json (RFC 9457) response builder.
114func edge_problem(out: *u8, status_line: *u8, ptype: *u8, title: *u8, status: i64, detail: *u8) -> i64 {
115 let jb: *u8 = sys_mmap(512)
116 var b: i64 = ta_cat(jb, 0, "{\"type\":\"" as *u8); b = ta_cat(jb, b, ptype)
117 b = ta_cat(jb, b, "\",\"title\":\"" as *u8); b = ta_cat(jb, b, title)
118 b = ta_cat(jb, b, "\",\"status\":" as *u8); b = ta_catn(jb, b, status)
119 b = ta_cat(jb, b, ",\"detail\":\"" as *u8); b = ta_cat(jb, b, detail); b = ta_cat(jb, b, "\"}" as *u8)
120 // build response with problem+json content type
121 var o: i64 = ta_cat(out, 0, "HTTP/1.1 " as *u8); o = ta_cat(out, o, status_line)
122 o = ta_cat(out, o, "\r\nContent-Type: application/problem+json\r\n" as *u8)
123 o = edge_sec_headers(out, o)
124 o = ta_cat(out, o, "Content-Length: " as *u8); o = ta_catn(out, o, b)
125 o = ta_cat(out, o, "\r\nConnection: close\r\n\r\n" as *u8)
126 o = edge_cpy(out, o, jb, b)
127 return o
128}
129
130// THE edge: hardened front for one request. Returns response length in `out` (cap EDGE_RESP_CAP).
131func nx_api_edge_handle(req: *u8, req_n: i64, out: *u8) -> i64 {
132 // 413: request at/over the read cap (possibly truncated) -> reject cleanly
133 if req_n >= EDGE_REQ_MAX { return edge_problem(out, "413 Payload Too Large" as *u8, "about:blank" as *u8, "Payload Too Large" as *u8, 413, "request exceeds the 64KiB edge limit" as *u8) }
134
135 let mo: *i64 = sys_mmap(16) as *i64
136 let po: *i64 = sys_mmap(16) as *i64
137 if ta_reqline(req, req_n, mo, po) == 0 {
138 return edge_problem(out, "400 Bad Request" as *u8, "about:blank" as *u8, "Bad Request" as *u8, 400, "malformed request line" as *u8)
139 }
140 let mp: *u8 = ((req as i64) + mo[0]) as *u8
141 let ml: i64 = mo[1]
142
143 // CORS preflight: OPTIONS -> 204 with the negotiated Access-Control-* (deny-by-default origin).
144 if ta_streq_n(mp, ml, "OPTIONS" as *u8) == 1 {
145 var o: i64 = ta_cat(out, 0, "HTTP/1.1 204 No Content\r\n" as *u8)
146 o = edge_sec_headers(out, o)
147 let vo: *i64 = sys_mmap(16) as *i64
148 if edge_origin_allowed(req, req_n, vo) == 1 {
149 o = ta_cat(out, o, "Access-Control-Allow-Origin: " as *u8)
150 o = edge_cpy(out, o, ((req as i64) + vo[0]) as *u8, vo[1])
151 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)
152 }
153 o = ta_cat(out, o, "Content-Length: 0\r\nConnection: close\r\n\r\n" as *u8)
154 return o
155 }
156
157 // Rate limit (real token bucket). Over budget -> 429 problem+json.
158 if edge_tb_allow() == 0 {
159 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)
160 }
161
162 // Dispatch to the real handler, then inject security + CORS headers into its response.
163 let inner: *u8 = sys_mmap(EDGE_RESP_CAP)
164 let rn: i64 = ta_handle(req, req_n, inner)
165 return edge_inject(inner, rn, out, req, req_n)
166}
167
168// ---- gate: real HTTP requests through the edge ----
169func edge_w(s: *u8) -> i64 { sys_write(1, s, ta_slen(s)); return 0 }
170func 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 }
171// substring search: does haystack[0..hn] contain needle?
172func edge_has(hay: *u8, hn: i64, needle: *u8) -> i64 {
173 let nn: i64 = ta_slen(needle)
174 if nn == 0 { return 1 }
175 var i: i64 = 0
176 while i + nn <= hn {
177 var m: i64 = 0; var ok: i64 = 1
178 while m < nn { if hay[i + m] != needle[m] { ok = 0; m = nn } else { m = m + 1 } }
179 if ok == 1 { return 1 }
180 i = i + 1
181 }
182 return 0
183}
184
185func main() -> i64 {
186 edge_w("nx_api_edge gate (CORS deny-by-default + security headers + token-bucket rate-limit + problem+json)\n" as *u8)
187 let out: *u8 = sys_mmap(EDGE_RESP_CAP)
188 var pass: i64 = 0
189
190 // T1: GET /api/tools with an allowlisted Origin -> 200 + security headers + ACAO reflects the origin
191 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
192 let l1: i64 = nx_api_edge_handle(r1, ta_slen(r1), out)
193 var t1: i64 = 1
194 if edge_has(out, l1, "200 OK" as *u8) == 0 { t1 = 0 }
195 if edge_has(out, l1, "X-Content-Type-Options: nosniff" as *u8) == 0 { t1 = 0 }
196 if edge_has(out, l1, "Strict-Transport-Security:" as *u8) == 0 { t1 = 0 }
197 if edge_has(out, l1, "Access-Control-Allow-Origin: https://nishifamily.com" as *u8) == 0 { t1 = 0 }
198 pass = pass + edge_row("T1 GET /api/tools + allowed Origin -> 200, security headers, ACAO reflected" as *u8, t1)
199
200 // T2: same GET with a DISALLOWED Origin -> 200 + security headers but NO Access-Control-Allow-Origin (deny)
201 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
202 let l2: i64 = nx_api_edge_handle(r2, ta_slen(r2), out)
203 var t2: i64 = 1
204 if edge_has(out, l2, "200 OK" as *u8) == 0 { t2 = 0 }
205 if edge_has(out, l2, "X-Content-Type-Options: nosniff" as *u8) == 0 { t2 = 0 }
206 if edge_has(out, l2, "Access-Control-Allow-Origin:" as *u8) == 1 { t2 = 0 } // must be ABSENT
207 pass = pass + edge_row("T2 disallowed Origin -> 200 + headers but NO ACAO (CORS deny-by-default)" as *u8, t2)
208
209 // T3: OPTIONS preflight from an allowed origin -> 204 + Access-Control-Allow-Methods
210 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
211 let l3: i64 = nx_api_edge_handle(r3, ta_slen(r3), out)
212 var t3: i64 = 1
213 if edge_has(out, l3, "204 No Content" as *u8) == 0 { t3 = 0 }
214 if edge_has(out, l3, "Access-Control-Allow-Methods: GET, POST, OPTIONS" as *u8) == 0 { t3 = 0 }
215 if edge_has(out, l3, "Access-Control-Allow-Origin: https://andelinwest.com" as *u8) == 0 { t3 = 0 }
216 pass = pass + edge_row("T3 OPTIONS preflight (allowed origin) -> 204 + Allow-Methods + ACAO" as *u8, t3)
217
218 // T4: drain the 300-token burst directly (rapid, ~0 refill), then a real request must get 429 problem+json.
219 var i: i64 = 0
220 while i < 320 { let _d: i64 = edge_tb_allow(); i = i + 1 }
221 let l4: i64 = nx_api_edge_handle(r1, ta_slen(r1), out)
222 var t4: i64 = 1
223 if edge_has(out, l4, "429 Too Many Requests" as *u8) == 0 { t4 = 0 }
224 if edge_has(out, l4, "application/problem+json" as *u8) == 0 { t4 = 0 }
225 pass = pass + edge_row("T4 drained token bucket -> request gets 429 application/problem+json" as *u8, t4)
226
227 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) }
228 edge_w("NX-API-EDGE GATE RED\n" as *u8); sys_exit(1)
229 return 1
230}