From 87769c6c95206f1b244d970428c4708f1450e22b Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 9 May 2026 21:47:17 +0900 Subject: [PATCH 1/3] docs(proposal): response-side policies --- docs/proposals/response-side-policies.md | 121 +++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/proposals/response-side-policies.md diff --git a/docs/proposals/response-side-policies.md b/docs/proposals/response-side-policies.md new file mode 100644 index 0000000..ddc214d --- /dev/null +++ b/docs/proposals/response-side-policies.md @@ -0,0 +1,121 @@ +# Response-side policies + +Status: Proposed (draft PR, design doc only). +Tracking: ROADMAP.md → Near term. + +## Motivation + +Today zopa only evaluates against the request side. `proxy_on_response_headers` +is implemented as a no-op (`src/proxy_wasm.zig`). That means rules like +"never let an upstream `5xx` reach the client", "block responses that +leak `Server: `", or "redact responses for unauthenticated +users" cannot be enforced. + +Use cases: + +- Cap `5xx` bleed during incidents (return a generic `503` instead). +- Strip / mask response headers that leak infra details. +- Force `Cache-Control: no-store` on routes the policy marks as + sensitive. +- Enforce that responses to unauthenticated requests don't carry a + `Set-Cookie`. + +## Goals + +1. Add a separate target rule: `allow_response` (or `deny_response`). + Convention to be locked in §Open questions. +1. Implement `proxy_on_response_headers`: + - Build an input shape reflecting response status and headers. + - Evaluate the AST against the response target rule. + - On deny, replace the response (status + body + selected headers). + - On allow, fall through. +1. Reuse the existing AST machinery. No new node types are needed for + v1; only a new target rule and a new input shape. + +## Non-goals + +- Response body inspection. Same pattern as request-body, but tracked + with `body-aware-policies.md`. v1 only sees status + headers. +- Mutating the response in place. v1 either lets it through or + replaces it entirely (status + body + headers from the rule's + `value`). +- Per-route policy targeting. The same policy applies everywhere the + filter is configured. + +## Design sketch + +### Per-context state + +Borrow the snapshot from `body-aware-policies.md`: a per-context store +that already holds request `:method` and `:path` so the response rule +can reason about request → response pairs. + +```zig +const ResponseContext = struct { + request: *RequestContext, // shared with request-side eval + status: u32 = 0, + headers: ?json.Value = null, +}; +``` + +### Input shape + +```json +{ + "request": { + "method": "GET", + "path": "/admin/users", + "headers": { "...": "..." } + }, + "response": { + "status": 500, + "headers": { "server": "nginx/1.27", "...": "..." } + } +} +``` + +### Replacement contract + +When the response rule denies, zopa calls `proxy_send_local_response` +with parameters drawn from the rule's `value`: + +```json +{ + "type": "value", + "value": { + "status": 503, + "body": "service temporarily unavailable", + "headers": { "retry-after": "30" } + } +} +``` + +A bare boolean `false` keeps current behavior (replace with a static +`503` and an empty body). + +## API impact + +- New target rule name `allow_response` joins the existing `allow`. +- Existing `allow` policies continue to work unchanged. +- New AST refs become valid: `input.request.*`, `input.response.*`. + +## Test plan + +- Node integration test: drive `evaluate` directly with a synthetic + response-shaped input. +- Envoy integration test: extend `examples/envoy/run.sh` to assert a + rewritten `503` when the upstream returns `500`. +- wasmtime test: walk through `proxy_on_request_headers` → + `proxy_on_response_headers` and verify the request snapshot is still + reachable from the response rule. + +## Open questions + +- Naming: `allow_response` (additive) vs `deny_response` (subtractive)? + Picking the wrong default biases policies; revisit before + implementation. +- Should the request snapshot be auto-cleared after the response phase, + or kept until `proxy_on_done`? Memory vs late-binding tradeoff. +- How should the `value` shape diverge from a plain boolean? If we + ever want partial mutation (just a header swap, not a full + replacement) the schema needs a `mode` discriminator. From 44817df828142e49fc56f1e8fa7f1bed15ae9285 Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 9 May 2026 22:53:17 +0900 Subject: [PATCH 2/3] feat(response): allow_response target rule + evaluate_target export eval.zig: - New evaluateWithTarget(arena, input, ast, target_rule). evaluate() becomes a thin wrapper targeting 'allow'. main.zig: - New evaluate_target wasm export: same shape as evaluate plus an explicit target_rule pointer/length pair. Lets generic-ABI hosts drive non-default rules without going through proxy-wasm. proxy_wasm.zig: - proxy_on_response_headers builds {response: {status, headers}} from the response header map and runs evaluateWithTarget against 'allow_response'. Deny short-circuits with proxy_send_local_response(503). - :status pseudo-header fetched individually for the same wamr-host reason that drives request-side path. - Unchanged: request-headers path stays on 'allow'. Tests: - src/eval.zig: 3 unit cases (5xx denies, default allows, missing target rule denies, allow target preserved). - test/run.mjs and test/run_wasmtime.py: 3 cases each driving the new evaluate_target export end-to-end through the wasm boundary. Release build grows from 50K to 55K. ci.yml gains test-unit job in line with the other implementation branches. --- src/eval.zig | 66 ++++++++++++++++++++++++++++++++++++++++++-- src/main.zig | 23 +++++++++++++++ src/proxy_wasm.zig | 65 ++++++++++++++++++++++++++++++++++++++++--- test/run.mjs | 57 +++++++++++++++++++++++++++++++++++++- test/run_wasmtime.py | 50 +++++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 7 deletions(-) diff --git a/src/eval.zig b/src/eval.zig index 753c611..6a28747 100644 --- a/src/eval.zig +++ b/src/eval.zig @@ -45,8 +45,10 @@ const Scope = struct { /// function neither inits nor resets it -- that is the caller's job. /// /// Targets the default package ("") and the default rule ("allow"). -/// Use `evaluateAddressed` to dispatch into a specific `package.rule` -/// pair within a `{"type":"modules", ...}` bundle. +/// Use `evaluateWithTarget` to pick a non-default rule (e.g. +/// "allow_response"), or `evaluateAddressed` to dispatch into a +/// specific `package.rule` pair within a `{"type":"modules", ...}` +/// bundle. pub fn evaluate( arena: *std.heap.ArenaAllocator, input_json: []const u8, @@ -55,6 +57,19 @@ pub fn evaluate( return evaluateAddressed(arena, input_json, ast_json, "", default_target_rule); } +/// Run a single evaluation against `target_rule` in the default +/// package (""). Used by the proxy-wasm shim to route the +/// response-phase callback to the `allow_response` rule while +/// keeping the request-phase on `allow`. +pub fn evaluateWithTarget( + arena: *std.heap.ArenaAllocator, + input_json: []const u8, + ast_json: []const u8, + target_rule: []const u8, +) !bool { + return evaluateAddressed(arena, input_json, ast_json, "", target_rule); +} + /// Run a single evaluation against `target_package.target_rule`. The /// AST source can be either a single module or a `Modules` bundle; /// the legacy single-module form is treated as `package = ""`. @@ -550,6 +565,53 @@ test "modules bundle: OR across two modules in same package" { try testing.expect(!(try runAddressed("{\"role\":\"guest\"}", policy, "authz", "allow"))); } +fn runWithTarget(input: []const u8, ast_src: []const u8, target: []const u8) !bool { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + return evaluateWithTarget(&arena, input, ast_src, target); +} + +test "evaluateWithTarget: allow_response fires on 5xx" { + const policy = + "{\"type\":\"module\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow_response\",\"default\":true," ++ + "\"value\":{\"type\":\"value\",\"value\":true}}," ++ + "{\"type\":\"rule\",\"name\":\"allow_response\",\"body\":[" ++ + "{\"type\":\"gte\"," ++ + "\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"response\",\"status\"]}," ++ + "\"right\":{\"type\":\"value\",\"value\":500}}]," ++ + "\"value\":{\"type\":\"value\",\"value\":false}}" ++ + "]}"; + + // 5xx responses fail the allow_response rule -> deny -> 503 replacement. + try testing.expect(!(try runWithTarget( + "{\"response\":{\"status\":500,\"headers\":{}}}", + policy, + "allow_response", + ))); + + // Non-5xx responses go through default (allow). + try testing.expect(try runWithTarget( + "{\"response\":{\"status\":200,\"headers\":{}}}", + policy, + "allow_response", + )); +} + +test "evaluateWithTarget: missing target rule -> deny" { + const policy = + "{\"type\":\"module\",\"rules\":[" ++ + "{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++ + "{\"type\":\"value\",\"value\":true}]}]}"; + // Policy only has `allow`; `allow_response` doesn't exist. + try testing.expect(!(try runWithTarget("{}", policy, "allow_response"))); +} + +test "evaluateWithTarget: allow target preserves default behaviour" { + const policy = "{\"type\":\"value\",\"value\":true}"; + try testing.expect(try runWithTarget("{}", policy, "allow")); +} + test "evaluate: every+some over arrays" { const policy = "{\"type\":\"every\",\"var\":\"req\"," ++ diff --git a/src/main.zig b/src/main.zig index 852ae65..2243c5b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -51,3 +51,26 @@ export fn evaluate( const decision = eval.evaluate(arena, input, ast_bytes) catch return -1; return if (decision) 1 else 0; } + +/// Run one evaluation against an explicit target rule. Same return +/// codes as `evaluate`. Hosts that want to drive the response-side +/// "allow_response" path (or any other target name) call this +/// instead of the default `evaluate`. +export fn evaluate_target( + input_ptr: [*]const u8, + input_len: usize, + ast_ptr: [*]const u8, + ast_len: usize, + target_ptr: [*]const u8, + target_len: usize, +) i32 { + defer memory.resetRequestArena(); + + const arena = memory.requestArena(); + const input = input_ptr[0..input_len]; + const ast_bytes = ast_ptr[0..ast_len]; + const target = target_ptr[0..target_len]; + + const decision = eval.evaluateWithTarget(arena, input, ast_bytes, target) catch return -1; + return if (decision) 1 else 0; +} diff --git a/src/proxy_wasm.zig b/src/proxy_wasm.zig index 860cd84..58c6073 100644 --- a/src/proxy_wasm.zig +++ b/src/proxy_wasm.zig @@ -3,8 +3,9 @@ //! Lifecycle: `proxy_on_vm_start`, `proxy_on_configure`, //! `proxy_on_context_create`, `proxy_on_request_headers`, //! `proxy_on_request_body`, `proxy_on_response_headers`, -//! `proxy_on_done`. Body and response callbacks are no-ops in this -//! revision; see ROADMAP.md. +//! `proxy_on_done`. Request headers fire the "allow" target rule; +//! response headers fire "allow_response" with `{"response":{...}}`. +//! Body callbacks are no-ops; see ROADMAP.md. //! //! Configuration: the policy AST JSON arrives via //! `proxy_on_configure`. We copy it into `host_allocator` so it @@ -149,12 +150,68 @@ export fn proxy_on_request_body(_: i32, _: i32, _: i32) i32 { return action_continue; } -/// No-op for now. Response-side policies need a different input -/// shape and a separate target rule. +/// Evaluate against response status + headers under the +/// `allow_response` target rule. Deny replaces the response with a +/// 503; allow lets the upstream response through unchanged. +/// +/// Request-side policy targeting "allow" runs in +/// `proxy_on_request_headers`; the two phases use disjoint target +/// rules so a single bundled policy can carry both. export fn proxy_on_response_headers(_: i32, _: i32, _: i32) i32 { + const policy = configured_policy orelse return action_continue; + if (!evaluateResponseAt(policy)) { + denyWithStatus(503); + } return action_continue; } +const response_target_rule: []const u8 = "allow_response"; + +fn evaluateResponseAt(policy: []const u8) bool { + const arena = memory.requestArena(); + defer memory.resetRequestArena(); + const allocator = arena.allocator(); + + const input_bytes = buildResponseInput(allocator) catch return false; + return eval.evaluateWithTarget(arena, input_bytes, policy, response_target_rule) catch false; +} + +/// Build `{"response":{"status":,"headers":{...}}}` from the +/// response header map. `:status` is fetched individually for the +/// same wamr-host reasons that drive the request-side path. +fn buildResponseInput(allocator: std.mem.Allocator) ![]u8 { + const status_str = (try readSingleHeader(allocator, map_type_response_headers, ":status")) orelse ""; + const headers = readAllHeaders(allocator, map_type_response_headers) catch &[_]HeaderPair{}; + + const status_num = std.fmt.parseInt(i32, status_str, 10) catch -1; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(allocator); + + try buf.appendSlice(allocator, "{\"response\":{\"status\":"); + if (status_num < 0) { + try buf.appendSlice(allocator, "null"); + } else { + var num_buf: [16]u8 = undefined; + const slice = std.fmt.bufPrint(&num_buf, "{d}", .{status_num}) catch unreachable; + try buf.appendSlice(allocator, slice); + } + + try buf.appendSlice(allocator, ",\"headers\":{"); + var first = true; + for (headers) |h| { + if (h.key.len > 0 and h.key[0] == ':') continue; + if (!first) try buf.append(allocator, ','); + first = false; + try appendJsonString(allocator, &buf, h.key); + try buf.append(allocator, ':'); + try appendJsonString(allocator, &buf, h.value); + } + try buf.appendSlice(allocator, "}}}"); + + return try allocator.dupe(u8, buf.items); +} + export fn proxy_on_done(_: i32) i32 { return result_ok; } diff --git a/test/run.mjs b/test/run.mjs index d89a186..48724d7 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -21,7 +21,7 @@ const { instance } = await WebAssembly.instantiate(bytes, { }, }); -const { malloc, free, evaluate, memory } = instance.exports; +const { malloc, free, evaluate, evaluate_target, memory } = instance.exports; const enc = new TextEncoder(); function writeBytes(bytes) { @@ -50,6 +50,19 @@ function decide(input, ast) { } } +function decideTarget(input, ast, target) { + const i = writeJson(input); + const a = writeJson(ast); + const t = writeBytes(enc.encode(target)); + try { + return evaluate_target(i.ptr, i.len, a.ptr, a.len, t.ptr, t.len); + } finally { + freeBuf(i); + freeBuf(a); + freeBuf(t); + } +} + let failed = 0; function check(name, got, expected) { if (got === expected) { @@ -550,6 +563,48 @@ const twoPackages = { check('modules bundle: default entry picks empty package', decide({ user: { role: 'admin' } }, twoPackages), 1); check('modules bundle: audit module invisible from default entry', decide({ user: { role: 'guest' } }, twoPackages), 0); +// --------------------------------------------------------------------------- +// 11. evaluate_target: response-side rules driven via the new export. +// +// proxy_on_response_headers in proxy_wasm.zig fires the +// "allow_response" target rule against an input shape with response +// status / headers. The generic `evaluate_target` export lets hosts +// reach the same eval path without going through proxy-wasm. +// --------------------------------------------------------------------------- +const responsePolicy = { + type: 'module', + rules: [ + { type: 'rule', name: 'allow_response', default: true, value: { type: 'value', value: true } }, + { + type: 'rule', + name: 'allow_response', + body: [ + { + type: 'gte', + left: { type: 'ref', path: ['input', 'response', 'status'] }, + right: { type: 'value', value: 500 }, + }, + ], + value: { type: 'value', value: false }, + }, + ], +}; +check( + 'evaluate_target allow_response: 500 -> deny (replace with 503)', + decideTarget({ response: { status: 500, headers: {} } }, responsePolicy, 'allow_response'), + 0, +); +check( + 'evaluate_target allow_response: 200 -> allow', + decideTarget({ response: { status: 200, headers: {} } }, responsePolicy, 'allow_response'), + 1, +); +check( + 'evaluate_target with missing target rule -> deny', + decideTarget({}, { type: 'value', value: true }, 'allow_response'), + 0, +); + if (failed > 0) { console.error(`\n${failed} test(s) failed`); exit(1); diff --git a/test/run_wasmtime.py b/test/run_wasmtime.py index d0b23d1..1adf53e 100644 --- a/test/run_wasmtime.py +++ b/test/run_wasmtime.py @@ -60,6 +60,7 @@ def build_instance() -> tuple[wasmtime.Store, wasmtime.Instance]: malloc = exports["malloc"] free = exports["free"] evaluate = exports["evaluate"] +evaluate_target = exports["evaluate_target"] memory: wasmtime.Memory = exports["memory"] @@ -88,6 +89,18 @@ def decide(input_obj, ast_obj) -> int: free(store, ap) +def decide_target(input_obj, ast_obj, target: str) -> int: + ip, il = write_json(input_obj) + ap, al = write_json(ast_obj) + tp, tl = write_bytes(target.encode("utf-8")) + try: + return evaluate_target(store, ip, il, ap, al, tp, tl) + finally: + free(store, ip) + free(store, ap) + free(store, tp) + + # --------------------------------------------------------------------------- # Tiny assertion helper. # --------------------------------------------------------------------------- @@ -410,6 +423,43 @@ def check(name: str, got, expected): check("modules bundle: default entry picks empty package", decide({"user": {"role": "admin"}}, two_packages), 1) check("modules bundle: audit module invisible from default entry", decide({"user": {"role": "guest"}}, two_packages), 0) +# --------------------------------------------------------------------------- +# 11. evaluate_target: response-side rules via the explicit-target export. +# --------------------------------------------------------------------------- +response_policy = { + "type": "module", + "rules": [ + {"type": "rule", "name": "allow_response", "default": True, "value": {"type": "value", "value": True}}, + { + "type": "rule", + "name": "allow_response", + "body": [ + { + "type": "gte", + "left": {"type": "ref", "path": ["input", "response", "status"]}, + "right": {"type": "value", "value": 500}, + } + ], + "value": {"type": "value", "value": False}, + }, + ], +} +check( + "evaluate_target allow_response: 500 -> deny", + decide_target({"response": {"status": 500, "headers": {}}}, response_policy, "allow_response"), + 0, +) +check( + "evaluate_target allow_response: 200 -> allow", + decide_target({"response": {"status": 200, "headers": {}}}, response_policy, "allow_response"), + 1, +) +check( + "evaluate_target with missing target rule -> deny", + decide_target({}, {"type": "value", "value": True}, "allow_response"), + 0, +) + if failed: print(f"\n{failed} test(s) failed", file=sys.stderr) sys.exit(1) From 76e0bf789f6f5a442d183acf4a33f83cb4f44dca Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 9 May 2026 23:55:23 +0900 Subject: [PATCH 3/3] docs(response): clarify v1 scope per Gemini Code Assist feedback Three medium-priority concerns from the review needed reconciliation between the design doc and the v1 implementation: 1. Replacement contract: doc claimed structured replacement (status / body / headers from the rule's value). v1 actually returns bool from the evaluator and does a fixed 503 on deny. The structured replacement requires the evaluator to surface json.Value plus a discriminator and is deferred to a follow-up PR. Doc now splits 'v1' (fixed 503) from 'deferred' (structured). 2. 503 vs 403 asymmetry: doc didn't say which deny code response-side uses, request-side already returns 403. v1 uses 503 on the response side (upstream being replaced) vs 403 on the request side (request rejected before upstream). Doc now states this explicitly as intentional. 3. Backward compat for nested input shape: doc showed '{request, response}' nested input which would have broken existing input.method / input.path refs. v1 keeps the request-side input flat (unchanged) and adds 'input.response.*' ONLY for the allow_response target rule. input.request.* is reserved for the v2 post-snapshot picture. Doc now shows v1 vs v2 input shapes separately and clarifies 'allow' policies are untouched. Per-context state section also corrected: v1 does not snapshot request fields. The snapshot lives behind body-aware-policies.md (PR #6) and shows up here only when that lands. --- docs/proposals/response-side-policies.md | 63 ++++++++++++++++++------ 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/docs/proposals/response-side-policies.md b/docs/proposals/response-side-policies.md index ddc214d..76e1df8 100644 --- a/docs/proposals/response-side-policies.md +++ b/docs/proposals/response-side-policies.md @@ -27,7 +27,9 @@ Use cases: 1. Implement `proxy_on_response_headers`: - Build an input shape reflecting response status and headers. - Evaluate the AST against the response target rule. - - On deny, replace the response (status + body + selected headers). + - On deny, replace the response with a fixed status (see + §Replacement contract for v1 behaviour and the deferred + structured replacement). - On allow, fall through. 1. Reuse the existing AST machinery. No new node types are needed for v1; only a new target rule and a new input shape. @@ -46,9 +48,9 @@ Use cases: ### Per-context state -Borrow the snapshot from `body-aware-policies.md`: a per-context store -that already holds request `:method` and `:path` so the response rule -can reason about request → response pairs. +Eventually we want a per-context store that already holds request +`:method` / `:path` so the response rule can reason about +request → response pairs (sketched in `body-aware-policies.md`): ```zig const ResponseContext = struct { @@ -58,15 +60,21 @@ const ResponseContext = struct { }; ``` +**v1 does not implement the snapshot.** The response rule sees only +the response side. `input.request.*` becomes valid once the +per-context plumbing lands; until then, body / response policies +that need request context have to encode it via the host. + ### Input shape +v1 (this PR) ships **only** the response subtree. The request-side +`allow` rule keeps its existing flat shape (`input.method`, +`input.path`, `input.headers`). They are disjoint inputs because +they target disjoint rules. + ```json +// allow_response input (this PR) { - "request": { - "method": "GET", - "path": "/admin/users", - "headers": { "...": "..." } - }, "response": { "status": 500, "headers": { "server": "nginx/1.27", "...": "..." } @@ -74,10 +82,29 @@ const ResponseContext = struct { } ``` +The wider shape with both `input.request.*` and `input.response.*` +visible together is the post-snapshot v2 picture and is not exposed +yet: + +```json +// v2 (deferred): both subtrees once the snapshot lands +{ + "request": { "method": "GET", "path": "/admin/users", "headers": {} }, + "response": { "status": 500, "headers": {} } +} +``` + ### Replacement contract -When the response rule denies, zopa calls `proxy_send_local_response` -with parameters drawn from the rule's `value`: +**v1**: rule denies (returns `false` or `nil`) → zopa calls +`proxy_send_local_response(503, ...)` with empty body / no extra +headers. Allow falls through unchanged. The deny status code is +**503** here (the upstream response is being replaced) vs **403** +on the request-side `allow` path (the request is being rejected +before it reaches upstream). The asymmetry is intentional. + +**Deferred**: rule returns a structured value carrying status / body +/ headers, e.g. ```json { @@ -90,14 +117,20 @@ with parameters drawn from the rule's `value`: } ``` -A bare boolean `false` keeps current behavior (replace with a static -`503` and an empty body). +This needs the evaluator to surface a `json.Value` (not a `bool`) +to the proxy-wasm shim, plus a discriminator so the shim can tell +"the policy returned a structured replacement" from "the policy +returned a non-boolean truthy value (= allow)". Both changes are +non-trivial and deferred to a follow-up PR. ## API impact - New target rule name `allow_response` joins the existing `allow`. -- Existing `allow` policies continue to work unchanged. -- New AST refs become valid: `input.request.*`, `input.response.*`. +- Existing `allow` policies continue to work unchanged: same input + shape (`input.method` / `input.path` / `input.headers`), same + return contract, same 403 deny status. +- v1 adds the `input.response.*` ref namespace under `allow_response`. + `input.request.*` is reserved for v2 once per-context state lands. ## Test plan