Skip to content

feat: add ENFORCE_OPERATIONAL_ROUTE_AUTH rollout toggle (PER-15243)#323

Open
dshoen619 wants to merge 5 commits into
mainfrom
david/per-15243-rollout-coordinated-pdp-fleet-upgrade-to-the-patched-image
Open

feat: add ENFORCE_OPERATIONAL_ROUTE_AUTH rollout toggle (PER-15243)#323
dshoen619 wants to merge 5 commits into
mainfrom
david/per-15243-rollout-coordinated-pdp-fleet-upgrade-to-the-patched-image

Conversation

@dshoen619

@dshoen619 dshoen619 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

The auth-hardening PRs (#317/#320/#321) made five operational routes — the update-trigger routes (/policy-updater/trigger, /data-updater/trigger, /update_policy, /update_policy_data) and the /kong decision endpoint — unconditionally require the PDP token. That's correct as an end state, but there's no way to relax it during a coordinated fleet upgrade, and some callers (notably the Kong OPA plugin and older SDK automations) may not send the token yet. Turning it on fleet-wide today would 401 live traffic with no warning.

This PR adds one flag, ENFORCE_OPERATIONAL_ROUTE_AUTH, that gates those five routes. It defaults to FALSE for a safe rollout: the routes accept unauthenticated requests and only log the ones that would have been rejected, so lagging callers keep working while the logs surface exactly who they are. Once the logs go quiet, flip the flag to true per-fleet and enforcement kicks in. Every other PDP route stays strictly enforced regardless of this flag.

How the flag behaves per request

Flag Valid token Missing / wrong token
OFF (default, rollout phase) passes allowed through, logs a "would-be rejection" warning
ON (steady state) passes 401, identical to enforce_pdp_token

Why /kong needed a router split (and the other four routes didn't)

This is the one non-obvious part of the diff, so it's worth spelling out.

A gate (auth dependency) can be attached at two levels:

  • Route level — attached to one individual endpoint.
  • Router level — attached to a whole group of endpoints; every route in the group inherits it.

The four update-trigger routes already carried their gate at the route level (the legacy routes via their @app.post(..., dependencies=[...]) decorator; the OPAL trigger routes via per-route injection in _gate_opal_trigger_routes). Swapping their strict gate for the new conditional one is a one-line change in place.

/kong was different. Its gate came from the enforcer group: include_router(enforcer_router, dependencies=[Depends(enforce_pdp_token)]) puts the strict gate on every enforcer route at once. In FastAPI a route can't opt out of a gate inherited from its group — you can add a gate to a single route but not remove the group-level one. So /kong couldn't be made conditional while it lived in that group; the strict gate would always fire first and 401 the request before the conditional gate could be lenient.

The fix is to pull /kong onto its own kong_router so it can be mounted under the conditional gate while the rest of the enforcer router keeps the strict gate. It's a split, not a move/kong's handler closes over kong_routes_table and the nested _is_allowed helper, so relocating it entirely would be a much larger refactor; a router split in the same closure avoids that.

What changed, by file

  • horizon/config.py — new ENFORCE_OPERATIONAL_ROUTE_AUTH bool (default False), PDP_-prefixed env var, also overridable from the cloud control plane.
  • horizon/authentication.py — new enforce_pdp_token_operational gate. Reads the flag per request (never captured at import, so a control-plane override takes effect immediately). Flag on → delegates to enforce_pdp_token. Flag off → downgrades a rejection to warn-and-allow. Kept as a distinct named callable so the fail-closed route audit still recognises it as a gate.
  • horizon/enforcer/api.py/kong moved onto its own kong_router; init_enforcer_api_router now returns (router, kong_router).
  • horizon/pdp.py — mounts kong_router under enforce_pdp_token_operational; the update-trigger routes now use the same operational wrapper; adds a startup warning (_warn_if_operational_route_auth_disabled) so a fleet in permissive mode stays visible.
  • Tests — enforce-by-default tests from fix: gate /kong, split /health public, enforce PDP token at router level (PER-15244) #317/feat: replace legacy /update_policy* 307 redirects with direct gated calls (PER-15246) #320/feat: default-deny authentication middleware (PER-15245) #321 now opt into enforcement; new tests assert the permissive default and the log throttling. 140 pass, ruff clean.

Log throttling (specific to /kong)

/kong is a high-QPS endpoint, so one warning per unauthenticated request would flood the synchronous stdout sink and add a blocking write to the request hot path. The warn-and-allow path therefore coalesces to one line per route per ~60s, carrying a count of how many would-be rejections it allowed. That count is also the rollout signal — watch it fall to zero on /kong, then flip the flag on.

Route audit note

The route audit stays green, but for /kong and the trigger routes its guarantee is now structural — it asserts a gate is attached, not that the gate rejects at runtime (the gate warns-and-allows while enforcement is off). That's why the startup warning exists.

Setting the flag at startup

Read per request, so it takes effect immediately with no rebuild. Defaults to FALSE (permissive rollout). Set via:

  • Env var (note the PDP_ prefix): PDP_ENFORCE_OPERATIONAL_ROUTE_AUTH=true
    • Docker: docker run -e PDP_ENFORCE_OPERATIONAL_ROUTE_AUTH=true permitio/pdp-v2:latest
    • Kubernetes: add { name: PDP_ENFORCE_OPERATIONAL_ROUTE_AUTH, value: "true" } to the container env.
  • Cloud control plane — flip per-fleet without redeploying. Intended production path once the logs show every caller sends the token.

Leave it unset/false during the coordinated upgrade; flip to true only after the per-request warnings go quiet.

Where to find the "would be rejected" logs

While the flag is off, would-be rejections are emitted through loguru at WARNING level, filterable by the string ENFORCE_OPERATIONAL_ROUTE_AUTH:

  • Startup, once: confirms the fleet is in permissive mode.
  • Per request (coalesced): identifies a specific caller not yet sending the token. When this stops across a representative window, it's safe to enforce.

Locations:

  • Container stdout — always on: docker logs <container> 2>&1 | grep ENFORCE_OPERATIONAL_ROUTE_AUTH / kubectl logs <pdp-pod> | grep ENFORCE_OPERATIONAL_ROUTE_AUTH
  • Logz.io central drain — only if CENTRAL_LOG_ENABLED=true with a valid token.
  • Datadog Logs — if stdout is scraped: service:permit-pdp "ENFORCE_OPERATIONAL_ROUTE_AUTH is off".

The auth-hardening PRs (#317/#320/#321) made the update-trigger routes
(/policy-updater/trigger, /data-updater/trigger, /update_policy,
/update_policy_data) and /kong unconditionally require the PDP token,
with no way to relax it during a coordinated fleet upgrade.

Add a single flag, ENFORCE_OPERATIONAL_ROUTE_AUTH (PDP_ prefixed env var,
also overridable from the cloud control plane), defaulting to FALSE for a
safe rollout: those five routes accept unauthenticated requests and only
LOG the ones that would be rejected, so callers that don't yet send the
token (e.g. the Kong OPA plugin, older SDK automations) keep working while
the logs surface them. Flip to true per-fleet once every caller sends the
token. Every other PDP route stays strictly enforced regardless.

- enforce_pdp_token_operational: conditional gate reading the flag per
  request; delegates to enforce_pdp_token when on, warn-and-allows when
  off. Distinct named callable so the fail-closed route audit still
  recognises it as a gate.
- /kong split onto its own router in the same closure so it can be mounted
  under the conditional gate while the rest of the enforcer router keeps
  the strict gate.
- Startup warning while the flag is off (the rollout default) so a fleet
  in the permissive phase stays visible.
- The route audit stays green but its guarantee is now structural (a gate
  is attached), not that the gate rejects at runtime.
- Enforce-by-default tests from #317/#320/#321 now opt into enforcement;
  new tests assert the permissive default. 140 pass, ruff clean.

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

linear-code Bot commented Jul 12, 2026

Copy link
Copy Markdown

PER-15243

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:677ba5dfee94080d297379f5cec60b6378cd9009f3e32ffb45dba006cf92fd0b
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 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.285%
EPSS Percentile20th 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 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 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 12, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:677ba5dfee94080d297379f5cec60b6378cd9009f3e32ffb45dba006cf92fd0b
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

…pt (PER-15243)

Read ENFORCE_OPERATIONAL_ROUTE_AUTH once and call enforce_pdp_token a single
time: honour a rejection by re-raising when the flag is on, downgrade it to
warn-and-allow when off. Behaviour is unchanged from the two-branch version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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 introduces a rollout toggle (ENFORCE_OPERATIONAL_ROUTE_AUTH) to conditionally enforce PDP-token auth on a small set of operational endpoints (OPAL trigger routes, legacy update aliases, and /kong) to support coordinated fleet upgrades while still surfacing non-compliant callers via warnings.

Changes:

  • Add ENFORCE_OPERATIONAL_ROUTE_AUTH config flag (default False) and an enforce_pdp_token_operational dependency that enforces when on, and warn-allows when off.
  • Gate OPAL-mounted trigger routes and legacy /update_policy* aliases with the operational wrapper; split /kong onto its own router so only it uses the operational gate.
  • Update/extend tests and route-auth audit to recognize the operational wrapper as a structural auth gate, plus new tests for permissive-default behavior and startup/per-request warnings.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
horizon/authentication.py Adds enforce_pdp_token_operational dependency implementing the rollout-aware gating behavior.
horizon/config.py Adds ENFORCE_OPERATIONAL_ROUTE_AUTH configuration flag with detailed description.
horizon/enforcer/api.py Splits /kong onto a dedicated router and returns (enforcer_router, kong_router) for separate mounting/gating.
horizon/pdp.py Mounts /kong under the operational gate, injects the operational gate into OPAL trigger routes, gates legacy update aliases, and adds a startup warning when permissive.
horizon/tests/test_route_auth_audit.py Updates the route-auth audit to treat enforce_pdp_token_operational as a recognized gate and asserts OPAL trigger routes carry it.
horizon/tests/test_opal_trigger_auth.py Adds enforcement-on fixtures for strict-mode assertions; adds permissive-default coverage and log-warning assertions.
horizon/tests/test_legacy_update_routes.py Updates unauth rejection tests to enable enforcement explicitly (since default is now permissive for these routes).
horizon/tests/test_enforcer_api.py Enables enforcement during protected-endpoint sweeps (to cover /kong) and adds a permissive-default /kong test.
horizon/tests/test_authentication.py Adds unit tests for enforce_pdp_token_operational behavior and warning logging in permissive mode.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread horizon/authentication.py Outdated
Comment thread horizon/authentication.py Outdated
Comment thread horizon/pdp.py
dshoen619 and others added 2 commits July 12, 2026 16:50
… flood (PER-15243)

While ENFORCE_OPERATIONAL_ROUTE_AUTH is off (the rollout default), enforce_pdp_token_operational
logged one WARNING per would-be-rejected request. /kong is a per-decision endpoint (potentially
high QPS), so during the shadow-mode window that floods the synchronous stdout sink and adds a
blocking write to the request path.

Rewrite the warn-and-allow log to a per-route coalesced counter: at most one line per route per
60s, carrying the number of tokenless requests allowed in that window. Every request is still
counted; only the logging is rate-limited. The counter is guarded by a threading.Lock because the
gate runs in FastAPI's threadpool. That per-route count also doubles as the rollout signal - watch
it fall to zero per route, then flip the flag on.

- authentication.py: add _operational_warn_should_emit throttle + reset_operational_warn_throttle.
- conftest.py: autouse fixture resets throttle state per test so asserted warnings aren't suppressed.
- test_authentication.py: add anti-flood (burst -> one line) and coalesced-count tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… wording (PER-15243)

- authentication.py: import loguru's logger directly instead of opal_client.logger, matching
  the rest of horizon (e.g. pdp.py) and avoiding a dependency on opal_client internals for the
  rollout warnings and test log capture. Same singleton, so behaviour is unchanged.
- authentication.py + pdp.py: the warn-and-allow branch also fires for an *invalid* token, not
  only a missing one, so reword "unauthenticated" -> "without a valid PDP token (missing or
  invalid)" in both the per-request and the startup warning for accurate log search/alerting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dshoen619 dshoen619 requested review from omer9564 and zeevmoney July 12, 2026 14:13
@dshoen619 dshoen619 self-assigned this Jul 13, 2026
The autouse throttle-reset fixture sits directly above a module-level `if`; ruff-format
(the pre-commit hook, distinct from ruff check) requires two blank lines there. Whitespace only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants