Skip to content

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

Open
dshoen619 wants to merge 6 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 6 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:aad8cfba0f5dbd21d44e306e4527cba4222926039e4f082cd3b09da150e053b4
vulnerabilitiescritical: 0 high: 3 medium: 8 low: 4 unspecified: 1
platformlinux/amd64
size225 MB
packages264
📦 Base Image python:3.10-alpine3.22
also known as
  • 3.10.20-alpine3.22
digestsha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd
vulnerabilitiescritical: 1 high: 13 medium: 12 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: 2 low: 0 github.com/quic-go/quic-go 0.54.1 (golang)

pkg:golang/github.com/quic-go/quic-go@0.54.1

medium 5.3: CVE--2026--40898 Allocation of Resources Without Limits or Throttling

Affected range<=0.59.0
Fixed version0.59.1
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
EPSS Score0.279%
EPSS Percentile20th percentile
Description

Summary

An attacker can cause excessive memory allocation in quic-go's HTTP/3 client and server implementations by sending a QPACK-encoded HEADERS frame that decodes into a large trailer field section with many unique field names and/or large values. The implementation builds an http.Header for the corresponding http.Request or http.Response, while only enforcing limits on the size of the QPACK-compressed HEADERS frame, not on the decoded field section. This can lead to memory exhaustion.

This is very similar to CVE-2025-64702. The difference is that this issue uses HTTP trailers, rather than HTTP headers, as the attack vector.

Impact

A misbehaving or malicious peer can cause a denial-of-service (DoS) attack against quic-go's HTTP/3 servers or clients by triggering excessive memory allocation, potentially leading to crashes or resource exhaustion. This affects both servers and clients due to symmetric header construction.

Details

In HTTP/3, field sections are compressed using QPACK (RFC 9204). Field sections are used for both HTTP headers and trailers. quic-go's HTTP/3 server and client decode the QPACK-encoded HEADERS frame into header fields, then construct an http.Request or http.Response.

http3.Server.MaxHeaderBytes and http3.Transport.MaxResponseHeaderBytes limit the encoded HEADERS frame size, with defaults of 1 MB for servers and 10 MB for clients. However, they did not limit the decoded field section size. A maliciously crafted HEADERS frame carrying trailers can expand to about 50x the encoded size using QPACK static table entries with long names and/or values.

RFC 9114 requires endpoints to enforce decoded field section size limits via SETTINGS, which quic-go did not do for trailers.

The Fix

quic-go now enforces RFC 9114 decoded field section size limits for trailers as well. It incrementally decodes QPACK entries and checks the field section size after each entry, aborting the stream if an entry causes the limit to be exceeded.

medium 5.3: CVE--2025--64702 Allocation of Resources Without Limits or Throttling

Affected range<0.57.0
Fixed version0.57.0
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
EPSS Score0.338%
EPSS Percentile26th percentile
Description

Summary

An attacker can cause excessive memory allocation in quic-go's HTTP/3 client and server implementations by sending a QPACK-encoded HEADERS frame that decodes into a large header field section (many unique header names and/or large values). The implementation builds an http.Header (used on the http.Request and http.Response, respectively), while only enforcing limits on the size of the (QPACK-compressed) HEADERS frame, but not on the decoded header, leading to memory exhaustion.

Impact

A misbehaving or malicious peer can cause a denial-of-service (DoS) attack on quic-go's HTTP/3 servers or clients by triggering excessive memory allocation, potentially leading to crashes or exhaustion. It affects both servers and clients due to symmetric header construction.

Details

In HTTP/3, headers are compressed using QPACK (RFC 9204). quic-go's HTTP/3 server (and client) decodes the QPACK-encoded HEADERS frame into header fields, then constructs an http.Request (or response).

http3.Server.MaxHeaderBytes and http3.Transport.MaxResponseHeaderBytes, respectively, limit encoded HEADERS frame size (default: 1 MB server, 10 MB client), but not decoded size. A maliciously crafted HEADERS frame can expand to ~50x the encoded size using QPACK static table entries with long names / values.

RFC 9114 requires enforcing decoded field section size limits via SETTINGS, which quic-go did not do.

The Fix

quic-go now enforces RFC 9114 decoded field section size limits, sending SETTINGS_MAX_FIELD_SECTION_SIZE and using incremental QPACK decoding to check the header size after each entry, aborting early on violations with HTTP 431 (on the server side) and stream reset (on the client side).

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 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 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: 0 low: 2 github.com/refraction-networking/utls 1.7.3 (golang)

