Skip to content

Fix jq expression injection in user-supplied filters (CVE-2026-123084) - #8340

Open
decko wants to merge 3 commits into
pulp:mainfrom
decko:pulp-2649-jq-env-shadow
Open

decko wants to merge 3 commits into
pulp:mainfrom
decko:pulp-2649-jq-env-shadow

Conversation

@decko

@decko decko commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

CVE-2026-123084

HeaderContentGuard.permit() evaluates a jq filter supplied through the API. jq makes the environment of the running process available through the env builtin and the $ENV variable, so a user who is allowed to create a content guard can read secrets held in the content process, e.g. the Django secret key or the database password. The header value comparison turns the guard into a boolean oracle, which allows recovering a secret character by character.

The same execution pattern exists in JSONHeaderRemoteAuthentication.

Fix

Compile user-supplied filters with both interfaces shadowed:

jq.compile(f"def env: {{}}; {{}} as $ENV | ({program})")

env resolves to a local definition and $ENV to a local binding, so neither can reach the process environment. Everything else in the jq language keeps working: property access, pipes, select, //, string interpolation, map, index, def, reduce, try.

An allow-list of "pure JSON paths" was considered first, but it would reject filters that are already in use, e.g. .roles[] | select(.active) | .name, .sub // "" or "\(.org_id)|\(.user.username)". Shadowing removes the capability leak without a syntax restriction.

As a side effect, import/include become syntax errors because they are only valid at the top level of a program.

Fail closed

permit() and authenticate() now catch ValueError (invalid filter, invalid JSON) and StopIteration (filter produced no result) and deny access. Previously an invalid or empty filter raised an unhandled server error.

Verification

Reproducer run inside a live Pulp container, with a canary value in the environment and a guard whose jq_filter is env.PULP_CVE_2026_123084_CANARY:

Against the unpatched HeaderContentGuard (checked out from main):

[CVE] raw jq filter 'env.PULP_CVE_2026_123084_CANARY' returned 's3cr3t-canary-value'
[CVE] vulnerable permit() ALLOWED access -> CVE-2026-123084 reproduced
[FIX] FAILED: permit() leaked the environment via 'env.PULP_CVE_2026_123084_CANARY'

Against this branch:

[FIX] permit() denied 'env.PULP_CVE_2026_123084_CANARY' -> fixed
[FIX] permit() denied '$ENV.PULP_CVE_2026_123084_CANARY' -> fixed
[FIX] compile_safe_jq() returns None for both environment interfaces
[OK]  '.roles[] | select(.active) | .name' -> 'reader'
[OK]  '"\\(.sub)-\\(.roles[0].name)"' -> 'user1-reader'
[OK]  legitimate HeaderContentGuard.permit() accepted the request
  • Full pulpcore unit suite: 601 passed, 9 skipped (oci-env agent test).
  • pulpcore/tests/unit/test_jq_utils.py: 20 tests covering env, $ENV, aliasing, try///, reduce, label, module import, plus unchanged results for legitimate filters.
  • pulpcore/tests/unit/test_content_guard.py: environment leak, complex filters and empty-result cases.
  • ruff check and ruff format --check clean.

Follow-ups

  • pulp-service has its own FeatureContentGuard with a direct jq.compile() call; it needs the same treatment (or should call this helper once it is released).
  • Backport to supported branches.

Assisted-by: DeepSeek V4.1 Flash (EnMaaS)

CVE-2026-123084: HeaderContentGuard.permit() evaluated a jq filter coming
from the API, and jq's env builtin and $ENV variable expose the
environment of the content process. A user able to create a content
guard could therefore read secrets such as the Django secret key or the
database password.

Compile user-supplied filters with env and $ENV shadowed, so the
environment is unreachable while the rest of the jq language keeps
working. The same helper is used by JSON header authentication. Also
fail closed when a filter is invalid or produces no result, instead of
raising a server error.

Assisted-by: DeepSeek V4.1 Flash (EnMaaS)
@decko decko added the ai-assisted Changes assisted by AI label Nov 11, 2026
The comparison of the jq result with header_value can be used as an
allow/deny oracle to guess a secret one character at a time. Cover the
slicing variants for both env and $ENV.

Assisted-by: DeepSeek V4.1 Flash (EnMaaS)
- Close the wrapper on a new line so a stored filter ending in a #
  comment is not turned into a syntax error.
- Reject invalid jq filters in HeaderContentGuardSerializer instead of
  storing them and denying every request behind the guard.
- Log invalid filters at WARNING with the filter text, and empty results
  at DEBUG.
- Treat non-ASCII base64 headers as an access denial rather than a 500.
- Add unit tests for JSONHeaderRemoteAuthentication and make the
  malicious-filter cases actually fail on the vulnerable code.

Assisted-by: DeepSeek V4.1 Flash (EnMaaS)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant