Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions docs/proposals/response-side-policies.md
Original file line number Diff line number Diff line change
@@ -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: <internal-build>`", 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.
Comment thread
kanywst marked this conversation as resolved.
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.
66 changes: 64 additions & 2 deletions src/eval.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = ""`.
Expand Down Expand Up @@ -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\"," ++
Expand Down
23 changes: 23 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
65 changes: 61 additions & 4 deletions src/proxy_wasm.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":<int>,"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;
}
Expand Down
Loading
Loading