Pulpcore provides a content upload API field 'fileurl' that allows any authenticated user with repository creator privileges to supply a URL from which Pulp will download and store content as an Artifact. The validator for this field (validateurl in RemoteSerializer, pulpcore/app/serializers/repository.py line 139) checks file paths ONLY when the URL string begins with 'file://' (using a case-insensitive startswith check). The DownloaderFactory (pulpcore/download/factory.py line 181) dispatches on the parsed URL scheme using Python's urlparse(), which correctly identifies 'file:../../path' (single colon, no double slash) as having scheme 'file'. This creates a validator/dispatcher mismatch: a URL like 'file:../../../../../../etc/pulp/certs/tokenprivatekey.pem' passes the string-based allowlist check (it does not start with 'file://') and is dispatched to FileDownloader. FileDownloader also calls RemoteSerializer().validateurl() which performs the same incomplete string check. The resolved path is computed as os.path.abspath(os.path.join(p.netloc, p.path)) where p.netloc is empty and p.path is the relative traversal sequence, making the final path relative to the Pulp process's working directory. The file is read and stored as an Artifact, then served through a File Distribution.
The reporter (Yonghwa Lee, Xint by Theori, underdog) demonstrated end-to-end exploitation: a non-staff non-superuser holding only file.filerepositorycreator and file.filedistributioncreator roles successfully reads /etc/pulp/certs/tokenprivatekey.pem (the Pulp Container registry JWT signing key at its documented default path), publishes it through their own distribution, downloads it, forges ES256 JWT bearer tokens, and performs unauthorized pull, push, persistence, and manifest deletion on private container repositories belonging to other users. Five independent trials confirmed this in the reporter's lab (results.txt included in PULP-001-artifact.zip).
Affected versions: pulpcore 3.117.1 and pulp-container 2.29.0 confirmed; current main branch also affected. No upstream fix is available at the time of ticket creation.
PSIRT Ticket: PSIRTSUPT-23758
An expression language injection has been found in the HeaderContentGuard.permit function in publication.py of the content guard in the Pulpcore package. The guard is supposed to check a value in a request header. It saves a jq filter and runs that filter against the header when a client asks for content. The filter is not limited to the header, and jq's env builtin also returns the environment of the content process. A low-privileged network attacker or user who can create a header content guard and attach it to a distribution can save a filter that reads that environment. The content request is allowed or denied according to the filter result. Repeating the request lets that attacker or user recover the environment, including Pulp settings held there, such as the Django secret key or the database password.
HeaderContentGuard.permit in pulpcore/app/models/publication.py compiles jqfilter with jq.compile and does not restrict jq’s env builtin.
When jqfilter is set, permit base64-decodes the named request header, parses it as JSON, runs the stored filter, and compares the result with headervalue using !=. A mismatch raises PermissionError. Handler.permit in pulpcore/content/handler.py calls permit for the distribution’s content guard and turns PermissionError into a denial. HeaderContentGuardSerializer accepts jqfilter as an unvalidated string. Only JSONDecodeError is caught around the jq call.
The allow-or-deny result is an oracle for the filter output. A filter that uses env reads the content-app process environment, so the caller can recover that environment, including PULPSECRETKEY and PULPDATABASESdefaultPASSWORD when those values were exported into the process. A value that exists only in the settings file is not part of that environment.
The caller must be able to create or change a header content guard and attach it to a distribution. Creation requires core.addheadercontentguard, which core.headercontentguardcreator grants. The creator hook assigns core.headercontentguardowner. Attaching the guard is a change to the distribution’s contentguard. The content request that runs the filter needs no further account. Once the guard is attached, any client who can request that content receives the same allow-or-deny result.
Likely introduced in pulpcore 3.39.0 by commit 7a02719e9. The dependency at introduction was jq>=1.6.0, which provides the env builtin. The same jq.compile call is still present on main at publication.py:548.