diff --git a/docs/proposals/response-side-policies.md b/docs/proposals/response-side-policies.md new file mode 100644 index 0000000..76e1df8 --- /dev/null +++ b/docs/proposals/response-side-policies.md @@ -0,0 +1,154 @@ +# 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 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. + +## 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 + +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 { + request: *RequestContext, // shared with request-side eval + status: u32 = 0, + headers: ?json.Value = null, +}; +``` + +**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) +{ + "response": { + "status": 500, + "headers": { "server": "nginx/1.27", "...": "..." } + } +} +``` + +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 + +**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 +{ + "type": "value", + "value": { + "status": 503, + "body": "service temporarily unavailable", + "headers": { "retry-after": "30" } + } +} +``` + +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: 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 + +- 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. 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)