code wiki / _hdl_build / nx_site_intake.nx

nx_site_intake.nx source

↩ module page · 221 lines · 9850 B

1// nx_site_intake.nx -- PUBLIC contact-intake route core for GENERATED SITES (uigen lane, debt 1785934266; 2// the buildable half of R0a "a dead form is a fake"). PURE CORE, NO MAIN -- the nx_office_serve/ 3// nx_survey_serve discipline: a host daemon mounts si_route behind the sovereign edge, and that mount + 4// the edge route row are the coordinated deployment hook, NOT this file. Once mounted, a generated 5// site's req names contact|action|/intake/<slug> and the T15 form goes live with ZERO emitter rebuild. 6// PUBLIC + ANONYMOUS => hostile by default (rule 12): 7// - SI_MAX_BODY cap at the door (the oversized-proxied-POST class that once destabilized sites.elf) 8// - honeypot `website` field: a filled honeypot gets the SAME 303 as success and records NOTHING 9// (a rejection a bot can measure is a rejection it can learn around) 10// - plane-grammar defense: TAB/CR/LF in decoded fields become spaces BEFORE the append (the clockjobs 11// writer-vs-plane lesson enforced at write time, gate-proven) 12// - `back` redirect is PATH-ONLY (leading '/', no ':', no '//') so no open redirect; else "/" 13// Rows land append-only on the caller-supplied plane as id<TAB>slug<TAB>name<TAB>email<TAB>message 14// (nx_store_put-load readable; id=epoch-sec -- the BYTES are the ledger, a load view collapses 15// same-second ids). Caller supplies plane + log paths: the gate passes per-run /tmp fixtures 16// (idempotent-fixture law), the real host passes knowledge/store/siteintake- + 17// knowledge/status/site_intake.log. A message longer than SI_MAX_MSG reads as absent (the form must 18// carry maxlength); only RECORDED submissions write a log line, so a honeypot flood cannot grow the 19// status log. No floats, no hardware writes, nothing here binds a socket. license_tier: ORIGINAL 20import "nx_syscalls.nx" 21import "nx_http_form.nx" 22 23const SI_MAX_BODY: i64 = 4096 24const SI_MAX_FIELD: i64 = 200 25const SI_MAX_MSG: i64 = 1000 26const SI_MAX_SLUG: i64 = 40 27const SI_ROW: i64 = 2048 28const SI_SCRATCH: i64 = 256 29const SI_MSGBUF: i64 = 1032 30const SI_HPBUF: i64 = 128 31const SI_SLUGBUF: i64 = 64 32const SI_TMP: i64 = 28 33const SI_MODE: i64 = 420 34const SI_TEN: i64 = 10 35const SI_B_TAB: i64 = 9 36const SI_B_LF: i64 = 10 37const SI_B_CR: i64 = 13 38const SI_B_SP: i64 = 32 39const SI_B_DASH: i64 = 45 40const SI_B_SLASH: i64 = 47 41const SI_B_COLON: i64 = 58 42const SI_B_0: i64 = 48 43const SI_B_9: i64 = 57 44const SI_B_A: i64 = 97 45const SI_B_Z: i64 = 122 46 47func si_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 48func si_cat(dst: *u8, off: i64, s: *u8) -> i64 { 49 var o: i64 = off 50 var i: i64 = 0 51 while s[i] != (0 as u8) { dst[o] = s[i]; o = o + 1; i = i + 1 } 52 return o 53} 54func si_putn(buf: *u8, off: i64, v: i64) -> i64 { 55 if v == 0 { buf[off] = SI_B_0 as u8; return off + 1 } 56 var m: i64 = v 57 var o: i64 = off 58 if m < 0 { buf[o] = SI_B_DASH as u8; o = o + 1; m = 0 - m } 59 let t: *u8 = sys_mmap(SI_TMP) 60 var k: i64 = 0 61 while m > 0 { t[k] = (SI_B_0 + (m % SI_TEN)) as u8; m = m / SI_TEN; k = k + 1 } 62 while k > 0 { k = k - 1; buf[o] = t[k]; o = o + 1 } 63 return o 64} 65func si_starts(buf: *u8, n: i64, lit: *u8) -> i64 { 66 let ll: i64 = si_len(lit) 67 if n < ll { return 0 } 68 var i: i64 = 0 69 while i < ll { if buf[i] != lit[i] { return 0 } i = i + 1 } 70 return 1 71} 72// plane-grammar defense: the row grammar's separator bytes can never arrive from user input 73func si_sanitize(s: *u8) -> i64 { 74 var i: i64 = 0 75 while s[i] != (0 as u8) { 76 if s[i] == (SI_B_TAB as u8) { s[i] = SI_B_SP as u8 } 77 if s[i] == (SI_B_CR as u8) { s[i] = SI_B_SP as u8 } 78 if s[i] == (SI_B_LF as u8) { s[i] = SI_B_SP as u8 } 79 i = i + 1 80 } 81 return 0 82} 83// decoded + sanitized form field; returns value length (0 = absent/empty/overlong) 84func si_field(body: *u8, bn: i64, name: *u8, out: *u8, cap: i64) -> i64 { 85 let vl: *i64 = sys_mmap(16) as *i64 86 vl[0] = 0 87 nx_http_form_get_field(body, bn, name, si_len(name), out, cap, vl) 88 if vl[0] <= 0 { out[0] = 0 as u8; return 0 } 89 var n: i64 = vl[0] 90 if n >= cap { n = cap - 1 } 91 out[n] = 0 as u8 92 si_sanitize(out) 93 return n 94} 95// PATH-ONLY redirect target: leading '/', not '//', no ':' and no spaces anywhere; else refused 96func si_back_ok(b: *u8) -> i64 { 97 if b[0] != (SI_B_SLASH as u8) { return 0 } 98 if b[1] == (SI_B_SLASH as u8) { return 0 } 99 var i: i64 = 0 100 while b[i] != (0 as u8) { 101 if b[i] == (SI_B_COLON as u8) { return 0 } 102 if b[i] == (SI_B_SP as u8) { return 0 } 103 i = i + 1 104 } 105 return 1 106} 107// slug: [a-z0-9-], 1..SI_MAX_SLUG bytes -- anything else is not a site of ours 108func si_slug_ok(s: *u8) -> i64 { 109 let n: i64 = si_len(s) 110 if n < 1 { return 0 } 111 if n > SI_MAX_SLUG { return 0 } 112 var i: i64 = 0 113 while i < n { 114 let c: i64 = s[i] as i64 115 var ok: i64 = 0 116 if c >= SI_B_A { if c <= SI_B_Z { ok = 1 } } 117 if c >= SI_B_0 { if c <= SI_B_9 { ok = 1 } } 118 if c == SI_B_DASH { ok = 1 } 119 if ok == 0 { return 0 } 120 i = i + 1 121 } 122 return 1 123} 124func si_resp(resp: *u8, line: *u8) -> i64 { 125 var o: i64 = si_cat(resp, 0, line) 126 o = si_cat(resp, o, "\r\nContent-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" as *u8) 127 return o 128} 129func si_resp_303(resp: *u8, back: *u8) -> i64 { 130 var o: i64 = si_cat(resp, 0, "HTTP/1.1 303 See Other\r\nLocation: " as *u8) 131 o = si_cat(resp, o, back) 132 o = si_cat(resp, o, "\r\nContent-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" as *u8) 133 return o 134} 135 136func si_streq(a: *u8, b: *u8) -> i64 { 137 var i: i64 = 0 138 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 } 139 if b[i] != (0 as u8) { return 0 } 140 return 1 141} 142// the daemon path's health surface (/api/deploy probes over HTTP; a core with no 200 anywhere 143// would make every deploy read as dead). GET-only; the slug 'health' is RESERVED from recording. 144func si_resp_health(resp: *u8) -> i64 { 145 return si_cat(resp, 0, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok" as *u8) 146} 147 148// the route core: full raw request in, full raw response out (returns response length). 149// plane/logf are caller-supplied append targets -- dependency-injected so the gate can prove the 150// whole data path against per-run fixtures and the host decides real retention paths. 151func si_route(req: *u8, rn: i64, resp: *u8, plane: *u8, logf: *u8) -> i64 { 152 if si_starts(req, rn, "POST /intake/" as *u8) == 0 { 153 if si_starts(req, rn, "GET /intake/health " as *u8) == 1 { return si_resp_health(resp) } 154 if si_starts(req, rn, "GET /intake/" as *u8) == 1 { return si_resp(resp, "HTTP/1.1 405 Method Not Allowed" as *u8) } 155 return si_resp(resp, "HTTP/1.1 404 Not Found" as *u8) 156 } 157 let slug: *u8 = sys_mmap(SI_SLUGBUF) 158 let pfx: i64 = si_len("POST /intake/" as *u8) 159 var i: i64 = pfx 160 var so: i64 = 0 161 var stop: i64 = 0 162 while stop == 0 { 163 if i >= rn { stop = 1 } else { 164 if req[i] == (SI_B_SP as u8) { stop = 1 } else { 165 if so < SI_SLUGBUF - 1 { slug[so] = req[i]; so = so + 1 } 166 i = i + 1 167 } 168 } 169 } 170 slug[so] = 0 as u8 171 if si_slug_ok(slug) == 0 { return si_resp(resp, "HTTP/1.1 404 Not Found" as *u8) } 172 if si_streq(slug, "health" as *u8) == 1 { return si_resp(resp, "HTTP/1.1 404 Not Found" as *u8) } 173 var bo: i64 = 0 - 1 174 i = 0 175 while i + 4 <= rn { 176 if req[i] == (SI_B_CR as u8) { if req[i+1] == (SI_B_LF as u8) { if req[i+2] == (SI_B_CR as u8) { if req[i+3] == (SI_B_LF as u8) { bo = i + 4; i = rn } } } } 177 i = i + 1 178 } 179 if bo < 0 { return si_resp(resp, "HTTP/1.1 400 Bad Request" as *u8) } 180 let bn: i64 = rn - bo 181 if bn > SI_MAX_BODY { return si_resp(resp, "HTTP/1.1 413 Content Too Large" as *u8) } 182 let body: *u8 = ((req as i64) + bo) as *u8 183 let back: *u8 = sys_mmap(SI_SCRATCH) 184 si_field(body, bn, "back\x00" as *u8, back, SI_SCRATCH - 1) 185 if si_back_ok(back) == 0 { back[0] = SI_B_SLASH as u8; back[1] = 0 as u8 } 186 let hp: *u8 = sys_mmap(SI_HPBUF) 187 if si_field(body, bn, "website\x00" as *u8, hp, SI_HPBUF - 1) > 0 { return si_resp_303(resp, back) } 188 let nm: *u8 = sys_mmap(SI_SCRATCH) 189 let em: *u8 = sys_mmap(SI_SCRATCH) 190 let mg: *u8 = sys_mmap(SI_MSGBUF) 191 let nn: i64 = si_field(body, bn, "name\x00" as *u8, nm, SI_MAX_FIELD) 192 let en: i64 = si_field(body, bn, "email\x00" as *u8, em, SI_MAX_FIELD) 193 let mn: i64 = si_field(body, bn, "message\x00" as *u8, mg, SI_MAX_MSG) 194 if nn == 0 { if en == 0 { if mn == 0 { return si_resp(resp, "HTTP/1.1 400 Bad Request" as *u8) } } } 195 let row: *u8 = sys_mmap(SI_ROW) 196 let ts: i64 = sys_now_realtime_sec() 197 var ro: i64 = si_putn(row, 0, ts) 198 row[ro] = SI_B_TAB as u8; ro = ro + 1 199 ro = si_cat(row, ro, slug) 200 row[ro] = SI_B_TAB as u8; ro = ro + 1 201 ro = si_cat(row, ro, nm) 202 row[ro] = SI_B_TAB as u8; ro = ro + 1 203 ro = si_cat(row, ro, em) 204 row[ro] = SI_B_TAB as u8; ro = ro + 1 205 ro = si_cat(row, ro, mg) 206 row[ro] = SI_B_LF as u8; ro = ro + 1 207 let fd: i64 = sys_openat_append(plane, SI_MODE) 208 if fd < 0 { return si_resp(resp, "HTTP/1.1 500 Internal Server Error" as *u8) } 209 let wr: i64 = sys_write(fd, row, ro) 210 sys_close(fd) 211 if wr != ro { return si_resp(resp, "HTTP/1.1 500 Internal Server Error" as *u8) } 212 let lg: *u8 = sys_mmap(SI_SCRATCH) 213 var lo: i64 = si_cat(lg, 0, "ts=" as *u8) 214 lo = si_putn(lg, lo, ts) 215 lo = si_cat(lg, lo, " organ=nx_site_intake slug=" as *u8) 216 lo = si_cat(lg, lo, slug) 217 lo = si_cat(lg, lo, " recorded=1\n" as *u8) 218 let lfd: i64 = sys_openat_append(logf, SI_MODE) 219 if lfd >= 0 { sys_write(lfd, lg, lo); sys_close(lfd) } 220 return si_resp_303(resp, back) 221}