pkg:golang/github.com/refraction-networking/utls@1.7.3

low 2.3: CVE--2026--27017 Use of a Cryptographic Primitive with a Risky Implementation

Affected range>=1.6.0
<1.8.1
Fixed version1.8.1
CVSS Score2.3
CVSS VectorCVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:P/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
EPSS Score0.154%
EPSS Percentile5th percentile
Description

There is a fingerprint mismatch with Chrome when using GREASE ECH, having to do with ciphersuite selection. When Chrome selects the preferred ciphersuite in the outer ClientHello and the ciphersuite for ECH, it does so consistently based on hardware support. That means, for example, if it prefers AES for the outer ciphersuite, it would also use AES for ECH. The Chrome parrot in utls hardcodes AES preference for outer ciphersuites but selects the ECH ciphersuite randomly between AES and ChaCha20. So there is a 50% chance of selecting ChaCha20 for ECH while using AES for the outer ciphersuite, which is impossible in Chrome.

This is only a problem in GREASE ECH, since in real ECH Chrome selects the first valid ciphersuite when AES is preferred, which is the same in utls. So no change is done there.

Affected symbols: HelloChrome_120, HelloChrome_120_PQ, HelloChrome_131, HelloChrome_133

Fix commit: 24bd1e05a788c1add7f3037f4532ea552b2cee07

Thanks to telegram @acgdaily for reporting this issue.

low 2.3: CVE--2026--26995 Exposure of Sensitive Information to an Unauthorized Actor

Affected range>=1.6.0
<1.8.2
Fixed version1.8.2
CVSS Score2.3
CVSS VectorCVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:P/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Description

The padding extension was incorrectly removed in utls for the non-pq variant of Chrome 120 fingerprint. Chrome removed this extension only when sending pq keyshares. Only this fingerprint is affected since newer fingerprints have pq keyshares by default and older fingerprints have this extension.

Affected symbols: HelloChrome_120

Fix commit: 8fe0b08e9a0e7e2d08b268f451f2c79962e6acd0

Thanks to telegram @acgdaily for reporting this issue.

critical: 0 high: 0 medium: 0 low: 1 github.com/cloudflare/circl 1.6.1 (golang)

pkg:golang/github.com/cloudflare/circl@1.6.1

low 2.9: CVE--2026--1229 Incorrect Calculation

Affected range<1.6.3
Fixed version1.6.3
CVSS Score2.9
CVSS VectorCVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:L/VI:L/VA:L/SC:L/SI:L/SA:L/E:P/S:N/AU:Y/U:Amber
EPSS Score0.397%
EPSS Percentile32nd percentile
Description

The CombinedMult function in the CIRCL ecc/p384 package (secp384r1 curve) produces an incorrect value for specific inputs. The issue is fixed by using complete addition formulas.
ECDH and ECDSA signing relying on this curve are not affected.

The bug was fixed in v1.6.3.

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:aad8cfba0f5dbd21d44e306e4527cba4222926039e4f082cd3b09da150e053b4
vulnerabilitiescritical: 0 high: 0 medium: 0 low: 0
platformlinux/amd64
size225 MB
packages264
📦 Base Image python:3.10-alpine3.22
also known as
  • 3.10.20-alpine3.22
digestsha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd
vulnerabilitiescritical: 1 high: 13 medium: 12 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
dshoen619 and others added 2 commits July 13, 2026 11:25
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>

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

