Skip to content

Commit fe45fc6

Browse files
committed
feat(body): allow_body target rule + body-aware proxy_on_request_body
eval.zig: - New evaluateWithTarget(arena, input, ast, target_rule). evaluate() becomes a thin wrapper targeting 'allow'. main.zig: - New evaluate_target wasm export with explicit target_rule pointer / length pair, mirroring the request-target ABI. proxy_wasm.zig: - proxy_on_request_body waits for end_of_stream then reads up to max_body_bytes (64 KiB) via proxy_get_buffer_bytes. - Builds {body: <parsed-or-null>, body_raw: <string>}: tries JSON parse first; on failure 'body' is null and 'body_raw' carries the raw bytes so policies can still match e.g. with the upcoming 'contains' builtin. - Evaluates 'allow_body' target rule. Deny -> 403 + Action.Pause. - Header-side path stays on 'allow', unchanged. Note on header context: hosts (Envoy/wamr in particular) clear :method / :path from the header map by the time the body callback fires, so a body rule that needs request method must depend on a snapshot taken in proxy_on_request_headers. Per-context snapshot plumbing is intentionally out of scope for v1; v1 surfaces only the body itself. Tracked in the design doc. Tests: - src/eval.zig: 3 unit cases (amount-over-limit denies, missing target denies, body_raw fallback). - test/run.mjs and test/run_wasmtime.py: 4 cases each driving the new evaluate_target export end-to-end through the wasm boundary. Release build grows from 50K to 54K. ci.yml gains test-unit job in line with the other implementation branches.
1 parent 096bd30 commit fe45fc6

5 files changed

Lines changed: 274 additions & 14 deletions

File tree

