An OPA ext_authz rule matched a URL path exactly; adding ?page=2 now returns 403. Why?
answer
- compare what the rule actually compared
- path is the raw request target
- the query string rides along with it
- parsed_path is split and decoded
- empty-bodied 403 is the default denial
basics
~10 sBecause input.attributes.request.http.path is the raw request target and still carries the query string, so the equality test against "/api/v1/orders" fails. Match input.parsed_path instead, which is query-free and percent-decoded.
solid answer
~40 sThe rule compares `input.attributes.request.http.path` to a literal, and that field is the request target exactly as it arrived — `"/api/v1/orders?page=2"`, not `"/api/v1/orders"`. The comparison is false, no other rule grants the request, the decision falls through to `default allow := false`, and Envoy returns its default denial, a 403 with an empty body, which is why the caller sees nothing explaining it. The fix is to match `input.parsed_path == ["api", "v1", "orders"]`: the plugin builds that array by dropping the query string, splitting on `/` and percent-decoding each segment. The same bug has a sharper edge on the deny side — a deny rule written against the raw path can be evaded by percent-encoding a character, since `/adm%69n` is not the string `/admin` but does decode to the `admin` segment.
code
rego · 15 linespackage envoy.authz
default allow := false
# BUG: path is the raw target, so it is "/api/v1/orders?page=2"
allow if {
input.attributes.request.http.method == "GET"
input.attributes.request.http.path == "/api/v1/orders"
}
# FIX: parsed_path drops the query and decodes each segment
allow if {
input.attributes.request.http.method == "GET"
input.parsed_path == ["api", "v1", "orders"]
}go deeper
Know that the path field carries the query string and the encoding as sent, and that the plugin offers a decoded, query-free version of it for matching.
Explain the whole chain: failed comparison, rule body fails, default deny, proxy returns its stock 403 with no body — and give the segment-matching fix.
Show the security direction of the same bug: a deny rule reading a representation the upstream does not act on is bypassable, and prove you would confirm the diagnosis from the decision log rather than by guessing.
Own the standard: agree one canonical way for every team's rules to match routes, so the platform is not carrying a hundred bespoke string comparisons that each fail differently.
## What the developer actually sees A service call that worked all week starts returning `403` with an empty body the moment a query parameter is added. Nothing in the response says which rule refused it or why, because a denial from external authorization is just Envoy's default rejection: status 403, no body, unless the policy went out of its way to shape one. The service logs show no request at all, because the request never reached the service — it was stopped at the proxy. ## The mechanism The policy is queried once per request with an `input` built from the proxy's check request. `input.attributes.request.http.path` is the **raw request target**: the path *and* the query string, still percent-encoded, exactly as the client sent it. So for `GET /api/v1/orders?page=2` that field is the string `"/api/v1/orders?page=2"`. An equality test against `"/api/v1/orders"` is simply false. In Rego a failed expression makes the rule body fail; the rule produces nothing; with a `default allow := false` in the document, the answer is a deny. This is why the OPA Envoy plugin adds `input.parsed_path`. It takes the target, discards everything from the `?` onward, splits the remaining path on `/`, and percent-decodes each segment, producing `["api", "v1", "orders"]`. Comparing that array to a list of segments is stable against query strings, against encoding, and against a caller who appends a fragment or an extra parameter. ## The dangerous direction On an allow rule this bug is merely annoying: a legitimate request is refused, someone gets paged, the rule is fixed. On a **deny** rule the same mismatch is a security hole, and it is the version an interviewer will push you toward. Suppose a rule denies anything whose raw path starts with `/admin`. A caller requests `/adm%69n/reset`. As a string, that does not start with `/admin`, so the deny does not fire. But the proxy and the upstream service will normalise and decode that target on the way to routing and handling it, and the request lands on the admin handler. The rule and the thing it was protecting disagreed about what the path was. Matching `input.parsed_path[0] == "admin"` closes that gap, because the decoding happens before your comparison rather than after it. The mirror-image mistake is a prefix test: `startswith(path, "/admin")` also matches `/administrators/export`, quietly widening the rule to a route nobody intended to gate. Segment comparison does not have that failure mode either — `parsed_path[0] == "admin"` is true for `/admin/reset` and false for `/administrators/export`. ## Fixing it, and confirming the fix The corrected rule matches on segments and, where the request may legitimately carry parameters, reads them from `input.parsed_query`, which maps each parameter name to an array of values (a name can repeat). Note also that indexing a segment that does not exist — `input.parsed_path[2]` on a two-segment path — makes the expression undefined, so the whole rule body fails. That is the correct behaviour for an allow rule and a trap for a deny rule, which is another argument for expressing gates as positive allows over an explicit default deny. To confirm what the policy actually saw rather than what you assume it saw, read OPA's decision log for the refused request: it records the `input` document and the result together. That single habit turns "the gate is broken" into "the gate compared `/api/v1/orders?page=2` to `/api/v1/orders`" in about a minute, and it is the difference between debugging a policy and arguing about one. ## The lesson to state out loud A policy decides on a *representation* of the request. Every place where the representation the rule reads differs from the representation the upstream acts on is a bug: query strings, percent-encoding, case, trailing slashes, duplicate parameters. Prefer the fields the plugin has already normalised, and write matches that are exact about structure rather than clever about strings.
- Turn it around: the same rule written as a deny. What is the risk?A deny on the raw path can be evaded. `/adm%69n/reset` is not the string `/admin/reset`, so a string-prefix deny never fires, yet the target is decoded on the way to routing and the request reaches the admin handler. Matching `input.parsed_path[0] == "admin"` compares after decoding, so the rule and the service agree on what the path is.
- Why is startswith on the path a poor way to gate /admin?It matches by characters, not by structure, so it also catches `/administrators/export` — a different route pulled into the gate by accident — while still missing an encoded variant of the real one. A segment comparison against `input.parsed_path` is exact in both directions: true for `/admin/reset`, false for `/administrators/export`.
- How would you find this in production without adding logging to the policy?Read OPA's decision log for the refused request. It records the input document that was evaluated alongside the result, so you can see the exact path string the rule compared instead of inferring it. That turns a vague "the gate is broken" report into a one-line diff between what the rule expected and what arrived.
saying these in an interview costs you the question
- Claiming request.http.path excludes the query string
- Assuming the raw path is already percent-decoded
- Gating a route with a string prefix test
- Blaming the proxy before reading the decision log
- Treating an empty-bodied 403 as evidence the service refused it