From 03b8ae8203dd5c24af2f6cbd00a82b7fcfa7d87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22decko=22=20de=20Brito?= Date: Wed, 11 Nov 2026 14:43:33 -0300 Subject: [PATCH 1/3] Fix jq expression injection in user-supplied filters (PULP-2649) 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) --- CHANGES/+cve-2026-123084.bugfix | 1 + pulpcore/app/authentication.py | 11 ++--- pulpcore/app/jq_utils.py | 19 ++++++++ pulpcore/app/models/publication.py | 9 ++-- pulpcore/tests/unit/test_content_guard.py | 55 +++++++++++++++++++++ pulpcore/tests/unit/test_jq_utils.py | 58 +++++++++++++++++++++++ 6 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 CHANGES/+cve-2026-123084.bugfix create mode 100644 pulpcore/app/jq_utils.py create mode 100644 pulpcore/tests/unit/test_jq_utils.py diff --git a/CHANGES/+cve-2026-123084.bugfix b/CHANGES/+cve-2026-123084.bugfix new file mode 100644 index 00000000000..6b8fac407a7 --- /dev/null +++ b/CHANGES/+cve-2026-123084.bugfix @@ -0,0 +1 @@ +Fixed an expression language injection in user-supplied jq filters (content guards and JSON header authentication) that could expose the environment of the content process, including the Django secret key and the database password. This addresses CVE-2026-123084. diff --git a/pulpcore/app/authentication.py b/pulpcore/app/authentication.py index 745e35e1ffa..376385e8adc 100644 --- a/pulpcore/app/authentication.py +++ b/pulpcore/app/authentication.py @@ -4,7 +4,6 @@ from binascii import Error as Base64DecodeError from gettext import gettext as _ -import jq from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.backends import RemoteUserBackend @@ -17,6 +16,8 @@ ) from rest_framework.exceptions import AuthenticationFailed +from pulpcore.app.jq_utils import compile_safe_jq + _logger = logging.getLogger(__name__) @@ -67,11 +68,9 @@ def authenticate(self, request): try: header_value = json.loads(header_decoded_content) - json_path = jq.compile(self.jq_filter) - - remote_user = json_path.input_value(header_value).first() - except json.JSONDecodeError: - _logger.debug(_("Access not allowed - Invalid JSON.")) + remote_user = compile_safe_jq(self.jq_filter).input_value(header_value).first() + except (StopIteration, ValueError): + _logger.debug(_("Access not allowed - Invalid JSON or jq filter.")) raise AuthenticationFailed(_("Access denied. Invalid JSON.")) if not remote_user: diff --git a/pulpcore/app/jq_utils.py b/pulpcore/app/jq_utils.py new file mode 100644 index 00000000000..f5565933e4e --- /dev/null +++ b/pulpcore/app/jq_utils.py @@ -0,0 +1,19 @@ +"""Helpers for evaluating user-supplied jq filters safely.""" + +import jq + + +def compile_safe_jq(program): + """ + Compile a user-supplied jq filter with the process environment hidden. + + jq exposes the environment of the running process through the `env` + builtin and the `$ENV` variable. Filters may come from untrusted sources, + such as a content guard created by a domain administrator, so evaluating + them as-is can leak secrets held in the environment of the process, e.g. the + Django `SECRET_KEY` or the database password. + + Shadowing both interfaces keeps the rest of the jq language available while + making the environment unreachable from the filter. + """ + return jq.compile(f"def env: {{}}; {{}} as $ENV | ({program})") diff --git a/pulpcore/app/models/publication.py b/pulpcore/app/models/publication.py index 8c9535d3161..c7be0d9c583 100644 --- a/pulpcore/app/models/publication.py +++ b/pulpcore/app/models/publication.py @@ -10,7 +10,6 @@ from gettext import gettext as _ from urllib.parse import urljoin, urlparse -import jq from aiohttp.web_exceptions import HTTPNotFound from django.conf import settings from django.contrib.postgres.fields import HStoreField @@ -22,6 +21,7 @@ from url_normalize import url_normalize from pulpcore.app.files import PulpTemporaryUploadedFile +from pulpcore.app.jq_utils import compile_safe_jq from pulpcore.app.models import AutoAddObjPermsMixin from pulpcore.app.models.fields import RelativePathField from pulpcore.app.util import cache_key, get_domain_pk, get_url, retain_distributed_pub_enabled @@ -545,11 +545,8 @@ def permit(self, request): if self.jq_filter: try: header_value = json.loads(header_decoded_content) - json_path = jq.compile(self.jq_filter) - - header_value = json_path.input_value(header_value).first() - - except json.JSONDecodeError: + header_value = compile_safe_jq(self.jq_filter).input_value(header_value).first() + except (StopIteration, ValueError): _logger.debug("Access not allowed - Invalid JSON or Path not found.") raise PermissionError(_("Access denied.")) else: diff --git a/pulpcore/tests/unit/test_content_guard.py b/pulpcore/tests/unit/test_content_guard.py index 1e3d83b9829..3df551f57cd 100644 --- a/pulpcore/tests/unit/test_content_guard.py +++ b/pulpcore/tests/unit/test_content_guard.py @@ -248,3 +248,58 @@ def test_envvar_header_content_guard_serializer_rejects_disallowed_env_var(db): serializer = EnvVarHeaderContentGuardSerializer() with pytest.raises(ValidationError): serializer.validate_env_var("DJANGO_SECRET_KEY") + + +@pytest.mark.parametrize("jq_filter", ["env.PULP_TEST_SECRET", "$ENV.PULP_TEST_SECRET"]) +def test_header_content_guard_hides_environment(db, monkeypatch, jq_filter): + """A jq filter must not be able to read the environment of the content process.""" + + monkeypatch.setenv("PULP_TEST_SECRET", "topsecret") + + content_guard = HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter=jq_filter, + header_value="topsecret", + ) + + request = Mock() + request.headers = {"x-header-name": b64encode(b"{}")} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + +def test_header_content_guard_supports_complex_jq_filters(db): + """Legitimate filters keep working, including pipes, select and interpolation.""" + + content_guard = HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter=r'.roles[] | select(.active) | "\(.name)"', + header_value="reader", + ) + + request = Mock() + payload = {"roles": [{"name": "reader", "active": True}]} + request.headers = {"x-header-name": b64encode(json.dumps(payload).encode())} + + assert content_guard.permit(request) is None + + +def test_header_content_guard_denies_when_filter_has_no_result(db): + """Filters that produce no result must deny access instead of raising an error.""" + + content_guard = HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter=".roles[] | select(.active == false) | .name", + header_value="reader", + ) + + request = Mock() + payload = {"roles": [{"name": "reader", "active": True}]} + request.headers = {"x-header-name": b64encode(json.dumps(payload).encode())} + + with pytest.raises(PermissionError): + content_guard.permit(request) diff --git a/pulpcore/tests/unit/test_jq_utils.py b/pulpcore/tests/unit/test_jq_utils.py new file mode 100644 index 00000000000..225abdfda84 --- /dev/null +++ b/pulpcore/tests/unit/test_jq_utils.py @@ -0,0 +1,58 @@ +import pytest + +from pulpcore.app.jq_utils import compile_safe_jq + +DATA = { + "sub": "user1", + "org_id": "org1", + "roles": [{"name": "reader", "active": True}, {"name": "writer", "active": False}], +} + +MALICIOUS_FILTERS = [ + "env", + "$ENV", + "env.SECRET_KEY", + "$ENV.SECRET_KEY", + "env | .", + "def getenv: env; getenv", + "builtins", + 'env // "leaked"', + 'try env catch "leaked"', + ".. , env", + "label $x | env", + 'env | to_entries | map(.value) | join(",")', +] + + +@pytest.mark.parametrize("jq_filter", MALICIOUS_FILTERS) +def test_environment_is_not_reachable(jq_filter, monkeypatch): + """Filters must not be able to read the environment of the running process.""" + monkeypatch.setenv("PULP_TEST_SECRET", "topsecret") + + result = compile_safe_jq(jq_filter).input_value(DATA).first() + + assert "topsecret" not in repr(result) + + +@pytest.mark.parametrize( + "jq_filter,expected", + [ + (".sub", "user1"), + ('.sub // ""', "user1"), + (".roles[] | select(.active) | .name", "reader"), + (".roles | map(.name)", ["reader", "writer"]), + (r'"\(.org_id)|\(.sub)"', "org1|user1"), + (".roles[0].name", "reader"), + (".missing?", None), + ], +) +def test_regular_filters_still_work(jq_filter, expected): + """Shadowing the environment must not restrict the rest of the jq language.""" + result = compile_safe_jq(jq_filter).input_value(DATA).first() + + assert result == expected + + +def test_invalid_filter_raises_value_error(): + with pytest.raises(ValueError): + compile_safe_jq("def broken:").input_value(DATA).first() From a38dba50d458f1e947a2d53db0668b127b3a31f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22decko=22=20de=20Brito?= Date: Wed, 11 Nov 2026 17:14:20 -0300 Subject: [PATCH 2/3] Add regression test for per-character environment oracle (PULP-2649) 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) --- pulpcore/tests/unit/test_content_guard.py | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pulpcore/tests/unit/test_content_guard.py b/pulpcore/tests/unit/test_content_guard.py index 3df551f57cd..b2bea8c1045 100644 --- a/pulpcore/tests/unit/test_content_guard.py +++ b/pulpcore/tests/unit/test_content_guard.py @@ -270,6 +270,38 @@ def test_header_content_guard_hides_environment(db, monkeypatch, jq_filter): content_guard.permit(request) +def test_header_content_guard_denies_per_character_environment_oracle(db, monkeypatch): + """The jq result must not work as a per-character allow/deny oracle for secrets.""" + + monkeypatch.setenv("PULP_TEST_SECRET", "supersecret") + + def permits(jq_filter, guess, payload=b"{}"): + content_guard = HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + header_value=guess, + jq_filter=jq_filter, + ) + request = Mock() + request.headers = {"x-header-name": b64encode(payload)} + try: + content_guard.permit(request) + except PermissionError: + return False + return True + + # Control: an honest filter still authorizes the matching value. + assert permits(".k", "v", b'{"k": "v"}') + assert not permits(".k", "x", b'{"k": "v"}') + + for index, character in enumerate("supersecret"): + for jq_filter in ( + f"env.PULP_TEST_SECRET | .[{index}:{index + 1}]", + f"$ENV.PULP_TEST_SECRET | .[{index}:{index + 1}]", + ): + assert not permits(jq_filter, character) + + def test_header_content_guard_supports_complex_jq_filters(db): """Legitimate filters keep working, including pipes, select and interpolation.""" From e6d7b888d8152b1b230f2c20f56c82d9129af311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22decko=22=20de=20Brito?= Date: Thu, 12 Nov 2026 07:29:05 -0300 Subject: [PATCH 3/3] Address review findings for the jq filter fix (PULP-2649) - 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) --- CHANGES/+cve-2026-123084.bugfix | 2 +- pulpcore/app/authentication.py | 5 +- pulpcore/app/jq_utils.py | 2 +- pulpcore/app/models/publication.py | 15 ++++-- pulpcore/app/serializers/publication.py | 9 ++++ pulpcore/tests/unit/test_authentication.py | 60 ++++++++++++++++++++++ pulpcore/tests/unit/test_content_guard.py | 51 +++++++++++++++++- pulpcore/tests/unit/test_jq_utils.py | 32 ++++++++++-- 8 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 pulpcore/tests/unit/test_authentication.py diff --git a/CHANGES/+cve-2026-123084.bugfix b/CHANGES/+cve-2026-123084.bugfix index 6b8fac407a7..fc74c4f3e3f 100644 --- a/CHANGES/+cve-2026-123084.bugfix +++ b/CHANGES/+cve-2026-123084.bugfix @@ -1 +1 @@ -Fixed an expression language injection in user-supplied jq filters (content guards and JSON header authentication) that could expose the environment of the content process, including the Django secret key and the database password. This addresses CVE-2026-123084. +Fixed an expression language injection in user-supplied jq filters (content guards and JSON header authentication) that could expose the environment of the content process, including the Django secret key and the database password. This addresses CVE-2026-123084. Filters that reference env or $ENV now evaluate to an empty object and deny access; use EnvVarHeaderContentGuard for allow-listed environment variables. diff --git a/pulpcore/app/authentication.py b/pulpcore/app/authentication.py index 376385e8adc..ee9f1efcb6a 100644 --- a/pulpcore/app/authentication.py +++ b/pulpcore/app/authentication.py @@ -1,7 +1,6 @@ import json import logging from base64 import b64decode -from binascii import Error as Base64DecodeError from gettext import gettext as _ from django.conf import settings @@ -62,7 +61,7 @@ def authenticate(self, request): try: header_content = request.META.get(self.header) header_decoded_content = b64decode(header_content) - except Base64DecodeError: + except ValueError: _logger.debug(_("Access not allowed - Header content is not Base64 encoded.")) raise AuthenticationFailed(_("Access denied.")) @@ -71,7 +70,7 @@ def authenticate(self, request): remote_user = compile_safe_jq(self.jq_filter).input_value(header_value).first() except (StopIteration, ValueError): _logger.debug(_("Access not allowed - Invalid JSON or jq filter.")) - raise AuthenticationFailed(_("Access denied. Invalid JSON.")) + raise AuthenticationFailed(_("Access denied.")) if not remote_user: _logger.debug(_("Path or value not found.")) diff --git a/pulpcore/app/jq_utils.py b/pulpcore/app/jq_utils.py index f5565933e4e..af33f5a4b2a 100644 --- a/pulpcore/app/jq_utils.py +++ b/pulpcore/app/jq_utils.py @@ -16,4 +16,4 @@ def compile_safe_jq(program): Shadowing both interfaces keeps the rest of the jq language available while making the environment unreachable from the filter. """ - return jq.compile(f"def env: {{}}; {{}} as $ENV | ({program})") + return jq.compile(f"def env: {{}}; {{}} as $ENV | ({program}\n)") diff --git a/pulpcore/app/models/publication.py b/pulpcore/app/models/publication.py index c7be0d9c583..c2d5959e480 100644 --- a/pulpcore/app/models/publication.py +++ b/pulpcore/app/models/publication.py @@ -538,16 +538,25 @@ def permit(self, request): try: header_decoded_content = b64decode(header_content) - except Base64DecodeError: + except ValueError: _logger.debug("Access not allowed - Header content is not Base64 encoded.") raise PermissionError(_("Access denied.")) if self.jq_filter: try: header_value = json.loads(header_decoded_content) + except ValueError: + _logger.debug("Access not allowed - Header content is not valid JSON.") + raise PermissionError(_("Access denied.")) + try: header_value = compile_safe_jq(self.jq_filter).input_value(header_value).first() - except (StopIteration, ValueError): - _logger.debug("Access not allowed - Invalid JSON or Path not found.") + except StopIteration: + _logger.debug("Access not allowed - jq filter produced no result.") + raise PermissionError(_("Access denied.")) + except ValueError as exc: + _logger.warning( + "Access not allowed - Invalid jq filter %r: %s", self.jq_filter, exc + ) raise PermissionError(_("Access denied.")) else: header_value = header_decoded_content.decode("utf8") diff --git a/pulpcore/app/serializers/publication.py b/pulpcore/app/serializers/publication.py index 449f7709623..a993498c448 100644 --- a/pulpcore/app/serializers/publication.py +++ b/pulpcore/app/serializers/publication.py @@ -6,6 +6,7 @@ from rest_framework import serializers from pulpcore.app import models +from pulpcore.app.jq_utils import compile_safe_jq from pulpcore.app.role_util import get_groups_with_perms, get_users_with_perms from pulpcore.app.serializers import ( BaseURLField, @@ -169,6 +170,14 @@ class HeaderContentGuardSerializer(ContentGuardSerializer, GetOrCreateSerializer required=False, ) + def validate_jq_filter(self, value): + if value: + try: + compile_safe_jq(value) + except ValueError: + raise serializers.ValidationError(_("Invalid jq filter.")) + return value + class Meta(ContentGuardSerializer.Meta): model = models.HeaderContentGuard fields = ContentGuardSerializer.Meta.fields + ("header_name", "header_value", "jq_filter") diff --git a/pulpcore/tests/unit/test_authentication.py b/pulpcore/tests/unit/test_authentication.py new file mode 100644 index 00000000000..d0621e9cc1e --- /dev/null +++ b/pulpcore/tests/unit/test_authentication.py @@ -0,0 +1,60 @@ +from base64 import b64encode +from unittest.mock import Mock, patch + +import pytest +from rest_framework.exceptions import AuthenticationFailed + +from pulpcore.app.authentication import JSONHeaderRemoteAuthentication + +HEADER = "x-rh-identity" + + +def get_auth(jq_filter): + auth = JSONHeaderRemoteAuthentication() + auth.header = HEADER + auth.jq_filter = jq_filter + return auth + + +def get_request(payload): + request = Mock() + request.META = {HEADER: b64encode(payload)} + return request + + +def test_environment_is_not_reachable(monkeypatch): + """A configured filter must not be able to read the environment of the API process.""" + + monkeypatch.setenv("PULP_TEST_SECRET", "topsecret") + + with patch("pulpcore.app.authentication.authenticate") as authenticate: + assert get_auth("env.PULP_TEST_SECRET").authenticate(get_request(b'{"sub": "u"}')) is None + + authenticate.assert_not_called() + + +def test_invalid_filter_denies(): + with pytest.raises(AuthenticationFailed): + get_auth("def broken:").authenticate(get_request(b'{"sub": "u"}')) + + +def test_filter_without_result_denies(): + with pytest.raises(AuthenticationFailed): + get_auth(".missing[]").authenticate(get_request(b'{"sub": "u"}')) + + +def test_non_base64_header_denies(): + request = Mock() + request.META = {HEADER: "é"} + + with pytest.raises(AuthenticationFailed): + get_auth(".sub").authenticate(request) + + +def test_valid_filter_authenticates(): + user = Mock() + + with patch("pulpcore.app.authentication.authenticate", return_value=user): + result = get_auth(".sub").authenticate(get_request(b'{"sub": "u"}')) + + assert result == (user, None) diff --git a/pulpcore/tests/unit/test_content_guard.py b/pulpcore/tests/unit/test_content_guard.py index b2bea8c1045..a9b11ba8688 100644 --- a/pulpcore/tests/unit/test_content_guard.py +++ b/pulpcore/tests/unit/test_content_guard.py @@ -12,7 +12,10 @@ EnvVarHeaderContentGuard, HeaderContentGuard, ) -from pulpcore.app.serializers import EnvVarHeaderContentGuardSerializer +from pulpcore.app.serializers import ( + EnvVarHeaderContentGuardSerializer, + HeaderContentGuardSerializer, +) ENVVAR_HEADER = "X-Test-Content-Guard-Header" ENVVAR_NAME = "ENVVAR_HEADER_GUARD_TEST_SECRET" @@ -335,3 +338,49 @@ def test_header_content_guard_denies_when_filter_has_no_result(db): with pytest.raises(PermissionError): content_guard.permit(request) + + +def test_header_content_guard_denies_invalid_filter(db): + """A broken stored filter denies instead of raising a server error.""" + + content_guard = HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter="def broken:", + header_value="v", + ) + + request = Mock() + request.headers = {"x-header-name": b64encode(b'{"k": "v"}')} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + +def test_header_content_guard_denies_non_ascii_header(db): + """A non-ASCII header value must not raise a server error.""" + + content_guard = HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter=".k", + header_value="v", + ) + + request = Mock() + request.headers = {"x-header-name": "é"} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + +def test_header_content_guard_serializer_rejects_invalid_filter(db): + """Invalid filters are rejected at write time.""" + + serializer = HeaderContentGuardSerializer() + + with pytest.raises(ValidationError): + serializer.validate_jq_filter("def broken:") + + assert serializer.validate_jq_filter(".k # comment") == ".k # comment" + assert serializer.validate_jq_filter(None) is None diff --git a/pulpcore/tests/unit/test_jq_utils.py b/pulpcore/tests/unit/test_jq_utils.py index 225abdfda84..9ce6ce4de88 100644 --- a/pulpcore/tests/unit/test_jq_utils.py +++ b/pulpcore/tests/unit/test_jq_utils.py @@ -11,14 +11,13 @@ MALICIOUS_FILTERS = [ "env", "$ENV", - "env.SECRET_KEY", - "$ENV.SECRET_KEY", + "env.PULP_TEST_SECRET", + "$ENV.PULP_TEST_SECRET", "env | .", "def getenv: env; getenv", - "builtins", 'env // "leaked"', 'try env catch "leaked"', - ".. , env", + "[.., env]", "label $x | env", 'env | to_entries | map(.value) | join(",")', ] @@ -56,3 +55,28 @@ def test_regular_filters_still_work(jq_filter, expected): def test_invalid_filter_raises_value_error(): with pytest.raises(ValueError): compile_safe_jq("def broken:").input_value(DATA).first() + + +def test_environment_interfaces_and_module_loading_stay_blocked(monkeypatch): + """Tripwires for the names the wrapper depends on staying shadowed.""" + monkeypatch.setenv("PULP_TEST_SECRET", "topsecret") + + assert compile_safe_jq("env").input_value(DATA).first() == {} + assert compile_safe_jq("$ENV").input_value(DATA).first() == {} + + builtins = compile_safe_jq("builtins").input_value(DATA).first() + assert "env/0" in builtins + assert "topsecret" not in repr(builtins) + + for jq_filter in ('include "x"; .', 'import "x" as y; .'): + with pytest.raises(ValueError): + compile_safe_jq(jq_filter) + + +@pytest.mark.parametrize( + "jq_filter", + [".sub # trailing comment", ".sub # no newline", ".sub\n# comment on its own line"], +) +def test_trailing_comments_do_not_break_the_wrapper(jq_filter): + """A stored filter ending in a comment must keep working.""" + assert compile_safe_jq(jq_filter).input_value(DATA).first() == "user1"