diff --git a/CHANGES/+CVE-2026-123084.bugfix b/CHANGES/+CVE-2026-123084.bugfix new file mode 100644 index 0000000000..a1a647539a --- /dev/null +++ b/CHANGES/+CVE-2026-123084.bugfix @@ -0,0 +1 @@ +Disabled complex `jq_filter` programs in `HeaderContentGuard` by default. Simple dotted paths continue to work. Use `settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT` if you __need__ to use it. CVE-2026-123084 diff --git a/pulpcore/app/models/publication.py b/pulpcore/app/models/publication.py index 8c9535d316..025b33cbaf 100644 --- a/pulpcore/app/models/publication.py +++ b/pulpcore/app/models/publication.py @@ -7,10 +7,10 @@ from base64 import b64decode from binascii import Error as Base64DecodeError from datetime import timedelta +from functools import reduce 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 @@ -35,6 +35,13 @@ _logger = logging.getLogger(__name__) +if settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT: # noqa + import jq +else: + # Match a dotted identifier path. + # e.g.: "tenant.auth.token" + json_path_re = re.compile(r"(?:\.\w+)+", flags=re.ASCII) + class PublicationQuerySet(models.QuerySet): """A queryset that provides publication filtering methods.""" @@ -544,12 +551,32 @@ def permit(self, request): if self.jq_filter: try: - header_value = json.loads(header_decoded_content) - json_path = jq.compile(self.jq_filter) + header_json = json.loads(header_decoded_content) + if settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT: # noqa + # DISCLAIMER: + # The prelude attempts to strip the environment from the jq srcipt. + # That does not mean it is safe to allow users to define a script to run on + # the content app. + JQ_PRELUDE = r"def env: {}; {} as $ENV |" + json_path = jq.compile( + f"{JQ_PRELUDE} {self.jq_filter}", + args={ + "JQ_BUILD_CONFIGURATION": "", + "ARGS": {"positional": [], "named": {}}, + }, + ) - header_value = json_path.input_value(header_value).first() + header_value = json_path.input_value(header_json).first() - except json.JSONDecodeError: + elif json_path_re.fullmatch(self.jq_filter): + path = self.jq_filter.lstrip(".").split(".") + header_value = reduce(lambda x, y: x[y], path, header_json) + else: + _logger.warning( + "Using 'jq_filter' is only allowed with simple paths on this installation." + ) + raise PermissionError(_("ContentGuard misconfigured.")) + except (TypeError, KeyError, json.JSONDecodeError): _logger.debug("Access not allowed - Invalid JSON or Path not found.") raise PermissionError(_("Access denied.")) else: diff --git a/pulpcore/app/serializers/__init__.py b/pulpcore/app/serializers/__init__.py index ac08e40c63..85785c7d14 100644 --- a/pulpcore/app/serializers/__init__.py +++ b/pulpcore/app/serializers/__init__.py @@ -47,6 +47,7 @@ relative_path_validator, TaskGroupStatusCountField, pulp_labels_validator, + RegexValidator, ) from .access_policy import AccessPolicySerializer from .acs import ( diff --git a/pulpcore/app/serializers/fields.py b/pulpcore/app/serializers/fields.py index d6f4443aa5..9ee276f51c 100644 --- a/pulpcore/app/serializers/fields.py +++ b/pulpcore/app/serializers/fields.py @@ -28,6 +28,17 @@ def relative_path_validator(relative_path): return relative_path +class RegexValidator: + def __init__(self, regex, flags=0): + self.pattern = re.compile(regex, flags) + + def __call__(self, value): + if not self.pattern.fullmatch(value): + raise serializers.ValidationError( + _("Value does not match pattern r'{}'.").format(self.pattern.pattern) + ) + + class RelativePathField(serializers.CharField): """ Serializer Field for the base_url field of the Distribution. diff --git a/pulpcore/app/serializers/publication.py b/pulpcore/app/serializers/publication.py index 449f770962..be82412f05 100644 --- a/pulpcore/app/serializers/publication.py +++ b/pulpcore/app/serializers/publication.py @@ -1,3 +1,4 @@ +import re from gettext import gettext as _ from django.conf import settings @@ -14,6 +15,7 @@ DomainUniqueValidator, GetOrCreateSerializerMixin, ModelSerializer, + RegexValidator, RelativePathField, RepositoryVersionRelatedField, pulp_labels_validator, @@ -161,14 +163,23 @@ class HeaderContentGuardSerializer(ContentGuardSerializer, GetOrCreateSerializer jq_filter = serializers.CharField( help_text=_( ( - "A JQ syntax compatible filter. If jq_filter is not set, then the value will" - "only be Base64 decoded and checked as an explicit string match." + "A simple dotted path (e.g. .a.b.c) used to extract the value from the " + "Base64 decoded JSON header. If jq_filter is not set, the header value " + "is only Base64 decoded and checked as an explicit string match." ) ), allow_null=True, required=False, ) + def validate_jq_filter(self, value): + if ( + value is not None + and not settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT + ): # noqa + RegexValidator(r"(?:\.\w+)+", re.ASCII)(value) + return value + class Meta(ContentGuardSerializer.Meta): model = models.HeaderContentGuard fields = ContentGuardSerializer.Meta.fields + ("header_name", "header_value", "jq_filter") diff --git a/pulpcore/app/settings.py b/pulpcore/app/settings.py index 8a4f1bf141..6a55f7380b 100644 --- a/pulpcore/app/settings.py +++ b/pulpcore/app/settings.py @@ -456,6 +456,11 @@ # Replaces every non PulpException error msg with "An internal error occured" REDACT_UNSAFE_EXCEPTIONS = False +# JQ is a query language after all. And it should be considered on the same level as SQL. +# Do not allow users to write arbitrary programs to run on the content app. +# Exposes to at least CVE-2026-123084. +ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = False + # HERE STARTS DYNACONF EXTENSION LOAD (Keep at the very bottom of settings.py) # Read more at https://www.dynaconf.com/django/ diff --git a/pulpcore/tests/unit/test_content_guard.py b/pulpcore/tests/unit/test_content_guard.py index 1e3d83b982..732b853630 100644 --- a/pulpcore/tests/unit/test_content_guard.py +++ b/pulpcore/tests/unit/test_content_guard.py @@ -4,9 +4,11 @@ from base64 import b64encode from unittest.mock import Mock +import jq import pytest from rest_framework.serializers import ValidationError +import pulpcore.app.models.publication from pulpcore.app.models import ( ContentRedirectContentGuard, EnvVarHeaderContentGuard, @@ -47,7 +49,7 @@ def envvar_header_secret(): return secret.rstrip("\r\n") -def test_preauthenticate_urls(): +def test_preauthenticate_urls(fake_domain): """Test that the redirecting content guard can produce url to the content app.""" original_url = "http://localhost:8080/pulp/content/dist/" @@ -116,83 +118,210 @@ def test_preauthenticate_urls(): content_guard.permit(request) -def test_header_content_guard(db): +class TestHeaderContentGuard: """Test HeaderContentGuard to protect content based on a specific header.""" - content_guard = HeaderContentGuard( - name="header_guard", - header_name="x-header-name", - jq_filter=".this.is.a.path", - header_value="somevalue", - ) + @pytest.fixture() + def content_guard(self, fake_domain): + return HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + header_value="somevalue", + ) - request = Mock() + def test_accepts_right_value(self, content_guard): + request = Mock() + encoded_value = b64encode(b"somevalue") + request.headers = {"x-header-name": encoded_value} + assert content_guard.permit(request) is None - # Try without any header - request.headers = {} - with pytest.raises(PermissionError): - content_guard.permit(request) - # Try with a wrong header_name - request.headers = {"x-burger": "food"} - with pytest.raises(PermissionError): - content_guard.permit(request) +class TestHeaderContentGuardWithJsonPath: + """Test HeaderContentGuard to protect content based on a specific header.""" - # Try the right header with a non base64 encoded content - request.headers = {"x-header-name": "somestring"} - with pytest.raises(PermissionError): - content_guard.permit(request) + @pytest.fixture() + def content_guard(self, settings, fake_domain): + settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = False # noqa + return HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter=".this.is_a.path", + header_value="somevalue", + ) - # Try the right header and a wrong value correctly encoded - header_value = b64encode(b"anything_here") - request.headers = {"x-header-name": header_value} - with pytest.raises(PermissionError): - content_guard.permit(request) + def test_rejects_wrong_value(self, settings, content_guard): + settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = False # noqa - # Try the right header with the wrong jq_filter - # but right value - header_value = json.dumps({"this": {"is": {"not_a": {"path": "somevalue"}}}}) - encoded_value = b64encode(bytes(header_value, "ascii")) + request = Mock() + header_value = json.dumps({"this": {"is_a": {"path": "othervalue"}}}) + encoded_value = b64encode(bytes(header_value, "ascii")) - request.headers = {"x-header-name": encoded_value} - with pytest.raises(PermissionError): - content_guard.permit(request) + request.headers = {"x-header-name": encoded_value} + with pytest.raises(PermissionError): + content_guard.permit(request) - # Try the right header, right jq_filter but wrong value - header_value = json.dumps({"this": {"is": {"a": {"path": "anything_here"}}}}) - encoded_value = b64encode(bytes(header_value, "ascii")) + def test_rejects_value_in_wrong_path(self, settings, content_guard): + settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = False # noqa - request.headers = {"x-header-name": encoded_value} - with pytest.raises(PermissionError): - content_guard.permit(request) + request = Mock() + header_value = json.dumps({"this": {"is": {"not_the": {"path": "somevalue"}}}}) + encoded_value = b64encode(bytes(header_value, "ascii")) - # Try the right header, json_access and value. - header_value = json.dumps({"this": {"is": {"a": {"path": "somevalue"}}}}) - encoded_value = b64encode(bytes(header_value, "ascii")) + request.headers = {"x-header-name": encoded_value} + with pytest.raises(PermissionError): + content_guard.permit(request) - request.headers = {"x-header-name": encoded_value} - assert not content_guard.permit(request) + def test_accepts_value_in_path(self, settings, content_guard): + settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = False # noqa - # Try to use a value that is not a JSON. - content_guard_without_jq_filter = HeaderContentGuard( - name="header_guard", header_name="x-header-name", header_value="somevalue" - ) + request = Mock() + header_value = json.dumps({"this": {"is_a": {"path": "somevalue"}}}) + encoded_value = b64encode(bytes(header_value, "ascii")) + + request.headers = {"x-header-name": encoded_value} + assert content_guard.permit(request) is None + + +class TestHeaderContentGuardWithJqFilter: + """Test HeaderContentGuard to protect content based on a specific header using jq_filter.""" + + @pytest.fixture() + def content_guard(self, fake_domain, settings, monkeypatch): + settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = True # noqa + # Fake that we imported jq according to the (to late) changed settings. + monkeypatch.setattr(pulpcore.app.models.publication, "jq", jq, raising=False) + + return HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter=".this.is.a|{path}.path", + header_value="somevalue", + ) + + def test_rejects_request_without_any_header(self, content_guard): + request = Mock() + request.headers = {} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + def test_rejects_request_with_wrong_header(self, content_guard): + request = Mock() + request.headers = {"x-burger": "food"} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + def test_rejects_request_with_value_not_base64(self, content_guard): + request = Mock() + request.headers = {"x-header-name": "somestring"} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + def test_rejects_request_with_wrong_base64_value(self, content_guard): + request = Mock() + header_value = b64encode(b"anything_here") + request.headers = {"x-header-name": header_value} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + def test_rejects_request_with_value_in_wrong_path(self, content_guard): + request = Mock() + header_value = json.dumps({"this": {"is": {"not_a": {"path": "somevalue"}}}}) + encoded_value = b64encode(bytes(header_value, "ascii")) + request.headers = {"x-header-name": encoded_value} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + def test_rejects_request_with_wrong_value_in_right_path(self, content_guard): + request = Mock() + header_value = json.dumps({"this": {"is": {"a": {"path": "anything_here"}}}}) + encoded_value = b64encode(bytes(header_value, "ascii")) + request.headers = {"x-header-name": encoded_value} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + def test_accepts_request_with_right_value_in_right_path(self, content_guard): + request = Mock() + header_value = json.dumps({"this": {"is": {"a": {"path": "somevalue"}}}}) + encoded_value = b64encode(bytes(header_value, "ascii")) + request.headers = {"x-header-name": encoded_value} + + assert content_guard.permit(request) is None + + def test_jq_feature_flag(self, settings, caplog, content_guard): + settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = False # noqa + caplog.set_level("WARNING") + + request = Mock() + header_value = json.dumps({"this": {"is": {"a": {"path": "somevalue"}}}}) + encoded_value = b64encode(bytes(header_value, "ascii")) + request.headers = {"x-header-name": encoded_value} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + print(caplog.records) + assert any( + record.levelname == "WARNING" + and record.message + == "Using 'jq_filter' is only allowed with simple paths on this installation." + for record in caplog.records + ) + + +class TestHeaderContentGuardWithJqFilterExploitEnvOracle: + @pytest.fixture() + def content_guard(self, fake_domain, settings, monkeypatch): + monkeypatch.setenv("SECRET", "HONEYPOT") + settings.ALLOW_INSECURE_HEADER_CONTENT_GUARD_JQ_PROCESSING_AND_FORFEIT_ALL_COMMUNITY_SUPPORT = True # noqa + # Fake that we imported jq according to the (to late) changed settings. + monkeypatch.setattr(pulpcore.app.models.publication, "jq", jq, raising=False) + + return HeaderContentGuard( + name="header_guard", + header_name="x-header-name", + jq_filter='"t"', + header_value="t", + ) + + def test_cannot_read_env(self, content_guard): + content_guard.jq_filter = 'if (env.SECRET[.pos:.pos+1] == .char) then "t" else "f" end' + + request = Mock() + header_value = json.dumps({"pos": 3, "char": "E"}) + encoded_value = b64encode(bytes(header_value, "ascii")) + request.headers = {"x-header-name": encoded_value} + + with pytest.raises(PermissionError): + content_guard.permit(request) + + def test_cannot_read_dollar_env(self, content_guard): + content_guard.jq_filter = 'if ($ENV.SECRET[.pos:.pos+1] == .char) then "t" else "f" end' + + request = Mock() + header_value = json.dumps({"pos": 3, "char": "E"}) + encoded_value = b64encode(bytes(header_value, "ascii")) + request.headers = {"x-header-name": encoded_value} - encoded_value = b64encode(b"somevalue") - request.headers = {"x-header-name": encoded_value} - assert not content_guard_without_jq_filter.permit(request) + with pytest.raises(PermissionError): + content_guard.permit(request) -def test_envvar_header_content_guard_allows_matching_header(db, envvar_header_secret): +def test_envvar_header_content_guard_allows_matching_header(fake_domain, envvar_header_secret): _envvar_guard().permit(_envvar_request(_encode_envvar_secret(envvar_header_secret))) -def test_envvar_header_content_guard_denies_missing_header(db, envvar_header_secret): +def test_envvar_header_content_guard_denies_missing_header(fake_domain, envvar_header_secret): with pytest.raises(PermissionError, match="Header X-Test-Content-Guard-Header not found"): _envvar_guard().permit(_envvar_request()) -def test_envvar_header_content_guard_denies_wrong_header_name(db, envvar_header_secret): +def test_envvar_header_content_guard_denies_wrong_header_name(fake_domain, envvar_header_secret): with pytest.raises(PermissionError): _envvar_guard().permit( _envvar_request( @@ -201,22 +330,22 @@ def test_envvar_header_content_guard_denies_wrong_header_name(db, envvar_header_ ) -def test_envvar_header_content_guard_denies_invalid_base64(db, envvar_header_secret): +def test_envvar_header_content_guard_denies_invalid_base64(fake_domain, envvar_header_secret): with pytest.raises(PermissionError, match="not Base64 encoded"): _envvar_guard().permit(_envvar_request("!!!c3VwZXItc2VjcmV0LXZhbHVl!!!")) -def test_envvar_header_content_guard_denies_non_base64_header(db, envvar_header_secret): +def test_envvar_header_content_guard_denies_non_base64_header(fake_domain, envvar_header_secret): with pytest.raises(PermissionError, match="not Base64 encoded"): _envvar_guard().permit(_envvar_request("A")) -def test_envvar_header_content_guard_denies_invalid_utf8(db, envvar_header_secret): +def test_envvar_header_content_guard_denies_invalid_utf8(fake_domain, envvar_header_secret): with pytest.raises(PermissionError, match="not valid UTF-8"): _envvar_guard().permit(_envvar_request(b64encode(b"\xff\xfe").decode("ascii"))) -def test_envvar_header_content_guard_denies_wrong_value(db, envvar_header_secret, caplog): +def test_envvar_header_content_guard_denies_wrong_value(fake_domain, envvar_header_secret, caplog): caplog.set_level("WARNING") with pytest.raises(PermissionError, match="Access denied"): _envvar_guard().permit(_envvar_request(_encode_envvar_secret("wrong-value"))) @@ -228,7 +357,7 @@ def test_envvar_header_content_guard_denies_wrong_value(db, envvar_header_secret ) -def test_envvar_header_content_guard_denies_env_var_not_in_allowlist(db, caplog): +def test_envvar_header_content_guard_denies_env_var_not_in_allowlist(fake_domain, caplog): caplog.set_level("WARNING") with pytest.raises(PermissionError, match="Access denied"): _envvar_guard(env_var="HOME").permit(_envvar_request("not-base64")) @@ -238,13 +367,13 @@ def test_envvar_header_content_guard_denies_env_var_not_in_allowlist(db, caplog) ) -def test_envvar_header_content_guard_denies_empty_env_var(db, monkeypatch): +def test_envvar_header_content_guard_denies_empty_env_var(fake_domain, monkeypatch): monkeypatch.setenv(ENVVAR_NAME, "") with pytest.raises(PermissionError, match="Access denied"): _envvar_guard().permit(_envvar_request("not-base64")) -def test_envvar_header_content_guard_serializer_rejects_disallowed_env_var(db): +def test_envvar_header_content_guard_serializer_rejects_disallowed_env_var(fake_domain): serializer = EnvVarHeaderContentGuardSerializer() with pytest.raises(ValidationError): serializer.validate_env_var("DJANGO_SECRET_KEY")