src/eval.zig

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ const Scope = struct {
4646
///
4747
/// Targets the default package ("") and the default rule ("allow").
4848
/// Use `evaluateWithTarget` to pick a non-default rule (e.g.
49-
/// "allow_response"), or `evaluateAddressed` to dispatch into a
50-
/// specific `package.rule` pair within a `{"type":"modules", ...}`
51-
/// bundle.
49+
/// "allow_response" or "allow_body"), or `evaluateAddressed` to
50+
/// dispatch into a specific `package.rule` pair within a
51+
/// `{"type":"modules", ...}` bundle.
5252
pub fn evaluate(
5353
arena: *std.heap.ArenaAllocator,
5454
input_json: []const u8,
@@ -58,9 +58,10 @@ pub fn evaluate(
5858
}
5959

6060
/// Run a single evaluation against `target_rule` in the default
61-
/// package (""). Used by the proxy-wasm shim to route the
62-
/// response-phase callback to the `allow_response` rule while
63-
/// keeping the request-phase on `allow`.
61+
/// package (""). Used by the proxy-wasm shim to route phase-specific
62+
/// callbacks: `allow_response` for the response phase and
63+
/// `allow_body` for the body phase, while the request-headers phase
64+
/// stays on `allow`.
6465
pub fn evaluateWithTarget(
6566
arena: *std.heap.ArenaAllocator,
6667
input_json: []const u8,
@@ -612,6 +613,58 @@ test "evaluateWithTarget: allow target preserves default behaviour" {
612613
try testing.expect(try runWithTarget("{}", policy, "allow"));
613614
}
614615

616+
test "evaluateWithTarget: allow_body fires on amount > limit" {
617+
const policy =
618+
"{\"type\":\"module\",\"rules\":[" ++
619+
"{\"type\":\"rule\",\"name\":\"allow_body\",\"default\":true," ++
620+
"\"value\":{\"type\":\"value\",\"value\":true}}," ++
621+
"{\"type\":\"rule\",\"name\":\"allow_body\",\"body\":[" ++
622+
"{\"type\":\"gt\"," ++
623+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"body\",\"amount\"]}," ++
624+
"\"right\":{\"type\":\"value\",\"value\":1000}}]," ++
625+
"\"value\":{\"type\":\"value\",\"value\":false}}" ++
626+
"]}";
627+
628+
// Body amount over limit -> rule fires returning false -> deny.
629+
try testing.expect(!(try runWithTarget(
630+
"{\"body\":{\"amount\":5000},\"body_raw\":\"...\"}",
631+
policy,
632+
"allow_body",
633+
)));
634+
635+
// Body amount under limit -> default rule wins -> allow.
636+
try testing.expect(try runWithTarget(
637+
"{\"body\":{\"amount\":50},\"body_raw\":\"...\"}",
638+
policy,
639+
"allow_body",
640+
));
641+
}
642+
643+
test "evaluateWithTarget: body_raw fallback when body parse fails" {
644+
// Policy targets body_raw directly so a non-JSON body is still
645+
// policy-checkable.
646+
const policy =
647+
"{\"type\":\"module\",\"rules\":[" ++
648+
"{\"type\":\"rule\",\"name\":\"allow_body\",\"body\":[" ++
649+
"{\"type\":\"eq\"," ++
650+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"body_raw\"]}," ++
651+
"\"right\":{\"type\":\"value\",\"value\":\"BLOCKED\"}}]," ++
652+
"\"value\":{\"type\":\"value\",\"value\":false}}," ++
653+
"{\"type\":\"rule\",\"name\":\"allow_body\",\"default\":true," ++
654+
"\"value\":{\"type\":\"value\",\"value\":true}}" ++
655+
"]}";
656+
try testing.expect(!(try runWithTarget(
657+
"{\"body\":null,\"body_raw\":\"BLOCKED\"}",
658+
policy,
659+
"allow_body",
660+
)));
661+
try testing.expect(try runWithTarget(
662+
"{\"body\":null,\"body_raw\":\"ok\"}",
663+
policy,
664+
"allow_body",
665+
));
666+
}
667+
615668
test "evaluate: every+some over arrays" {
616669
const policy =
617670
"{\"type\":\"every\",\"var\":\"req\"," ++

src/main.zig

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ export fn evaluate(
5353
}
5454

5555
/// Run one evaluation against an explicit target rule. Same return
56-
/// codes as `evaluate`. Hosts that want to drive the response-side
57-
/// "allow_response" path (or any other target name) call this
58-
/// instead of the default `evaluate`.
56+
/// codes as `evaluate`. Hosts that want to drive a non-default rule
57+
/// (`allow_response` for the response phase, `allow_body` for the
58+
/// body phase, or any other target name) call this instead of the
59+
/// default `evaluate`.
5960
export fn evaluate_target(
6061
input_ptr: [*]const u8,
6162
input_len: usize,

src/proxy_wasm.zig

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
//! `proxy_on_context_create`, `proxy_on_request_headers`,
55
//! `proxy_on_request_body`, `proxy_on_response_headers`,
66
//! `proxy_on_done`. Request headers fire the "allow" target rule;
7+
//! request body fires "allow_body" with `{"body": <parsed-json>,
8+
//! "body_raw": <string>}` once the host signals end of stream;
79
//! response headers fire "allow_response" with `{"response":{...}}`.
8-
//! Body callbacks are no-ops; see ROADMAP.md.
910
//!
1011
//! Configuration: the policy AST JSON arrives via
1112
//! `proxy_on_configure`. We copy it into `host_allocator` so it
@@ -16,8 +17,9 @@
1617
//! `malloc`. We `hostFree` them once consumed.
1718

1819
const std = @import("std");
19-
const memory = @import("memory.zig");
2020
const eval = @import("eval.zig");
21+
const json = @import("json.zig");
22+
const memory = @import("memory.zig");
2123

2224
// ABI version negotiation: one empty export per supported version.
2325

@@ -144,12 +146,88 @@ export fn proxy_on_request_headers(_: i32, _: i32, _: i32) i32 {
144146
return action_continue;
145147
}
146148

147-
/// No-op for now. Keeps the symbol resolvable when the filter
148-
/// declares body interest.
149-
export fn proxy_on_request_body(_: i32, _: i32, _: i32) i32 {
149+
/// Evaluate against the request body once the host signals end of
150+
/// stream. Until then we return `Continue` so streaming chunks pass
151+
/// through; the final fragment triggers the eval. Body input shape
152+
/// is `{"body": <parsed-or-null>, "body_raw": <string>}`.
153+
///
154+
/// Hosts clear `:method` / `:path` from the header map by the time
155+
/// this fires (Envoy/wamr behaviour), so a body rule that needs
156+
/// header context must depend on a snapshot taken in
157+
/// `proxy_on_request_headers`. Per-context snapshot plumbing is
158+
/// tracked in ROADMAP.md; v1 surfaces only the body itself.
159+
export fn proxy_on_request_body(_: i32, body_size: i32, end_of_stream: i32) i32 {
160+
if (end_of_stream == 0) return action_continue;
161+
if (body_size <= 0) return action_continue;
162+
const policy = configured_policy orelse return action_continue;
163+
if (!evaluateBodyAt(@intCast(body_size), policy)) {
164+
denyWithStatus(403);
165+
return action_pause;
166+
}
150167
return action_continue;
151168
}
152169

170+
const body_target_rule: []const u8 = "allow_body";
171+
const max_body_bytes: usize = 64 * 1024;
172+
173+
fn evaluateBodyAt(body_size: usize, policy: []const u8) bool {
174+
const arena = memory.requestArena();
175+
defer memory.resetRequestArena();
176+
const allocator = arena.allocator();
177+
178+
const cap = if (body_size > max_body_bytes) max_body_bytes else body_size;
179+
const body_bytes = readBodyBytes(allocator, cap) catch return false;
180+
const input_bytes = buildBodyInput(allocator, body_bytes) catch return false;
181+
return eval.evaluateWithTarget(arena, input_bytes, policy, body_target_rule) catch false;
182+
}
183+
184+
/// Pull the request body from the host. Returns an empty slice on
185+
/// host error so the caller sees a body of "" rather than failing
186+
/// the request outright.
187+
fn readBodyBytes(allocator: std.mem.Allocator, cap: usize) ![]const u8 {
188+
var data: ?[*]u8 = null;
189+
var data_size: usize = 0;
190+
const status = proxy_get_buffer_bytes(
191+
buffer_type_http_request_body,
192+
0,
193+
cap,
194+
&data,
195+
&data_size,
196+
);
197+
if (status != status_ok) return &[_]u8{};
198+
if (data_size == 0) return &[_]u8{};
199+
const ptr = data orelse return &[_]u8{};
200+
defer memory.hostFree(ptr);
201+
return try allocator.dupe(u8, ptr[0..data_size]);
202+
}
203+
204+
/// Build `{"body": <parsed-json-or-null>, "body_raw": <string>}`. We
205+
/// try to parse the body as JSON; if it fails, `body` is null and
206+
/// the policy can still match against `body_raw` (e.g. with the
207+
/// `contains` builtin). The parsed copy is dropped on the next
208+
/// arena reset, so this only costs one transient walk.
209+
fn buildBodyInput(allocator: std.mem.Allocator, body: []const u8) ![]u8 {
210+
const parsed_ok = blk: {
211+
_ = json.parse(allocator, body) catch break :blk false;
212+
break :blk true;
213+
};
214+
215+
var buf: std.ArrayList(u8) = .empty;
216+
defer buf.deinit(allocator);
217+
218+
try buf.appendSlice(allocator, "{\"body\":");
219+
if (parsed_ok and body.len > 0) {
220+
try buf.appendSlice(allocator, body);
221+
} else {
222+
try buf.appendSlice(allocator, "null");
223+
}
224+
try buf.appendSlice(allocator, ",\"body_raw\":");
225+
try appendJsonString(allocator, &buf, body);
226+
try buf.append(allocator, '}');
227+
228+
return try allocator.dupe(u8, buf.items);
229+
}
230+
153231
/// Evaluate against response status + headers under the
154232
/// `allow_response` target rule. Deny replaces the response with a
155233
/// 503; allow lets the upstream response through unchanged.

test/run.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,73 @@ check(
605605
0,
606606
);
607607

608+
// ---------------------------------------------------------------------------
609+
// 12. evaluate_target: body-side rules driven via the new export.
610+
//
611+
// proxy_on_request_body in proxy_wasm.zig fires the "allow_body"
612+
// target rule against {body, body_raw}. The generic evaluate_target
613+
// export lets hosts reach the same eval path without proxy-wasm.
614+
// ---------------------------------------------------------------------------
615+
const bodyPolicy = {
616+
type: 'module',
617+
rules: [
618+
{ type: 'rule', name: 'allow_body', default: true, value: { type: 'value', value: true } },
619+
{
620+
type: 'rule',
621+
name: 'allow_body',
622+
body: [
623+
{
624+
type: 'gt',
625+
left: { type: 'ref', path: ['input', 'body', 'amount'] },
626+
right: { type: 'value', value: 1000 },
627+
},
628+
],
629+
value: { type: 'value', value: false },
630+
},
631+
],
632+
};
633+
check(
634+
'evaluate_target allow_body: amount over limit -> deny',
635+
decideTarget({ body: { amount: 5000 }, body_raw: '{"amount":5000}' }, bodyPolicy, 'allow_body'),
636+
0,
637+
);
638+
check(
639+
'evaluate_target allow_body: amount under limit -> allow',
640+
decideTarget({ body: { amount: 50 }, body_raw: '{"amount":50}' }, bodyPolicy, 'allow_body'),
641+
1,
642+
);
643+
644+
// body_raw fallback: policies can match on the raw bytes when the
645+
// body did not parse as JSON.
646+
const rawPolicy = {
647+
type: 'module',
648+
rules: [
649+
{
650+
type: 'rule',
651+
name: 'allow_body',
652+
body: [
653+
{
654+
type: 'eq',
655+
left: { type: 'ref', path: ['input', 'body_raw'] },
656+
right: { type: 'value', value: 'BLOCKED' },
657+
},
658+
],
659+
value: { type: 'value', value: false },
660+
},
661+
{ type: 'rule', name: 'allow_body', default: true, value: { type: 'value', value: true } },
662+
],
663+
};
664+
check(
665+
'evaluate_target allow_body: body_raw=BLOCKED -> deny',
666+
decideTarget({ body: null, body_raw: 'BLOCKED' }, rawPolicy, 'allow_body'),
667+
0,
668+
);
669+
check(
670+
'evaluate_target allow_body: body_raw=ok -> allow',
671+
decideTarget({ body: null, body_raw: 'ok' }, rawPolicy, 'allow_body'),
672+
1,
673+
);
674+
608675
if (failed > 0) {
609676
console.error(`\n${failed} test(s) failed`);
610677
exit(1);

test/run_wasmtime.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,67 @@ def check(name: str, got, expected):
460460
0,
461461
)
462462

463+
# ---------------------------------------------------------------------------
464+
# 12. evaluate_target: body-side rules via the explicit-target export.
465+
# ---------------------------------------------------------------------------
466+
body_policy = {
467+
"type": "module",
468+
"rules": [
469+
{"type": "rule", "name": "allow_body", "default": True, "value": {"type": "value", "value": True}},
470+
{
471+
"type": "rule",
472+
"name": "allow_body",
473+
"body": [
474+
{
475+
"type": "gt",
476+
"left": {"type": "ref", "path": ["input", "body", "amount"]},
477+
"right": {"type": "value", "value": 1000},
478+
}
479+
],
480+
"value": {"type": "value", "value": False},
481+
},
482+
],
483+
}
484+
check(
485+
"evaluate_target allow_body: amount over limit -> deny",
486+
decide_target({"body": {"amount": 5000}, "body_raw": "{\"amount\":5000}"}, body_policy, "allow_body"),
487+
0,
488+
)
489+
check(
490+
"evaluate_target allow_body: amount under limit -> allow",
491+
decide_target({"body": {"amount": 50}, "body_raw": "{\"amount\":50}"}, body_policy, "allow_body"),
492+
1,
493+
)
494+
495+
raw_policy = {
496+
"type": "module",
497+
"rules": [
498+
{
499+
"type": "rule",
500+
"name": "allow_body",
501+
"body": [
502+
{
503+
"type": "eq",
504+
"left": {"type": "ref", "path": ["input", "body_raw"]},
505+
"right": {"type": "value", "value": "BLOCKED"},
506+
}
507+
],
508+
"value": {"type": "value", "value": False},
509+
},
510+
{"type": "rule", "name": "allow_body", "default": True, "value": {"type": "value", "value": True}},
511+
],
512+
}
513+
check(
514+
"evaluate_target allow_body: body_raw=BLOCKED -> deny",
515+
decide_target({"body": None, "body_raw": "BLOCKED"}, raw_policy, "allow_body"),
516+
0,
517+
)
518+
check(
519+
"evaluate_target allow_body: body_raw=ok -> allow",
520+
decide_target({"body": None, "body_raw": "ok"}, raw_policy, "allow_body"),
521+
1,
522+
)
523+
463524
if failed:
464525
print(f"\n{failed} test(s) failed", file=sys.stderr)
465526
sys.exit(1)

0 commit comments

Comments
 (0)