feat: replace legacy /update_policy* 307 redirects with direct gated calls (PER-15246)#320
Conversation
…calls (PER-15246) The gated aliases /update_policy and /update_policy_data 307-redirected to the canonical OPAL trigger routes. Many HTTP clients (requests, httpx, browsers) strip Authorization on redirect, so once the canonical routes are gated by the upcoming default-deny middleware (PER-15245), a legitimate SDK calling the alias with a token would get 307 -> token dropped -> 401. Call the OPAL updaters directly instead of redirecting, mirroring the canonical handlers (policy_updater.trigger_update_policy / data_updater.get_base_policy_data, 503 when the data updater is disabled). Keep the per-route enforce_pdp_token gate and drop the now-unused RedirectResponse import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR removes the legacy POST /update_policy* 307 redirect behavior and replaces it with direct in-process calls to the OPAL updaters, preventing Authorization header loss on redirect (important once canonical trigger routes become gated).
Changes:
- Replace
RedirectResponse-based legacy routes with directawaitcalls topolicy_updater.trigger_update_policy(force_full_update=True)anddata_updater.get_base_policy_data(...). - Add a
503HTTPExceptionfor/update_policy_datawhendata_updateris disabled. - Add a new test module covering success, auth failure behavior, disabled-updater behavior, and non-redirect behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| horizon/pdp.py | Replaces legacy redirect handlers with direct updater calls and adds a 503 guard for disabled data_updater. |
| horizon/tests/test_legacy_update_routes.py | Adds tests to validate legacy routes’ auth gating and updater invocation behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Assert the 503 detail string matches the canonical data route exactly. - Use httpx `is_redirect` instead of `!= 307` so the no-redirect guard covers every redirect code (301/302/303/307/308), since clients drop Authorization on all of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🔍 Vulnerabilities of
|
| digest | sha256:26c335ae98b023b4b402bcc08a42957cd302666fa8364f3dc214a8b7fd272ad3 |
| vulnerabilities | |
| platform | linux/amd64 |
| size | 218 MB |
| packages | 247 |
📦 Base Image python:3.10-alpine3.22
| also known as |
|
| digest | sha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd |
| vulnerabilities |
Description
Description
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
|
The canonical OPAL trigger handlers log the API-originated trigger; the direct-call rewrite dropped that, leaving SDK-triggered full re-pulls unattributable in PDP logs during incident debugging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Automated review — PER-15246 (replace legacy /update_policy* 307 redirects with direct gated calls)
What this PR does: In horizon/pdp.py, the two legacy alias routes /update_policy and /update_policy_data no longer return a RedirectResponse (307) to the canonical /policy-updater/trigger / /data-updater/trigger. They now invoke the OPAL updater methods inline: legacy_trigger_policy_update calls policy_updater.trigger_update_policy(force_full_update=True); legacy_trigger_data_update returns 503 if data_updater is None, else calls data_updater.get_base_policy_data(...); both return {"status": "ok"} (200). Motivation: 307 redirects make many HTTP clients drop the Authorization header, so once the canonical routes are gated an SDK hitting an alias would 307 -> lose its token -> 401. Adds a new test file horizon/tests/test_legacy_update_routes.py.
Verdict: REQUEST_CHANGES. One Postable HIGH finding: the new test file aborts the entire pytest collection in CI (ModuleNotFoundError: No module named 'horizon.tests', "Interrupted: 1 error during collection", exit 2) — no tests run, the fix is unverified, and merging would break pytests on main for all PRs.
The production change itself is correct and faithful (verified against opal_client's canonical handlers in policy/api.py and data/api.py):
trigger_update_policy(force_full_update=True)+{"status":"ok"}+ 200 exactly match/policy-updater/trigger.- The
data_updater is None -> 503path uses the exact canonical detail string ("Data Updater is currently disabled. Dynamic data updates are not available."); the only deliberate difference isdata_fetch_reason="request from sdk (legacy alias)"(canonical uses "request from sdk"), for telemetry. - No redirect, no double-execution; both aliases remain gated with
Depends(enforce_pdp_token). - Caveat: the canonical handlers were read from the local
opalcheckout (may differ slightly from the pinnedopal-client==0.9.6), but the log-line and 503-detail strings match exactly, indicating fidelity.
Findings
Postable
| # | Sev | File:Line | Category | Description |
|---|---|---|---|---|
| 1 | HIGH | horizon/tests/test_legacy_update_routes.py:6 | Testing / CI | from horizon.tests.test_enforcer_api import MockPermitPDP -> ModuleNotFoundError: No module named 'horizon.tests' (no __init__.py, non-editable install) interrupts ALL pytest collection (exit 2); no tests run and merging breaks main's pytests. |
| 2 | MEDIUM | horizon/tests/test_legacy_update_routes.py:40,72 | Cross-PR interaction | Asserts 422 for a missing auth header; sibling PR #317 flips that to 401 (adds = None to enforce_pdp_token). Whichever merges second breaks these assertions. |
Informational
| # | Sev | Ref | Category | Description |
|---|---|---|---|---|
| I1 | INFO | pdp-tester / docker-scout / security-snyk (CI) | Pre-existing / infra | pdp-tester red = pre-existing k3d-action 404 breakage (this branch lacks the Docker-runtime migration in #317/#318/#319); docker-scout red = residual base-image CVEs; security/snyk red = quota. None caused by this PR. |
| I2 | INFO | horizon/pdp.py legacy_trigger_policy_update | Parity note | Like the canonical route, the policy alias does not None-check policy_updater (only the data alias None-checks data_updater). This matches canonical behavior — intentional parity, not a gap. |
| I3 | INFO | Copilot threads (line 65, redirect guard) | Dedup | Copilot's earlier asks (assert the exact 503 detail string; guard against all redirect codes not just 307) were both addressed by the author (.is_redirect, exact detail assertion). Not re-raised. |
Blast radius: Behavioral — clients calling the legacy aliases now get a direct 200 instead of a 307, so the token is no longer at risk of being stripped. The test-collection break (finding 1) has repo-wide CI blast radius once merged.
Isolation / scope: Well-isolated — only pdp.py + one new test file, no unrelated churn (contrast with #317, which bundled CI changes).
- Import MockPermitPDP by basename (from test_enforcer_api) instead of horizon.tests.*: CI installs the package non-editably, so the wheel has no tests/ package and the dotted import aborted all pytest collection. Basename matches pytest's prepend import mode and also avoids a duplicate module object (second OpalClient construction) in local full-suite runs. - Accept 401 or 422 for a missing Authorization header: 422 on current main (required-param validation), 401 once PR #317 gives the param a None default. Survives either merge order; still fails on 200/500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aioresponses 0.7.x cannot mock aiohttp>=3.14 (ClientResponse gained a required stream_writer argument), which fails 34 enforcer/local-api tests in CI. Identical to the pin in #317 so either merge order resolves cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…licy-307-redirects-with-direct
- The fixture comment claimed monkeypatch mutations could leak across modules; monkeypatch reverts at teardown, so state the real rationale (defensive isolation from the shared singleton). - httpx's is_redirect covers any 3xx, not just the five common codes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PER-15358 (#318, now merged into this branch) fixes the aioresponses/ aiohttp-3.14 incompatibility with a stream_writer compat shim in horizon/tests/conftest.py, deliberately keeping the test env on the CVE-patched 3.14 line. The pin (mirrored from #317 before #318 landed) would force CI back to 3.13.x, bypass the shim, and reintroduce the dev/prod version skew. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…licy-307-redirects-with-direct
…licy-307-redirects-with-direct
…licy-307-redirects-with-direct
omer9564
left a comment
There was a problem hiding this comment.
This doesn't solve the actual vulnerabilities :
Unauthenticated Forced Policy/Data Re-Sync on All Managed Customer
PDPs — Missing Authorization on /policy-updater/trigger and
/data-updater/trigger
Vulnerability type: Missing Authentication / Broken Access Control
(CWE-306, CWE-285)
Affected asset: the managed customer Policy Decision Point (PDP)
fleet — e.g. aenetworks-prod[.]pdp[.]us-east-1[.]permit[.]io and
every open managed PDP host of the form
<tenant>[.]pdp[.]{us-east-1,us-east-2}[.]permit[.]io. Confirmed on 7
open production customer PDPs (A&E Networks, Allegion,
Wingwork/Getwingwork, Iofinnet).
Severity: Medium — CVSS 5.3
(CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L). At sustained scale the
Availability impact rises (~7.5); the higher figure is not provable
without flooding real customer runtimes, so the conservative proven
score is reported.
Program: Permit.io security — Ze'ev Manilovich <zeev[(@)]permit[.]io>
(URLs/IPs defanged — re-fang before submitting.)
Relationship to prior reports (please read)
A previously-reported issue concerned an unauthenticated dev-mode
login backdoor on the staging host api[.]stg[.]permit[.]io (now
remediated, returns 404). This report concerns a different
surface — the production managed-customer PDP fleet
(*.pdp.{us-east-1,us-east-2}.[permit.io](http://permit.io/)) — and a different
vulnerability class (missing authorization on operational control
endpoints, not authentication backdoor). Unlike the staging host,
these PDPs serve real, production customers.
Summary
On every open managed customer PDP, two operational endpoints — POST /policy-updater/trigger and POST /data-updater/trigger — accept
requests with no Authorization header and return HTTP 200 {"status":"ok"}, causing the PDP to forcibly re-pull its policy/data
from the Permit control plane. Every other operational route on the
same hosts correctly rejects unauthenticated requests with 401
(/policy-store/config, /local/role_assignments, /callbacks,
/facts/*, the decision routes). An attacker who can resolve a
customer's PDP hostname (enumerable via the customer's DNS / your SSO
lookup — see separate report) can force arbitrary, unauthenticated
reloads of that customer's production policy decision runtime.
Reproduction (confirmed firsthand, non-destructive)
# 1. Pick any open managed PDP, e.g. A&E Networks prod:
$ curl -sk -X POST
'hxxps[://]aenetworks-prod[.]pdp[.]us-east-1[.]permit[.]io/policy-updater/trigger'
{"status":"ok"} [HTTP 200 —
accepted with NO Authorization header]
# 2. The companion data endpoint behaves identically:
$ curl -sk -X POST
'hxxps[://]aenetworks-prod[.]pdp[.]us-east-1[.]permit[.]io/data-updater/trigger'
{"status":"ok"} [HTTP 200 —
accepted with NO Authorization header]
# 3. Compare to any OTHER operational route on the SAME host —
properly auth-gated:
$ curl -sk 'hxxps[://]aenetworks-prod[.]pdp[.]us-east-1[.]permit[.]io/policy-store/config'
{"detail":{"error":"access token was not provided"}} [HTTP 401]
# 4. Discriminator — confirms the 200 is a genuine missing-authz gap,
not a no-op catch-all:
$ curl -sk -X GET
'hxxps[://]aenetworks-prod[.]pdp[.]us-east-1[.]permit[.]io/policy-updater/trigger'
-> 405 (method not allowed)
$ curl -sk -X POST
'hxxps[://]aenetworks-prod[.]pdp[.]us-east-1[.]permit[.]io/policy-updater/triggerXYZ'
-> 404 (route does not exist)
$ curl -sk 'hxxps[://]aenetworks-prod[.]pdp[.]us-east-1[.]permit[.]io/'
-> 200
{"status":"ok","online":true} (health, expected)
Identical 200 {"status":"ok"} with no auth was reproduced on all 7
open managed customer PDPs: aenetworks-prod, aenetworks-dev,
allegion-tam, allegion-test, getwingwork, iofinnet,
wingwork. The request body is ignored (a POST carrying
{"url":"hxxp[://]169[.]254[.]169[.]254/..."} returns the same 200 {"status":"ok"} — no SSRF sink, no data returned), so the primitive
is a forced reload, not a data-exfil vector. No customer
policy/tenant/secret data was retrieved at any point — only the
generic {"status":"ok"} status object.
Impact
- Unauthenticated operational control of production customer
runtimes. Any internet user can force a real customer's PDP to
synchronously re-fetch and re-evaluate its policy/data configuration,
bypassing the per-instance PDP token that protects every other route. - Availability / reliability. Triggering forces a reload cycle on
the target PDP. At scale (iterating across the customer PDP fleet,
repeatedly), this is a low-cost unauthenticated Availability attack
against your customers' authorization decision latencies and your
control-plane fetch path. - Authz-model integrity. The inconsistency (two routes open, all
peers 401) indicates the trigger routes were accidentally excluded
from the PDP-token middleware — a latent defect whose blast radius
grows if any future change makes a reload state-changing (e.g.
pulling a maliciously-updated policy, swapping config sources, or
resetting cached state).
Root cause
The POST /policy-updater/trigger and POST /data-updater/trigger
handlers on the managed PDP image are not wired through the
Authorization: Bearer <PDP_TOKEN> middleware that gates the
remainder of the PDP's operational API. They execute and return
success unconditionally.
Remediation
- Route
/policy-updater/triggerand/data-updater/triggerthrough
the same PDP-token Authorization middleware that protects
/policy-store/configet al.; reject unauthenticated calls with
401. - Restrict these control endpoints to the control-plane caller only
(mTLS / a signed control-plane caller identity), not any internet
requester. - Audit the PDP image's route table for any other routes
inadvertently excluded from the auth middleware (the
GET-vs-POST-vs-bogus discriminator used above is a quick regression
check).
Validation notes (honest)
- Confirmed firsthand: unauthenticated
200 {"status":"ok"}on
both trigger endpoints across all 7 open managed PDPs, with the
gated/405/404 discriminators above ruling out a catch-all false
positive. No authentication of any kind was supplied. - Not measured: actual decision-latency/availability degradation
from sustained triggering — deliberately not performed because it
would require flooding real production customer runtimes
(non-destructive, real-customer data-safety). The missing-authz defect
itself is proven; the magnitude of the availability impact is
inferred, hence the conservative CVSS 5.3 rather than the ~7.5 that
sustained abuse would imply. I recommend Permit confirm whether a
forced reload can cause transient decision disruption and score
accordingly. - No customer policy/tenant/user/secret data was retrieved. Only the
generic{"status":"ok"}status was returned.
zeevmoney
left a comment
There was a problem hiding this comment.
Automated review — PR #320 (replace legacy /update_policy* 307 redirects with direct calls)
Reviewed with python-pro + fastapi-pro; findings adversarially verified against the canonical OPAL 0.9.6 handlers.
Verdict: APPROVE. Highest confirmed severity is LOW. The change is a faithful, byte-for-byte mirror of the canonical /policy-updater/trigger and /data-updater/trigger handlers (same {"status":"ok"} body, 200 status, force_full_update=True, and verbatim 503 detail string).
Public API: not broken. Both routes are include_in_schema=False (not in the OpenAPI contract). For redirect-following clients (including the legacy SDK) the net result was already 200 {"status":"ok"} reached at the canonical route — now delivered in one hop, and without the drop-Authorization-on-redirect trap. The only clients affected by the 307→200 change are ones that neither follow redirects nor treat 2xx as success — a class already non-functional against these endpoints. A repo-wide grep found no docs/tests/SDK fixtures depending on the old 307.
Scope note (not a defect in this PR): #320 does not close the unauthenticated /policy-updater/trigger & /data-updater/trigger vulnerability — those canonical routes are gated by the sibling middleware PR #321 (in enforce mode). #320 only makes the already-gated legacy aliases work correctly under that gate.
One LOW inline note below.
…eview) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@omer9564 thanks — the vulnerability you've reproduced is real and correctly diagnosed, but it's closed by a different PR in the same coordinated effort, not this one. Quick scope map so the split is clear: This PR (#320 / PER-15246) has a deliberately narrow job: the two legacy alias routes The actual fix for what you reported is #321 / PER-15245 ( Related siblings already landed/underway:
So your |
What
The gated aliases
POST /update_policyandPOST /update_policy_datapreviously returned a307RedirectResponseto the canonical OPAL trigger routes (/policy-updater/trigger,/data-updater/trigger). This replaces the redirects with direct in-process calls to the same OPAL updater methods the canonical handlers use.Why
Many HTTP clients (
requests,httpx, browsers) strip theAuthorizationheader on redirect. The aliases only "work" today because the canonical targets are open. Once the canonical routes are gated by the upcoming default-deny middleware (PER-15245), a legitimate SDK calling an alias with a token would get307→ token dropped →401.Calling the updaters directly keeps the happy path byte-identical for today's callers (same
200 {"status": "ok"}, one hop instead of two) while removing the header-drop trap. Only consumers are old Permit SDKs.Changes
/update_policy→await self._opal.policy_updater.trigger_update_policy(force_full_update=True), returns{"status": "ok"}./update_policy_data→503(verbatim canonical detail string) whendata_updater is None, otherwiseawait get_base_policy_data(data_fetch_reason="request from sdk (legacy alias)"), returns{"status": "ok"}.Depends(enforce_pdp_token)gate (belt-and-suspenders with PER-15245).RedirectResponseimport; addedHTTPException.horizon/tests/test_legacy_update_routes.py(6 tests).Notes
policy_updaterleft unguarded (noNonecheck), matching the canonical OPAL handler. TheNonecase is unreachable in a running PDP — horizon never disables the policy updater, and a disabled data updater already crashes startup atpdp.py:178.enforce_pdp_token— a missing header yields422(required-param validation runs before the dependency body) and an invalid token yields401. Both cases also assert the updater is never awaited, which is the durable security property. (The malformed-header →500footgun and missing-header →401normalization are PER-15245's header-safe helper, out of scope here.)Test
Ruff lint + format clean.
Rollout (PER-15243)
/update_policy_dataaccess-log latency shifts from ~1ms (redirect emit) to a control-plane round-trip — identical to what redirect-following clients already experience post-hop, so no new exposure, but per-route latency alerts may need re-baselining.307counts to these paths drop to zero.Linear: PER-15246
🤖 Generated with Claude Code