code wiki / (root) / nx_api_edge.nx

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}