From 40127bf53eb1228bebe7c6204b94a159edeafd3f Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 23 Aug 2026 06:45:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20framework=20v1=20slice=202=20=E2=80=94?= =?UTF-8?q?=20the=20remaining=20ledger,=20ten=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - After seam: interface After + Aw + use_after; dispatch funnels every response (handler/short-circuit/404/405) through the after chain; the WS 101 sentinel skips it (never serialized) - http/secure.wo: SecurityHeaders (nosniff/DENY/referrer; HSTS stays at the TLS proxy), Cors (preflight 204 before + origin stamp after, one class both halves), HostAllow (421), client_ip (XFF parsing — peer VERIFY stays story 35) - http/nego.wo: accepts() (exact, type/*, */*; q stripped not ranked), etag_for (quoted base64 sha256), with_etag (If-None-Match -> 304) - router: *rest wildcard (last segment, empty rest matches), Group (prefix + routes + group middleware) + Gmw prefix-scoped entries, App.mount; new App fields carry defaults so standing ctor literals keep compiling - Req grows ctx bag; parse rejects duplicate Content-Length (400, RFC 9112 §6.3) - web-app exercises all of it; gate grows 26 -> 38 checks (wildcards, group+ctx, etag+304, 406/200 negotiation, sec headers, 421, preflight+origin stamp, dup-CL 400) - ledger rows flipped; dep graph section 3 grown (slice-2 done nodes, crypto gate cleared, cookie/CSRF/session/webhook/JWT now ready) - merges: chat-ws-lifecycle (digests for ETag; WS + lifecycle ride along) + site-sample (second consumer gate); battery 13/13 Co-Authored-By: Claude Fable 5 --- docs/00-dependency-graph.md | 56 +++++++------ docs/examples/web-app/main.wo | 69 ++++++++++++++- docs/examples/writeonce-framework/README.md | 28 +++---- docs/examples/writeonce-framework/app.wo | 59 +++++++++++-- .../examples/writeonce-framework/http/nego.wo | 49 +++++++++++ .../writeonce-framework/http/secure.wo | 76 +++++++++++++++++ .../writeonce-framework/http/types.wo | 4 + .../writeonce-framework/internal/parse.wo | 15 +++- .../writeonce-framework/router/router.wo | 82 +++++++++++++++++- scripts/web-app-accept.sh | 83 +++++++++++++++++++ 10 files changed, 467 insertions(+), 54 deletions(-) create mode 100644 docs/examples/writeonce-framework/http/nego.wo create mode 100644 docs/examples/writeonce-framework/http/secure.wo diff --git a/docs/00-dependency-graph.md b/docs/00-dependency-graph.md index 6aefa4c..291d072 100644 --- a/docs/00-dependency-graph.md +++ b/docs/00-dependency-graph.md @@ -148,38 +148,38 @@ flowchart TD classDef gate fill:#8250df,color:#fff,stroke:none classDef ready fill:#1a7f37,color:#fff,stroke:none classDef blocked fill:#eac54f,color:#000,stroke:none + classDef done fill:#6e7781,color:#fff,stroke:none - CORS["CORS middleware"]:::ready - SECH["security-headers middleware"]:::ready - HOSTV["host validation"]:::ready - STRICT["strict-parsing audit (dup/conflicting Content-Length)"]:::ready - WILD["wildcard segments *rest"]:::ready - PREC["specificity precedence"]:::blocked - GROUPS["route groups"]:::ready - CTX["req.ctx bag"]:::ready - XFF["X-Forwarded-For/-Proto parsing"]:::ready - ACCEPT["Accept-driven negotiation"]:::ready + CORS["CORS middleware ✅ slice 2"]:::done + SECH["security-headers middleware ✅ slice 2"]:::done + HOSTV["host validation (421) ✅ slice 2"]:::done + STRICT["strict-parsing audit (dup Content-Length = 400) ✅ slice 2"]:::done + WILD["wildcard segments *rest ✅ slice 2"]:::done + PREC["precedence: registration order stands, wildcards last by construction ✅"]:::done + GROUPS["route groups + group middleware ✅ slice 2"]:::done + CTX["req.ctx bag ✅ slice 2"]:::done + XFF["client_ip: X-Forwarded-For parsing ✅ slice 2 (peer VERIFY stays gated)"]:::done + ACCEPT["accepts(): response-side negotiation ✅ slice 2"]:::done NETSEAM["GATE: net runtime seams (timeouts, unix socket, peer address) — story 35 owns"]:::gate TMOUT["read/write/idle timeouts"]:::blocked UNIX["unix socket binding"]:::blocked PEERV["trusted-proxy PEER verification"]:::blocked - CRYPTO["GATE: story 34 crypto — C builtins vs pure-.wo (bitwise landed with 36, both possible; brainstorm decides); carriers (Bytes, base64) landed with 19"]:::gate - SHA["SHA-256/512, HMAC, CRC32"]:::blocked - ETAG["ETag + conditional requests"]:::blocked - COOKIE["signed cookies"]:::blocked - CSRF["CSRF"]:::blocked - SESS["session integrity"]:::blocked - HOOKV["webhook verification"]:::blocked - JWT["JWT HS256 (HARD STOP after)"]:::blocked + CRYPTO["GATE CLEARED: iteration 34 landed C builtins — sha1/sha256/hmac_sha256 (ids 85-87)"]:::done + SHA["sha1/sha256/hmac_sha256 ✅ iteration 34; SHA-512/CRC32 wait for a consumer"]:::done + ETAG["ETag + If-None-Match 304 ✅ slice 2"]:::done + COOKIE["signed cookies"]:::ready + CSRF["CSRF"]:::ready + SESS["session integrity"]:::ready + HOOKV["webhook verification"]:::ready + JWT["JWT HS256 (HARD STOP after)"]:::ready RADIX["radix-tree routing"]:::blocked I9E3["GATE: router scan unmeasured — 22's harness landed but benched the DB, not the router; perf-targets entry first"]:::gate STORAGE["storage-integration rows: migrations (future story), eager loading + tenant roots (query-surface work, 9-series)"]:::blocked - WILD --> PREC NETSEAM --> TMOUT NETSEAM --> UNIX NETSEAM --> PEERV @@ -193,12 +193,18 @@ flowchart TD I9E3 --> RADIX ``` -Green nodes (CORS, security headers, host validation, strict-parsing -audit, wildcards, route groups, `req.ctx`, XFF parsing, Accept -negotiation) need nothing — startable in any order, gated by -`just web-app`. Note: 21's keypair crypto is its own C implementation -(already on branch `keypair-auth`) — it neither waits for nor feeds the -crypto-fork gate. +**Slice 2 landed (2026-08-23, branch `framework-v1b`):** every +formerly-green node plus the crypto chain's first consumers — CORS, +security headers, host validation (421), strict dup-Content-Length, +`*rest` wildcards, route groups + group middleware, `req.ctx`, +`client_ip`, `accepts()`, ETag/304 — all gated by `just web-app` +(38 checks). The after-middleware seam (`After`/`use_after`) carries +the response-header half. Now READY with the digest builtins landed: +signed cookies, CSRF, session integrity, webhook verification, JWT +HS256 (the hard stop). Still gated: timeouts/unix-socket/peer-verify +(story 35's net seams) and the radix tree (router scan unmeasured). +Note: 21's keypair crypto is its own C implementation (already on +branch `keypair-auth`) — it neither waits for nor feeds this chain. ## 4. Framework v2 (iteration 18) — internal order diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo index 073ca00..0c4e260 100644 --- a/docs/examples/web-app/main.wo +++ b/docs/examples/web-app/main.wo @@ -118,6 +118,57 @@ class DeleteProduct { } } +-- ---- framework v1 slice 2: the storefront exercises the new surface ---- + +-- wildcard capture: GET /files/*path echoes the rest +class EchoPath { + pad: Int + fn handle(req: Req) -> Resp { + let p = req.params["path"]; + if p == nil { return ok_text("path="); } + return ok_text("path=${p}"); + } +} + +-- group middleware writes the request-scoped ctx bag; the handler reads it +class StampCtx { + pad: Int + fn before(mut req: Req) -> ?Resp { + req.ctx["via"] = "api-group"; + return nil; + } +} + +class ApiPing { + pad: Int + fn handle(req: Req) -> Resp { + let via = req.ctx["via"]; + if via == nil { return ok_text("pong via="); } + return ok_text("pong via=${via}"); + } +} + +-- ETag + conditional: same body = same tag; If-None-Match collapses to 304 +class EtagProbe { + pad: Int + fn handle(req: Req) -> Resp { + return with_etag(req, ok_json("{\"v\":1}")); + } +} + +-- response-side negotiation: JSON or nothing +class NegoProbe { + pad: Int + fn handle(req: Req) -> Resp { + if accepts(req, "application/json") == false { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 406, headers: h, body: "{\"error\":\"json only\"}" }; + } + return ok_json("{\"ok\":true}"); + } +} + fn main(args: multi Text) -> Int { if len(args) < 1 { print_err("usage: web-app (WA_TOKEN and WO_DATA must be set)"); @@ -135,13 +186,27 @@ fn main(args: multi Text) -> Int { } let app = App { middleware: [], routes: [] }; - -- the framework's Bearer mechanism: constant-time compare, principal - -- attached to req.principal for handlers that want "who is this" + -- v1 slice 2: host gate first (421 before anything runs), CORS preflight + -- next, then the framework's Bearer mechanism (constant-time compare, + -- principal attached to req.principal for handlers that want "who") + app.use_mw(Mw { m: HostAllow { host: "a" } }); + app.use_mw(Mw { m: Cors { allow_origin: "*" } }); app.use_mw(Mw { m: BearerAuth { token: token, principal: "api" } }); + -- the response half: security headers + the CORS origin stamp on every + -- response that leaves dispatch (404/405/401 included) + app.use_after(Aw { a: SecurityHeaders { pad: 0 } }); + app.use_after(Aw { a: Cors { allow_origin: "*" } }); app.get("/products", ListProducts { pad: 0 }); app.get("/products/:name", ShowProduct { pad: 0 }); app.post("/products", CreateProduct { pad: 0 }); app.post("/orders", CreateOrder { pad: 0 }); app.delete_("/products/:name", DeleteProduct { pad: 0 }); + app.get("/files/*path", EchoPath { pad: 0 }); + app.get("/etag-probe", EtagProbe { pad: 0 }); + app.get("/nego", NegoProbe { pad: 0 }); + let g = Group { prefix: "/api" }; + g.use_mw(Mw { m: StampCtx { pad: 0 } }); + g.get("/ping", ApiPing { pad: 0 }); + app.mount(g); return app.serve("127.0.0.1", port); } diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md index 588b043..79677a2 100644 --- a/docs/examples/writeonce-framework/README.md +++ b/docs/examples/writeonce-framework/README.md @@ -80,7 +80,7 @@ first (pure `.wo` cannot express it yet). | Item | State | | --- | --- | -| HTTP/1.1 parsing | 🔶 parses + 400-and-survive; STRICT ambiguity rejection (duplicate/conflicting `Content-Length`, oversize checks beyond BODY_MAX) not audited — hardening slice | +| HTTP/1.1 parsing | ✅ parses + 400-and-survive; duplicate `Content-Length` rejected outright (RFC 9112 §6.3, slice 2); BODY_MAX bounds headers and body | | Keep-alive | ✅ pipelined-serve / close-when-idle (arc landed 2026-08-21; retirement of close-when-idle rides iteration 24's fiber-per-connection slice) | | Read/write/idle timeouts | 🔧 `net` has no timeout surface — story 35 owns the seam (park_deadline infra already exists for sleeps), then a framework knob | | Request size limits | ✅ BODY_MAX bounds headers AND body | @@ -93,9 +93,9 @@ first (pure `.wo` cannot express it yet). | --- | --- | | Path matching | 🔶 linear scan, first-match-wins; a radix tree waits on a MEASUREMENT first — 22's harness landed (benched the DB, not the router); needs a perf-targets register entry | | Method dispatch · path params · 404 · 405+`Allow` | ✅ | -| Wildcards | ⬜ only `:param` today; `*rest` capture is a candidate slice | -| Precedence rules | 🔶 registration order IS the rule (documented); specificity-based precedence unneeded until wildcards exist | -| Route groups | ⬜ candidate slice (prefix + per-group middleware) | +| Wildcards | ✅ `*rest` as the LAST pattern segment captures the joined tail (empty rest matches) — slice 2 | +| Precedence rules | ✅ registration order IS the rule; wildcards capture only in last position, so order stays the whole story | +| Route groups | ✅ `Group { prefix }` + per-group before-middleware, mounted in one move — slice 2 | ### Request/response @@ -103,18 +103,18 @@ first (pure `.wo` cannot express it yet). | --- | --- | | Case-insensitive headers · query parsing | ✅ (names lowercased on read) | | JSON · form-urlencoded · multipart | ✅ all three hooks (`json.decode`, `form_values`, `multipart_parts`) | -| Content negotiation | 🔶 `media_type(req)` covers the request side; `Accept`-driven response negotiation ⬜ | -| Trusted-proxy client IP | 🔶 `X-Forwarded-For/-Proto` parsing is expressible (candidate slice); VERIFYING the peer is the trusted proxy needs a peer-address seam 🔧 — story 35 owns it | +| Content negotiation | ✅ `media_type(req)` request-side; `accepts(req, mtype)` response-side (exact, type/*, */*; q-values stripped not ranked — ranking waits for an app serving alternates) — slice 2 | +| Trusted-proxy client IP | 🔶 `client_ip(req)` parses X-Forwarded-For (slice 2); VERIFYING the peer is the trusted proxy still needs the peer-address seam 🔧 — story 35 owns it | | Status/header setting · redirects | ✅ builders + `set_header` | | Lazy body streaming + backpressure · streaming responses · explicit commit point | ⏸ UNBLOCKED by the arc (8/11 landed 2026-08-21) — stays parked until its own slice | -| ETag + conditional requests | ⬜ candidate; wants story 34's digests (bitwise landed with 36 — pure-`.wo` vs C-builtin is 34's brainstorm) | +| ETag + conditional requests | ✅ `etag_for` (quoted base64 SHA-256) + `with_etag` (If-None-Match → 304) over iteration 34's digest builtins — slice 2 | ### Context & middleware | Item | State | | --- | --- | | Ordered middleware chain | ✅ registration order, `?Resp` short-circuits | -| Request-scoped context | 🔶 `req.params` + `req.principal` are the context today; a general `req.ctx` bag is a candidate slice | +| Request-scoped context | ✅ `req.ctx` map (slice 2): middleware writes, handlers read; identity stays in `principal` | | Guaranteed teardown | 🔶 every fd closes on every path (gate-proven); no user teardown hooks yet | | Cancellation into pending storage ops | ⏸ UNBLOCKED by the arc (8/11 landed 2026-08-21) — stays parked until its own slice | | Panic recovery | 🔶 trap = 500 and the server survives ✅; "rolls back the transaction" is framework v2 (needs `transaction { }`, iteration 18) | @@ -134,18 +134,18 @@ first (pure `.wo` cannot express it yet). | Item | State | | --- | --- | | Constant-time comparison · Authorization parsing · Basic auth · principal | ✅ `http/auth.wo`, `req.principal` | -| CORS | ⬜ candidate slice (middleware + preflight answers) | -| Security headers | ⬜ candidate slice (one middleware, a header set) | -| Host validation | ⬜ candidate slice (middleware against a host allowlist) | -| Strict parsing | 🔶 same item as Transport's hardening slice | +| CORS | ✅ `Cors { allow_origin }` — preflight 204 (before) + origin stamp on every response (after) — slice 2 | +| Security headers | ✅ `SecurityHeaders` after-middleware (nosniff, DENY, referrer-policy); HSTS stays at the TLS proxy by design — slice 2 | +| Host validation | ✅ `HostAllow { host }` answers 421 before any route — slice 2 | +| Strict parsing | ✅ same item as Transport's row: duplicate Content-Length is a 400 | ### Crypto (self-written, hard-stop after JWT HS256) | Item | State | | --- | --- | | base64 | ✅ pure `.wo` (`http/auth.wo`) | -| SHA-256 · SHA-512 · HMAC · CRC32 | 🔧 the language has NO bitwise operators — these are C runtime builtins (libc-only doctrine permits hand-rolled crypto in the runtime) or the language grows bit ops first; the fork goes to a brainstorm before the slice | -| Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT HS256) | ⬜ framework slices AFTER the hash primitives exist; **hard stop there** — no RS256, no JOSE zoo | +| SHA-1 · SHA-256 · HMAC-SHA256 | ✅ C runtime builtins (iteration 34, ids 85–87, RFC-vector gated); SHA-512/CRC32 wait for a consumer | +| Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT HS256) | ⬜ UNBLOCKED (the primitives exist since iteration 34); each is its own slice; **hard stop at JWT HS256** — no RS256, no JOSE zoo | ## Layout and privacy (iteration 17) diff --git a/docs/examples/writeonce-framework/app.wo b/docs/examples/writeonce-framework/app.wo index c6a1ac8..a8ab72e 100644 --- a/docs/examples/writeonce-framework/app.wo +++ b/docs/examples/writeonce-framework/app.wo @@ -1,15 +1,21 @@ --- app.wo — the assembly: an App holds the middleware chain and the route +-- app.wo — the assembly: an App holds the middleware chains and the route -- table, satisfies internal's Dispatcher interface, and serves. -- --- let app = App { middleware: [], routes: [] }; +-- let app = App { middleware: [], gmw: [], afters: [], routes: [] }; -- app.use_mw(Mw { m: Auth { token: t } }); +-- app.use_after(Aw { a: SecurityHeaders { pad: 0 } }); -- app.add(Route { method: "GET", pattern: "/products/:id", h: Show {} }); -- return app.serve("127.0.0.1", port); -- --- Dispatch order: middleware in registration order (a Resp short-circuits), --- then the first matching route (method + pattern), else the framework 404. --- The serve loop wraps dispatch in `try`, so a trapping handler answers 500 --- and the server survives. +-- Dispatch order: global middleware in registration order (a Resp +-- short-circuits), prefix-scoped group middleware next (framework v1 +-- slice 2), then the first matching route (method + pattern), else the +-- framework 404/405 — and EVERY one of those responses passes the after +-- chain (security headers, CORS response headers) before it leaves. +-- The one exception is the WS hijack sentinel (status 101): that +-- response is never serialized, so afters skip it. +-- The serve loop wraps dispatch in `try`, so a trapping handler answers +-- 500 and the server survives. use http use router -- iteration 17: the serve loop is library-internal now (internal/serve.wo). @@ -18,16 +24,36 @@ use internal pub class App { middleware: multi Mw + -- v1 slice 2 additions carry defaults so the standing ctor literal + -- `App { middleware: [], routes: [] }` keeps compiling everywhere. + gmw: multi Gmw = [] + afters: multi Aw = [] routes: multi Route fn use_mw(take m: Mw) { push(self.middleware, m); } + fn use_after(take a: Aw) { + push(self.afters, a); + } + fn add(take r: Route) { push(self.routes, r); } + -- Mount a group: its (already prefixed) routes join the table in + -- order; its middleware becomes prefix-scoped entries. + fn mount(take g: Group) { + while len(g.routes) > 0 { + push(self.routes, shift(g.routes)); + } + while len(g.middleware) > 0 { + let m = shift(g.middleware); + push(self.gmw, Gmw { prefix: g.prefix, m: m.m }); + } + } + -- Registration helpers — the ctor-literal-into-take shape, per method. fn get(pattern: Text, take h: Handler) { push(self.routes, Route { method: "GET", pattern: pattern, h: h }); @@ -45,11 +71,18 @@ pub class App { push(self.routes, Route { method: "DELETE", pattern: pattern, h: h }); } - fn dispatch(mut req: Req) -> Resp { + -- The pre-after half of dispatch: first Resp wins. + fn route_req(mut req: Req) -> Resp { for mw in self.middleware { let short = mw.m.before(req); if short != nil { return short; } } + for g in self.gmw { + if starts_with(req.path, g.prefix) { + let short = g.m.before(req); + if short != nil { return short; } + } + } -- Path-first matching so a wrong-method hit on a known path answers -- 405 with the Allow header (registration order) instead of a 404. let allow = ""; @@ -69,7 +102,17 @@ pub class App { return not_found(); } + fn dispatch(mut req: Req) -> Resp { + let resp = self.route_req(req); + if resp.status != 101 { + for aw in self.afters { + aw.a.after(req, resp); + } + } + return resp; + } + fn serve(host: Text, port: Int) -> Int { return internal.serve(host, port, self); } -} +} \ No newline at end of file diff --git a/docs/examples/writeonce-framework/http/nego.wo b/docs/examples/writeonce-framework/http/nego.wo new file mode 100644 index 0000000..5c394fc --- /dev/null +++ b/docs/examples/writeonce-framework/http/nego.wo @@ -0,0 +1,49 @@ +-- http/nego.wo — framework v1 slice 2: response-side content negotiation +-- and ETag / conditional requests (the crypto slice's first ledger +-- consumer beyond the WS handshake). + +-- Does the request accept this media type? Absent Accept = yes (RFC 9110 +-- §12.5.1: no header means anything goes). Matching is exact, type/*, +-- or */*; q-values are stripped, not ranked — v1 answers CAN I send +-- this, not WHICH ONE is best (a ranking negotiation waits for an app +-- that serves alternates). +pub fn accepts(req: Req, mtype: Text) -> Bool { + let acc = req.headers["accept"]; + if acc == nil { return true; } + let slash = index_of(mtype, "/"); + let major = mtype; + if slash >= 0 { major = substr(mtype, 0, slash); } + for part in split(to_lower("${acc}"), ",") { + let item = trim(part); + let semi = index_of(item, ";"); + if semi >= 0 { item = trim(substr(item, 0, semi)); } + if item == mtype { return true; } + if item == "*/*" { return true; } + if item == "${major}/*" { return true; } + } + return false; +} + +-- A strong ETag for a body: quoted base64 of its SHA-256. Deterministic, +-- content-addressed — two identical bodies share one tag across +-- restarts and shards. +pub fn etag_for(body: Text) -> Text { + return "\"${base64_encode(sha256(bytes_of_text(body)))}\""; +} + +-- Stamp the response's ETag and collapse it to 304 when the request's +-- If-None-Match already has it. The 304 keeps the etag header and +-- drops the body (RFC 9110 §15.4.5). Call it last in a handler: +-- return with_etag(req, ok_json(body)); +pub fn with_etag(req: Req, take r: Resp) -> Resp { + let tag = etag_for(r.body); + r.headers["etag"] = tag; + let inm = req.headers["if-none-match"]; + if inm != nil { + if trim(inm) == tag { + r.status = 304; + r.body = ""; + } + } + return r; +} \ No newline at end of file diff --git a/docs/examples/writeonce-framework/http/secure.wo b/docs/examples/writeonce-framework/http/secure.wo new file mode 100644 index 0000000..84aa26e --- /dev/null +++ b/docs/examples/writeonce-framework/http/secure.wo @@ -0,0 +1,76 @@ +-- http/secure.wo — framework v1 slice 2: the security middlewares and the +-- trusted-proxy parsing helper. Mechanism here, POLICY in the app — the +-- same split http/auth.wo keeps. The classes satisfy router's Middleware/ +-- After interfaces STRUCTURALLY at the registration site — no import here. + +-- The response headers every deployment wants and nobody remembers. +-- HSTS is deliberately absent: TLS terminates at the proxy (the +-- framework's standing decision), so Strict-Transport-Security belongs +-- in the proxy config next to the certificates. +pub class SecurityHeaders { + pad: Int + fn after(req: Req, mut r: Resp) { + r.headers["x-content-type-options"] = "nosniff"; + r.headers["x-frame-options"] = "DENY"; + r.headers["referrer-policy"] = "strict-origin-when-cross-origin"; + } +} + +-- CORS, both halves in one class: `before` answers the OPTIONS preflight +-- (204 with the allow set), `after` stamps Access-Control-Allow-Origin on +-- every response to a request that carried an Origin. Register it twice — +-- once as Mw, once as Aw — the structural interfaces make one value +-- satisfy both. allow_origin is the policy knob ("*" or one origin). +pub class Cors { + allow_origin: Text + + fn before(mut req: Req) -> ?Resp { + if req.method != "OPTIONS" { return nil; } + let origin = req.headers["origin"]; + if origin == nil { return nil; } + let want = req.headers["access-control-request-method"]; + if want == nil { return nil; } + let h: map = {}; + h["access-control-allow-origin"] = self.allow_origin; + h["access-control-allow-methods"] = "GET, POST, PUT, DELETE, OPTIONS"; + h["access-control-allow-headers"] = "authorization, content-type"; + h["access-control-max-age"] = "600"; + return Resp { status: 204, headers: h, body: "" }; + } + + fn after(req: Req, mut r: Resp) { + let origin = req.headers["origin"]; + if origin != nil { + r.headers["access-control-allow-origin"] = self.allow_origin; + } + } +} + +-- Host validation: a request whose Host header is missing or not the +-- one this app serves answers 421 (misdirected request) before any +-- route runs. Port suffixes count as part of the host on purpose — +-- behind the proxy the forwarded Host is exactly one known value. +pub class HostAllow { + host: Text + fn before(mut req: Req) -> ?Resp { + let got = req.headers["host"]; + if got != nil { + if trim(got) == self.host { return nil; } + } + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 421, headers: h, body: "{\"error\":\"misdirected request\"}" }; + } +} + +-- The PARSING half of trusted-proxy client identity: the left-most +-- X-Forwarded-For entry, trimmed; "" when absent. VERIFYING that the +-- peer actually is the trusted proxy needs a peer-address runtime seam — +-- story 35's, not this slice's. +pub fn client_ip(req: Req) -> Text { + let xff = req.headers["x-forwarded-for"]; + if xff == nil { return ""; } + let parts = split("${xff}", ","); + if len(parts) == 0 { return ""; } + return trim(parts[0]); +} \ No newline at end of file diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-framework/http/types.wo index a07d85c..1287576 100644 --- a/docs/examples/writeonce-framework/http/types.wo +++ b/docs/examples/writeonce-framework/http/types.wo @@ -11,6 +11,10 @@ pub typedef Req = { body: Text, -- exactly Content-Length bytes ("" if none) principal: Text, -- who this is: "" until an auth middleware -- (http/auth.wo) authenticates the request + ctx: map, -- request-scoped bag (v1 slice 2): middleware + -- writes, handlers read — request ids, + -- tenant keys, anything per-request that is + -- not identity (identity is `principal`) conn: net.Conn -- the connection the request arrived on. -- INTERNAL plumbing for http/ws.wo's -- upgrade (iteration 24): handlers never diff --git a/docs/examples/writeonce-framework/internal/parse.wo b/docs/examples/writeonce-framework/internal/parse.wo index 97bff46..2d83aef 100644 --- a/docs/examples/writeonce-framework/internal/parse.wo +++ b/docs/examples/writeonce-framework/internal/parse.wo @@ -116,6 +116,7 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed { path = url_decode(path, false); let headers: map = {}; + let cl_seen = 0; let i = 1; while i < len(lines) { let line = trim(lines[i]); @@ -123,7 +124,16 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed { if line == "" { continue; } let colon = index_of(line, ":"); if colon > 0 { - headers[to_lower(substr(line, 0, colon))] = trim(substr(line, colon + 1, len(line) - colon - 1)); + let hname = to_lower(substr(line, 0, colon)); + -- v1 slice 2, the strict-ambiguity audit: a SECOND Content-Length + -- header is request smuggling's favorite tool — reject the request + -- outright instead of letting last-one-wins pick a body length + -- (RFC 9112 §6.3: such a message MUST be treated as an error). + if hname == "content-length" { + cl_seen = cl_seen + 1; + if cl_seen > 1 { return malformed(""); } + } + headers[hname] = trim(substr(line, colon + 1, len(line) - colon - 1)); } } @@ -150,6 +160,7 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed { } let req = Req { method: method, path: path, params: {}, query: query, - headers: headers, body: body, principal: "", conn: c }; + headers: headers, body: body, principal: "", ctx: {}, + conn: c }; return Parsed { closed: false, ok: true, req: req, rest: rest }; } diff --git a/docs/examples/writeonce-framework/router/router.wo b/docs/examples/writeonce-framework/router/router.wo index d1c4c6e..5a626cc 100644 --- a/docs/examples/writeonce-framework/router/router.wo +++ b/docs/examples/writeonce-framework/router/router.wo @@ -17,6 +17,20 @@ pub interface Middleware { fn before(mut req: Req) -> ?Resp } +-- framework v1 slice 2: the response half of the chain. `after` runs on +-- EVERY response leaving dispatch — handler answers, middleware +-- short-circuits, the framework 404/405 — so security headers and CORS +-- reach all of them. It mutates, never replaces (no ?Resp: an after that +-- could swallow the response would be a second handler). +pub interface After { + fn after(req: Req, mut r: Resp) +} + +-- Wrapper record, same reason as Mw/Route. +pub class Aw { + a: After +} + -- One route: method + pattern + the handler value. Built with a ctor -- literal at the registration site (`Route { method: "GET", pattern: -- "/products/:id", h: ProductShow {} }`) — the exact ownership shape the @@ -46,7 +60,11 @@ pub class Logging { -- Does `pattern` match `path`? Fills `params` with :name captures. -- Segments split on '/', empties dropped (so "/a//b" == "/a/b" and the -- root "/" is the empty segment list). First mismatch wins; a :segment --- captures anything non-empty. +-- captures anything non-empty. A LAST segment `*name` (framework v1 +-- slice 2) captures the whole rest — zero or more segments, re-joined +-- with '/' — so "/files/*path" matches "/files" (path = "") and +-- "/files/a/b" (path = "a/b"). A `*` anywhere else never matches: +-- precedence stays registration order, wildcards last by construction. pub fn route_match(pattern: Text, path: Text, mut params: map) -> Bool { let ps = split(pattern, "/"); let xs = split(path, "/"); @@ -54,10 +72,30 @@ pub fn route_match(pattern: Text, path: Text, mut params: map) -> Bo for s in ps { if s != "" { push(psegs, s); } } let xsegs: multi Text = []; for s in xs { if s != "" { push(xsegs, s); } } - if len(psegs) != len(xsegs) { return false; } + let np = len(psegs); + let wild = false; + if np > 0 { + if starts_with(psegs[np - 1], "*") { wild = true; } + } + if wild == false { + if np != len(xsegs) { return false; } + } else { + if len(xsegs) < np - 1 { return false; } + } let i = 0; - while i < len(psegs) { + while i < np { let p = psegs[i]; + if wild and i == np - 1 { + let rest = ""; + let j = i; + while j < len(xsegs) { + if rest == "" { rest = xsegs[j]; } else { rest = "${rest}/${xsegs[j]}"; } + j = j + 1; + } + params[substr(p, 1, len(p) - 1)] = rest; + return true; + } + if starts_with(p, "*") { return false; } let x = xsegs[i]; if starts_with(p, ":") { params[substr(p, 1, len(p) - 1)] = x; @@ -68,3 +106,41 @@ pub fn route_match(pattern: Text, path: Text, mut params: map) -> Bo } return true; } + +-- framework v1 slice 2: a route GROUP — a prefix plus its own routes and +-- its own before-middleware, mounted into an App in one move. The group +-- prefixes patterns at REGISTRATION (the mount is a plain move); its +-- middleware becomes prefix-scoped on the App: it runs only for paths +-- under the prefix, after the global chain, before the route scan. +pub class Group { + prefix: Text + routes: multi Route = [] + middleware: multi Mw = [] + + fn get(pattern: Text, take h: Handler) { + push(self.routes, Route { method: "GET", pattern: "${self.prefix}${pattern}", h: h }); + } + + fn post(pattern: Text, take h: Handler) { + push(self.routes, Route { method: "POST", pattern: "${self.prefix}${pattern}", h: h }); + } + + fn put(pattern: Text, take h: Handler) { + push(self.routes, Route { method: "PUT", pattern: "${self.prefix}${pattern}", h: h }); + } + + fn delete_(pattern: Text, take h: Handler) { + push(self.routes, Route { method: "DELETE", pattern: "${self.prefix}${pattern}", h: h }); + } + + fn use_mw(take m: Mw) { + push(self.middleware, m); + } +} + +-- A prefix-scoped middleware entry on the App (what mounting a group's +-- middleware becomes). +pub class Gmw { + prefix: Text + m: Middleware +} diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh index d41329b..40835a3 100755 --- a/scripts/web-app-accept.sh +++ b/scripts/web-app-accept.sh @@ -213,6 +213,89 @@ IFS='|' read -r hs same hb gb <<<"$hd" && ok "HEAD answers GET's Content-Length with no body" \ || bad "HEAD" "status=$hs same-length=$same head-body=$hb get-body=$gb" +# ---- 12b. framework v1 slice 2 ---- +# raw client with header control: prints "STATUS|HEADERS|BODY" +# (headers ;-joined, lowercased names) +hraw() { # extra_header_lines(\n-separated) method path + timeout 5 python3 - "$PORT" "$1" "$2" "$3" <<'PYEOF' +import socket, sys +port, extra, method, path = int(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4] +s = socket.create_connection(("127.0.0.1", port), timeout=5) +h = f"{method} {path} HTTP/1.1\r\n" +for line in extra.split("\n"): + if line: h += line + "\r\n" +h += "content-length: 0\r\nconnection: close\r\n\r\n" +s.sendall(h.encode()) +d = b"" +try: + while True: + c = s.recv(4000) + if not c: break + d += c +except Exception: pass +s.close() +head, _, body = d.partition(b"\r\n\r\n") +lines = head.decode().splitlines() +status = lines[0].split(" ")[1] +def norm(l): + n, _, v = l.partition(":") + return n.lower() + ":" + v +hdrs = ";".join(norm(l) for l in lines[1:]) +print(status + "|" + hdrs + "|" + body.decode(errors="replace")) +PYEOF +} +AUTH="host: a +authorization: Bearer s3cr3t" + +r="$(hraw "$AUTH" GET /files/a/b/c)" +[[ "$r" == 200\|*"path=a/b/c"* ]] && ok "wildcard *rest captures the tail" || bad "wildcard" "$r" +r="$(hraw "$AUTH" GET /files)" +[[ "$r" == 200\|*"path="* ]] && ok "wildcard matches the empty rest" || bad "wildcard-empty" "$r" +r="$(hraw "$AUTH" GET /api/ping)" +[[ "$r" == 200\|*"pong via=api-group"* ]] && ok "group route + group middleware + req.ctx" || bad "group" "$r" +r="$(hraw "$AUTH" GET /etag-probe)" +[[ "$r" == 200\|*"etag: \""* ]] && ok "ETag stamped on the response" || bad "etag" "$r" +tag="$(printf '%s' "$r" | tr ';' '\n' | grep -m1 '^etag: ' | cut -d' ' -f2)" +r="$(hraw "$AUTH +if-none-match: $tag" GET /etag-probe)" +[[ "$r" == 304\|* ]] && ok "If-None-Match answers 304" || bad "etag-304" "$r" +r="$(hraw "$AUTH +accept: text/html" GET /nego)" +[[ "$r" == 406\|* ]] && ok "Accept negotiation refuses non-JSON (406)" || bad "nego-406" "$r" +r="$(hraw "$AUTH +accept: application/*" GET /nego)" +[[ "$r" == 200\|*'"ok":true'* ]] && ok "Accept type/* matches" || bad "nego-200" "$r" +r="$(hraw "$AUTH" GET /products)" +[[ "$r" == 200\|*"x-content-type-options: nosniff"*"x-frame-options: DENY"* ]] \ + && ok "security headers on responses" || bad "sec-headers" "$r" +r="$(hraw "host: evil +authorization: Bearer s3cr3t" GET /products)" +[[ "$r" == 421\|* ]] && ok "host validation answers 421" || bad "host-421" "$r" +r="$(hraw "host: a +origin: http://x +access-control-request-method: POST" OPTIONS /products)" +[[ "$r" == 204\|*"access-control-allow-origin: *"*"access-control-allow-methods:"* ]] \ + && ok "CORS preflight answers 204 + allow set" || bad "cors-preflight" "$r" +r="$(hraw "$AUTH +origin: http://x" GET /products)" +[[ "$r" == 200\|*"access-control-allow-origin: *"* ]] \ + && ok "CORS origin stamped on real responses" || bad "cors-after" "$r" +r="$(timeout 5 python3 - "$PORT" <<'PYEOF' +import socket, sys +s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=5) +s.sendall(b"GET /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\ncontent-length: 0\r\ncontent-length: 5\r\nconnection: close\r\n\r\n") +d = b"" +try: + while True: + c = s.recv(2000) + if not c: break + d += c +except Exception: pass +print(d.decode(errors="replace").splitlines()[0].split(" ")[1]) +PYEOF +)" +[[ "$r" == "400" ]] && ok "duplicate Content-Length rejected (400)" || bad "dup-cl" "got $r" + # ---- 13. pipelined keep-alive: two requests, one connection ---- n="$(timeout 5 python3 - "$PORT" <<'PYEOF' import socket, sys