Reviewed with python-pro and fastapi-pro. Blast radius verified: confined to exactly the five operational routes (/kong, /policy-updater/trigger, /data-updater/trigger, /update_policy, /update_policy_data) and nothing else — an empirical route sweep of the built app shows 5 routes on the conditional gate, 48 still on strict enforce_pdp_token/OPAL, 9 public, 0 ungated; a runtime toggle sweep confirmed control routes (/allowed, /user-permissions, /nginx_allowed, /local/*) return 401 in both flag states. With the flag ON the five routes are byte-identical to the previous strict gate; the /kong router split leaves the 8 sibling enforcer routes, their gating, tags, and OpenAPI schema untouched. Auth suite: 126 passed.

Approving. All findings are MEDIUM or lower (test-coverage gaps + an observability nuance in the warn-and-allow log throttle); none block the rollout mechanism. Inline comments below.

Comment thread horizon/authentication.py
if now - last_emit >= _OPERATIONAL_WARN_INTERVAL_SECONDS:
_operational_warn_state[path] = (0, now)
return True, pending
_operational_warn_state[path] = (pending, last_emit)

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] Coalesced would-be-rejection count is silently dropped when a route falls idle

Problem: Pending counts are flushed only by a later request that arrives after the interval elapses — there is no periodic or shutdown flush. If a burst of unauthenticated calls hits a low-traffic route (e.g. /update_policy) and then traffic stops, the accumulated pending (every hit but the first) is never logged. Since this count is the documented rollout signal ("watch it fall to zero, then flip the flag on"), an operator can see a route go quiet and enable enforcement while the tail of lagging callers was never surfaced — the signal under-reports exactly when traffic is sparse, which is when it matters most. /kong self-flushes under load; the quiet update routes are where this bites.

Suggestion: Flush the residual on a low-frequency timer or at shutdown, or document that the count is best-effort and a nonzero residual can go unreported when a route falls idle — so "logs went quiet" is not read as "all callers migrated."

Comment thread horizon/authentication.py
if emit:
logger.warning(
"ENFORCE_OPERATIONAL_ROUTE_AUTH is off: allowed {count} request(s) without a valid PDP token "
"(missing or invalid) to {method} {path} in the last ~{interval}s that would otherwise be "

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.

[LOW] "in the last ~{interval}s" can misrepresent the actual window

Problem: last_emit only advances on an actual emit, so count spans the entire gap since the previous emitted line — which is >= the interval and unbounded when traffic is sparse. A 100-request burst at t0 followed by a single request at t0+180s emits allowed 100 request(s) ... in the last ~60s, though 99 occurred 180s earlier. An operator reading the count as a per-minute rate is misled. (The count itself is accurate; only the fixed-window phrasing is wrong. Flagged independently by both review passes.)

Suggestion: Drop the "~{interval}s" claim, or report the real elapsed span (now - last_emit) instead of the constant.

assert not any(self.WARN_SUBSTRING in record for record in capture_loguru)

@pytest.mark.usefixtures("enforce_off")
def test_enforce_off_coalesces_repeated_warnings(self, capture_loguru):

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] Per-route (vs. global) coalescing is never asserted

Problem: Every coalescing/count test drives a single path (/kong or /policy-updater/trigger). Nothing asserts that two different routes each get their own immediate first warning. A regression that collapsed the throttle to one global key — dropping the per-path dict keying that the design comment (authentication.py:74-75, "one line per route per interval") promises — would pass the entire suite. This is precisely the property the throttle exists to provide, and it is unguarded.

Suggestion: Add a test (flag off) that calls the gate for path A (asserts one warning), then path B, and asserts B also emits its own first warning — i.e. sum(...) == 2 across two distinct paths — proving the coalescing is keyed per route, not globally.

# Simulate the interval having elapsed without waiting on the wall clock.
pending, _last = auth._operational_warn_state["/kong"]
auth._operational_warn_state["/kong"] = (pending, time.monotonic() - (auth._OPERATIONAL_WARN_INTERVAL_SECONDS + 1))
assert auth._operational_warn_should_emit("/kong") == (True, 5)

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.

[LOW] Count-accuracy test couples to private internals rather than observable behavior

Problem: test_operational_warn_throttle_coalesces_count reaches into auth._operational_warn_state["/kong"], hand-mutates the raw (pending, last_emit) tuple to fake elapsed time, and asserts the exact tuple return (True, 5) of the private _operational_warn_should_emit. A valid refactor of the throttle internals (different structure, token bucket, injected clock) would break this test even with identical observable behavior — the "test behavior, not implementation" concern. There is real tension here (verifying count accuracy without a 60s sleep is hard), so this is a judgment call, not a defect.

Suggestion: Prefer driving the public enforce_pdp_token_operational with monkeypatch.setattr(auth.time, "monotonic", ...) (or an injected time source) and asserting on the captured log lines/counts, so the test exercises the coalescing contract rather than the tuple shape.

Comment thread horizon/authentication.py


def reset_operational_warn_throttle() -> None:
"""Clear the throttle state. For tests, whose asserted warnings must not be suppressed by a prior test."""

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.

[LOW] Docstring is a sentence fragment / not Google-style

Problem: "Clear the throttle state. For tests, whose asserted warnings must not be suppressed by a prior test." — the second sentence is an ungrammatical fragment ("For tests, whose ..."). Minor, but this is a public module-level function.

Suggestion: e.g. "Clear the process-global throttle state. Used by tests so an asserted warning is not suppressed by a warning already logged for the same path in a prior test."

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