From adef173db6254987be82329915af92e858628b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22decko=22=20de=20Brito?= Date: Thu, 27 Aug 2026 18:52:39 -0300 Subject: [PATCH] Add EnvVarHeaderContentGuard Validate a Base64-encoded request header against a content-app environment variable so shared secrets can rotate without updating guard records. Names must be listed in ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS. fixes #8007 Assisted-by: Cursor Grok 4.6 Co-authored-by: Cursor --- .github/workflows/scripts/before_install.sh | 4 +- CHANGES/8007.feature | 1 + docs/admin/reference/settings.md | 13 +++ docs/user/guides/protect-content.md | 31 ++++++ .../0158_envvarheadercontentguard.py | 43 ++++++++ pulpcore/app/models/__init__.py | 2 + pulpcore/app/models/publication.py | 72 ++++++++++++ pulpcore/app/serializers/__init__.py | 1 + pulpcore/app/serializers/publication.py | 34 ++++++ pulpcore/app/settings.py | 4 + pulpcore/app/viewsets/__init__.py | 1 + pulpcore/app/viewsets/publication.py | 78 +++++++++++++ .../api/using_plugin/test_contentguard.py | 104 ++++++++++++++++++ pulpcore/tests/unit/test_content_guard.py | 90 ++++++++++++++- template_config.yml | 3 + 15 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 CHANGES/8007.feature create mode 100644 pulpcore/app/migrations/0158_envvarheadercontentguard.py diff --git a/.github/workflows/scripts/before_install.sh b/.github/workflows/scripts/before_install.sh index f8ff33dbf30..42e7ed23053 100755 --- a/.github/workflows/scripts/before_install.sh +++ b/.github/workflows/scripts/before_install.sh @@ -49,8 +49,8 @@ plugin_name: "pulpcore" legacy_component_name: "pulpcore" component_name: "core" component_version: "${COMPONENT_VERSION}" -pulp_env: {"PULP_CA_BUNDLE": "/etc/pulp/certs/pulp_webserver.crt"} -pulp_settings: {"allowed_export_paths": ["/tmp"], "allowed_import_paths": ["/tmp"], "api_root": "/pulp/", "content_path_prefix": "/somewhere/else/", "csrf_trusted_origins": ["https://pulp:443"], "distributed_publication_retention_period": 3, "orphan_protection_time": 0, "task_diagnostics": ["memory"], "task_protection_time": 10, "tmpfile_protection_time": 10, "upload_protection_time": 10} +pulp_env: {"ENVVAR_HEADER_GUARD_TEST_SECRET": "functional-test-secret-value", "PULP_CA_BUNDLE": "/etc/pulp/certs/pulp_webserver.crt"} +pulp_settings: {"allowed_export_paths": ["/tmp"], "allowed_import_paths": ["/tmp"], "api_root": "/pulp/", "content_path_prefix": "/somewhere/else/", "csrf_trusted_origins": ["https://pulp:443"], "distributed_publication_retention_period": 3, "envvar_header_content_guard_allowed_vars": ["ENVVAR_HEADER_GUARD_TEST_SECRET"], "orphan_protection_time": 0, "task_diagnostics": ["memory"], "task_protection_time": 10, "tmpfile_protection_time": 10, "upload_protection_time": 10} pulp_scheme: "https" image: name: "pulp" diff --git a/CHANGES/8007.feature b/CHANGES/8007.feature new file mode 100644 index 00000000000..b598f59dfa8 --- /dev/null +++ b/CHANGES/8007.feature @@ -0,0 +1 @@ +Added ``EnvVarHeaderContentGuard`` to validate a Base64-encoded header against a content-app environment variable listed in ``ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS``. diff --git a/docs/admin/reference/settings.md b/docs/admin/reference/settings.md index b47b17d106a..9f359a22d95 100644 --- a/docs/admin/reference/settings.md +++ b/docs/admin/reference/settings.md @@ -204,6 +204,19 @@ ALLOWED_IMPORT_PATHS = ['/mnt/foo/bar'] # only a subpath is needed Defaults to `[]`, meaning `file:///` urls are not allowed in any Remote. +### ENVVAR\_HEADER\_CONTENT\_GUARD\_ALLOWED\_VARS + +Names of process environment variables that `EnvVarHeaderContentGuard` may read. + +``` +ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = ["SHARED_SECRET"] +``` + +Defaults to `[]`, meaning no environment variable may be used. The secret must be present in the +**content app** process environment (not only the API). Creating this guard type is a privileged +operation: only names on this list can be referenced, which prevents using the content app as an +oracle for other process secrets. + ### ANALYTICS If `True`, Pulp will anonymously post analytics information to diff --git a/docs/user/guides/protect-content.md b/docs/user/guides/protect-content.md index 2237ad36c41..53b333d4d7d 100644 --- a/docs/user/guides/protect-content.md +++ b/docs/user/guides/protect-content.md @@ -61,6 +61,37 @@ pulp content-guard header create --name header-guard --header-name X-Pulp-User - pulp content-guard header create --name header-guard --header-name X-Auth-Service --header-value true --jq-filter '.authenticated' ``` +### EnvVar Header Content Guard + +The env-var header content guard checks a request header against a secret stored in a +**content-app** environment variable. Proxies must send `base64(utf-8(secret))` in the configured +header. Pulp reads the plaintext secret from the content app at request time, so rotating the +secret is a deployment change rather than a database update. + +The environment variable name must be listed in +`ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS`. Creating this guard is privileged: the name is a +pointer into the content-app process environment, so this type should not be granted to +untrusted tenants. + +Set the secret on every content-app replica (and typically the API as well). Setting it only on +the API causes all content requests to be denied. + +Pulp CLI commands for this guard type are not available yet. Use the REST API: + +```bash +# Allow the env var, then create a guard that checks X-Pulp-Shared-Secret against it +# ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = ["SHARED_SECRET"] +export GUARD_HREF=$(curl -s -X POST :24817/pulp/api/v3/contentguards/core/envvar_header/ \ + -H "Content-Type: application/json" \ + -d '{"name": "shared-secret-guard", "header_name": "X-Pulp-Shared-Secret", "env_var": "SHARED_SECRET"}' \ + | jq -r '.pulp_href') + +# Assign it to an existing file distribution (DISTRO_HREF is that distribution's pulp_href) +curl -s -X PATCH :24817${DISTRO_HREF} \ + -H "Content-Type: application/json" \ + -d "{\"content_guard\": \"${GUARD_HREF}\"}" +``` + ### Composite Content Guard The composite content guard combines multiple guards using OR logic - if any of the configured guards allows access, the request is permitted. This enables flexible authentication schemes, like allowing access via either certificates OR RBAC authentication. diff --git a/pulpcore/app/migrations/0158_envvarheadercontentguard.py b/pulpcore/app/migrations/0158_envvarheadercontentguard.py new file mode 100644 index 00000000000..e970e94d572 --- /dev/null +++ b/pulpcore/app/migrations/0158_envvarheadercontentguard.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.15 on 2026-08-26 + +import django.db.models.deletion +from django.db import migrations, models + +import pulpcore.app.models.access_policy + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0157_distribution_base_path_constraint"), + ] + + operations = [ + migrations.CreateModel( + name="EnvVarHeaderContentGuard", + fields=[ + ( + "contentguard_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.contentguard", + ), + ), + ("header_name", models.TextField()), + ("env_var", models.TextField()), + ], + options={ + "permissions": ( + ( + "manage_roles_envvarheadercontentguard", + "Can manage role assignments on EnvVar Header content guard", + ), + ), + "default_related_name": "%(app_label)s_%(model_name)s", + }, + bases=("core.contentguard", pulpcore.app.models.access_policy.AutoAddObjPermsMixin), + ), + ] diff --git a/pulpcore/app/models/__init__.py b/pulpcore/app/models/__init__.py index a1bf1ca1147..e800a5c5762 100644 --- a/pulpcore/app/models/__init__.py +++ b/pulpcore/app/models/__init__.py @@ -61,6 +61,7 @@ CompositeContentGuard, ContentRedirectContentGuard, HeaderContentGuard, + EnvVarHeaderContentGuard, ArtifactDistribution, ) @@ -149,6 +150,7 @@ "CompositeContentGuard", "ContentRedirectContentGuard", "HeaderContentGuard", + "EnvVarHeaderContentGuard", "ArtifactDistribution", "Remote", "Repository", diff --git a/pulpcore/app/models/publication.py b/pulpcore/app/models/publication.py index 7de105a50b2..942a0d7b70f 100644 --- a/pulpcore/app/models/publication.py +++ b/pulpcore/app/models/publication.py @@ -1,4 +1,5 @@ import hashlib +import hmac import json import logging import os @@ -556,6 +557,77 @@ class Meta: ) +class EnvVarHeaderContentGuard(ContentGuard, AutoAddObjPermsMixin): + """ + Content guard that validates a Base64-encoded header against a server-side environment variable. + + Clients and proxies must send the expected secret as a Base64-encoded UTF-8 string in + ``header_name``. Pulp decodes the header, then compares the result to the value of + ``os.environ[env_var]`` using a timing-safe comparison. + + ``env_var`` must be listed in ``settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS``. + The expected secret is read from the content-app process environment at request time + so rotation only requires updating the environment and redeploying. + """ + + TYPE = "envvar_header" + + header_name = models.TextField() + env_var = models.TextField() + + def permit(self, request): + if self.env_var not in settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS: + _logger.debug( + "Access not allowed. Environment variable %s is not in " + "ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS.", + self.env_var, + ) + raise PermissionError(_("Access denied.")) + + header_content = request.headers.get(self.header_name) + if not header_content: + _logger.debug("Access not allowed. Header %s not found.", self.header_name) + raise PermissionError(_("Access denied.")) + + try: + header_decoded_content = b64decode(header_content, validate=True) + except Base64DecodeError: + _logger.debug("Access not allowed - Header content is not Base64 encoded.") + raise PermissionError(_("Access denied.")) from None + + try: + header_value = header_decoded_content.decode("utf-8") + except UnicodeDecodeError: + _logger.debug("Access not allowed - Header content is not valid UTF-8.") + raise PermissionError(_("Access denied.")) from None + + expected = os.environ.get(self.env_var) + if expected is None or expected.rstrip("\r\n") == "": + _logger.warning( + "Access not allowed. Environment variable %s is unset or empty.", self.env_var + ) + raise PermissionError(_("Access denied.")) + + expected_stripped = expected.rstrip("\r\n") + if not hmac.compare_digest( + header_value.encode("utf-8"), + expected_stripped.encode("utf-8"), + ): + _logger.debug("Access not allowed. Header value does not match environment variable.") + raise PermissionError(_("Access denied.")) + + return + + class Meta: + default_related_name = "%(app_label)s_%(model_name)s" + permissions = ( + ( + "manage_roles_envvarheadercontentguard", + "Can manage role assignments on EnvVar Header content guard", + ), + ) + + class CompositeContentGuard(ContentGuard, AutoAddObjPermsMixin): """ Content guard to allow a list of contentguards to be evaluated on access. diff --git a/pulpcore/app/serializers/__init__.py b/pulpcore/app/serializers/__init__.py index 5b8d89a6a80..ac08e40c633 100644 --- a/pulpcore/app/serializers/__init__.py +++ b/pulpcore/app/serializers/__init__.py @@ -90,6 +90,7 @@ CompositeContentGuardSerializer, ContentRedirectContentGuardSerializer, HeaderContentGuardSerializer, + EnvVarHeaderContentGuardSerializer, ArtifactDistributionSerializer, ) from .purge import PurgeSerializer diff --git a/pulpcore/app/serializers/publication.py b/pulpcore/app/serializers/publication.py index d42aeecc72e..449f7709623 100644 --- a/pulpcore/app/serializers/publication.py +++ b/pulpcore/app/serializers/publication.py @@ -1,5 +1,6 @@ from gettext import gettext as _ +from django.conf import settings from django.db.models import Q from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -173,6 +174,39 @@ class Meta(ContentGuardSerializer.Meta): fields = ContentGuardSerializer.Meta.fields + ("header_name", "header_value", "jq_filter") +class EnvVarHeaderContentGuardSerializer(ContentGuardSerializer, GetOrCreateSerializerMixin): + """ + A serializer for EnvVarHeaderContentGuard. + + The guard expects the request header named ``header_name`` to carry a Base64-encoded + UTF-8 representation of the secret. The plaintext secret is read from ``env_var`` on + the server at request time and is never stored in or returned by the API. + """ + + header_name = serializers.CharField(help_text=_("The header name the guard will check on.")) + env_var = serializers.CharField( + help_text=_( + "Name of a content-app environment variable holding the expected secret " + "(plaintext UTF-8). Must be listed in ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS. " + "The request header must send that value Base64-encoded. " + "The value is never stored in or returned by the API." + ), + ) + + def validate_env_var(self, value): + if value not in settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS: + raise serializers.ValidationError( + _( + "Environment variable '{}' is not in ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS." + ).format(value) + ) + return value + + class Meta(ContentGuardSerializer.Meta): + model = models.EnvVarHeaderContentGuard + fields = ContentGuardSerializer.Meta.fields + ("header_name", "env_var") + + class DistributionSerializer(ModelSerializer): """ The Serializer for the Distribution model. diff --git a/pulpcore/app/settings.py b/pulpcore/app/settings.py index d34aebf8a17..40182e456eb 100644 --- a/pulpcore/app/settings.py +++ b/pulpcore/app/settings.py @@ -321,6 +321,10 @@ ALLOWED_EXPORT_PATHS = [] +# Process environment variable names EnvVarHeaderContentGuard may read. +# Empty list means no variable is allowed. +ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = [] + # https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-CACHES CACHES = { "default": { diff --git a/pulpcore/app/viewsets/__init__.py b/pulpcore/app/viewsets/__init__.py index 379ee75c7fc..5fea6efdd55 100644 --- a/pulpcore/app/viewsets/__init__.py +++ b/pulpcore/app/viewsets/__init__.py @@ -59,6 +59,7 @@ CompositeContentGuardViewSet, ContentRedirectContentGuardViewSet, HeaderContentGuardViewSet, + EnvVarHeaderContentGuardViewSet, ArtifactDistributionViewSet, ) from .reclaim import ReclaimSpaceViewSet diff --git a/pulpcore/app/viewsets/publication.py b/pulpcore/app/viewsets/publication.py index 41452aae31f..faa15170d5b 100644 --- a/pulpcore/app/viewsets/publication.py +++ b/pulpcore/app/viewsets/publication.py @@ -9,6 +9,7 @@ ContentGuard, ContentRedirectContentGuard, Distribution, + EnvVarHeaderContentGuard, HeaderContentGuard, Publication, RBACContentGuard, @@ -20,6 +21,7 @@ ContentGuardSerializer, ContentRedirectContentGuardSerializer, DistributionSerializer, + EnvVarHeaderContentGuardSerializer, HeaderContentGuardSerializer, PublicationSerializer, RBACContentGuardSerializer, @@ -412,6 +414,82 @@ class HeaderContentGuardViewSet(ContentGuardViewSet, RolesMixin): } +class EnvVarHeaderContentGuardViewSet(ContentGuardViewSet, RolesMixin): + """ + Content guard that validates a Base64-encoded header against a server-side environment variable. + """ + + endpoint_name = "envvar_header" + queryset = EnvVarHeaderContentGuard.objects.all() + serializer_class = EnvVarHeaderContentGuardSerializer + queryset_filtering_required_permission = "core.view_envvarheadercontentguard" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_perms:core.add_envvarheadercontentguard", + }, + { + "action": ["retrieve", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + "condition": ( + "has_model_or_domain_or_obj_perms:core.view_envvarheadercontentguard" + ), + }, + { + "action": ["update", "partial_update"], + "principal": "authenticated", + "effect": "allow", + "condition": ( + "has_model_or_domain_or_obj_perms:core.change_envvarheadercontentguard" + ), + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": ( + "has_model_or_domain_or_obj_perms:core.delete_envvarheadercontentguard" + ), + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": ( + "has_model_or_domain_or_obj_perms:core.manage_roles_envvarheadercontentguard" + ), + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": ["core.envvarheadercontentguard_owner"]}, + }, + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + LOCKED_ROLES = { + "core.envvarheadercontentguard_creator": ["core.add_envvarheadercontentguard"], + "core.envvarheadercontentguard_owner": [ + "core.view_envvarheadercontentguard", + "core.change_envvarheadercontentguard", + "core.delete_envvarheadercontentguard", + "core.manage_roles_envvarheadercontentguard", + ], + "core.envvarheadercontentguard_viewer": ["core.view_envvarheadercontentguard"], + } + + class CompositeContentGuardViewSet(ContentGuardViewSet, RolesMixin): """ Content guard that queries a list-of content-guards for access permissions. diff --git a/pulpcore/tests/functional/api/using_plugin/test_contentguard.py b/pulpcore/tests/functional/api/using_plugin/test_contentguard.py index 585ced47ec9..dfd7bc0b911 100644 --- a/pulpcore/tests/functional/api/using_plugin/test_contentguard.py +++ b/pulpcore/tests/functional/api/using_plugin/test_contentguard.py @@ -1,4 +1,5 @@ import json +import os import uuid from base64 import b64encode @@ -7,8 +8,12 @@ from pulpcore.client.pulp_file import PatchedfileFileDistribution from pulpcore.client.pulpcore import PatchedCompositeContentGuard +from pulpcore.client.pulpcore.exceptions import ApiException from pulpcore.tests.functional.utils import get_from_url +ENVVAR_HEADER_GUARD_ENV_VAR = "ENVVAR_HEADER_GUARD_TEST_SECRET" +ENVVAR_HEADER_GUARD_HEADER_NAME = "X-Test-Content-Guard-Header" + @pytest.mark.parallel def test_rbac_content_guard_full_workflow( @@ -168,6 +173,105 @@ def test_header_contentguard_workflow( assert response.status == 404 +@pytest.mark.parallel +def test_envvar_header_contentguard_workflow( + pulpcore_bindings, + file_bindings, + distribution_base_url, + gen_user, + file_distribution_factory, + gen_object_with_cleanup, + monitor_task, +): + if not os.environ.get(ENVVAR_HEADER_GUARD_ENV_VAR): + pytest.skip(f"{ENVVAR_HEADER_GUARD_ENV_VAR} not set") + + creator_user = gen_user( + model_roles=["core.envvarheadercontentguard_creator", "file.filedistribution_creator"] + ) + secret = os.environ[ENVVAR_HEADER_GUARD_ENV_VAR].rstrip("\r\n") + + with creator_user: + distro = file_distribution_factory() + guard = gen_object_with_cleanup( + pulpcore_bindings.ContentguardsEnvvarHeaderApi, + { + "name": distro.name, + "header_name": ENVVAR_HEADER_GUARD_HEADER_NAME, + "env_var": ENVVAR_HEADER_GUARD_ENV_VAR, + }, + ) + body = PatchedfileFileDistribution(content_guard=guard.pulp_href) + monitor_task(file_bindings.DistributionsFileApi.partial_update(distro.pulp_href, body).task) + distro = file_bindings.DistributionsFileApi.read(distro.pulp_href) + assert guard.pulp_href == distro.content_guard + retrieved = pulpcore_bindings.ContentguardsEnvvarHeaderApi.read(guard.pulp_href) + assert retrieved.header_name == ENVVAR_HEADER_GUARD_HEADER_NAME + assert retrieved.env_var == ENVVAR_HEADER_GUARD_ENV_VAR + body_dict = retrieved.to_dict() + assert "header_value" not in body_dict + assert secret not in str(body_dict) + + distro_base_url = distribution_base_url(distro.base_url) + + response = get_from_url(distro_base_url, headers=None) + assert response.status == 403 + + wrong_headers = { + ENVVAR_HEADER_GUARD_HEADER_NAME: b64encode(b"wrong-secret").decode("ascii"), + } + response = get_from_url(distro_base_url, headers=wrong_headers) + assert response.status == 403 + + matching_headers = { + ENVVAR_HEADER_GUARD_HEADER_NAME: b64encode(secret.encode("utf-8")).decode("ascii"), + } + response = get_from_url(distro_base_url, headers=matching_headers) + assert response.status == 404 + + +def test_envvar_header_content_guard_rejects_disallowed_env_var(pulpcore_bindings, gen_user): + creator_user = gen_user(model_roles=["core.envvarheadercontentguard_creator"]) + with creator_user, pytest.raises(ApiException) as exc: + pulpcore_bindings.ContentguardsEnvvarHeaderApi.create( + { + "name": str(uuid.uuid4()), + "header_name": ENVVAR_HEADER_GUARD_HEADER_NAME, + "env_var": "HOME", + } + ) + assert exc.value.status == 400 + + +def test_envvar_header_content_guard_access_policy_exists(pulpcore_bindings): + policies = pulpcore_bindings.AccessPoliciesApi.list( + viewset_name="contentguards/core/envvar_header" + ) + assert policies.count == 1 + policy = policies.results[0] + assert policy.statements + actions = {a for s in policy.statements for a in s["action"]} + assert { + "list", + "create", + "retrieve", + "update", + "partial_update", + "destroy", + "my_permissions", + "list_roles", + "add_role", + "remove_role", + } <= actions + create_conditions = " ".join( + str(s.get("condition", "")) for s in policy.statements if "create" in s["action"] + ) + assert "core.add_envvarheadercontentguard" in create_conditions + assert policy.creation_hooks is not None + assert any("core.envvarheadercontentguard_owner" in str(hook) for hook in policy.creation_hooks) + assert policy.queryset_scoping is not None + + def test_composite_contentguard_crud( pulpcore_bindings, gen_user, diff --git a/pulpcore/tests/unit/test_content_guard.py b/pulpcore/tests/unit/test_content_guard.py index 9024020f239..af722394f11 100644 --- a/pulpcore/tests/unit/test_content_guard.py +++ b/pulpcore/tests/unit/test_content_guard.py @@ -1,11 +1,50 @@ import json +import os import re from base64 import b64encode from unittest.mock import Mock import pytest +from rest_framework.serializers import ValidationError -from pulpcore.app.models import ContentRedirectContentGuard, HeaderContentGuard +from pulpcore.app.models import ( + ContentRedirectContentGuard, + EnvVarHeaderContentGuard, + HeaderContentGuard, +) +from pulpcore.app.serializers import EnvVarHeaderContentGuardSerializer + +ENVVAR_HEADER = "X-Test-Content-Guard-Header" +ENVVAR_NAME = "ENVVAR_HEADER_GUARD_TEST_SECRET" + + +def _encode_envvar_secret(secret): + return b64encode(secret.encode("utf-8")).decode("ascii") + + +def _envvar_guard(env_var=ENVVAR_NAME): + return EnvVarHeaderContentGuard( + name="envvar_header_guard", + header_name=ENVVAR_HEADER, + env_var=env_var, + ) + + +def _envvar_request(header_value=None, header_name=ENVVAR_HEADER): + request = Mock() + headers = {} + if header_value is not None: + headers[header_name] = header_value + request.headers = headers + return request + + +@pytest.fixture +def envvar_header_secret(): + secret = os.environ.get(ENVVAR_NAME) + if secret is None or secret.rstrip("\r\n") == "": + pytest.skip(f"{ENVVAR_NAME} not set") + return secret.rstrip("\r\n") def test_preauthenticate_urls(): @@ -142,3 +181,52 @@ def test_header_content_guard(db): encoded_value = b64encode(b"somevalue") request.headers = {"x-header-name": encoded_value} assert not content_guard_without_jq_filter.permit(request) + + +def test_envvar_header_content_guard_allows_matching_header(db, 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): + with pytest.raises(PermissionError): + _envvar_guard().permit(_envvar_request()) + + +def test_envvar_header_content_guard_denies_wrong_header_name(db, envvar_header_secret): + with pytest.raises(PermissionError): + _envvar_guard().permit( + _envvar_request( + _encode_envvar_secret(envvar_header_secret), header_name="X-Other-Header" + ) + ) + + +def test_envvar_header_content_guard_denies_invalid_base64(db, envvar_header_secret): + with pytest.raises(PermissionError): + _envvar_guard().permit(_envvar_request("!!!c3VwZXItc2VjcmV0LXZhbHVl!!!")) + + +def test_envvar_header_content_guard_denies_non_base64_header(db, envvar_header_secret): + with pytest.raises(PermissionError): + _envvar_guard().permit(_envvar_request("A")) + + +def test_envvar_header_content_guard_denies_invalid_utf8(db, envvar_header_secret): + with pytest.raises(PermissionError): + _envvar_guard().permit(_envvar_request(b64encode(b"\xff\xfe").decode("ascii"))) + + +def test_envvar_header_content_guard_denies_wrong_value(db, envvar_header_secret): + with pytest.raises(PermissionError): + _envvar_guard().permit(_envvar_request(_encode_envvar_secret("wrong-value"))) + + +def test_envvar_header_content_guard_denies_env_var_not_in_allowlist(db): + with pytest.raises(PermissionError): + _envvar_guard(env_var="HOME").permit(_envvar_request()) + + +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") diff --git a/template_config.yml b/template_config.yml index 0a72287323b..13a4e4af29f 100644 --- a/template_config.yml +++ b/template_config.yml @@ -43,6 +43,7 @@ plugins: name: "pulp_certguard" pulp_env: PULP_CA_BUNDLE: "/etc/pulp/certs/pulp_webserver.crt" + ENVVAR_HEADER_GUARD_TEST_SECRET: "functional-test-secret-value" pulp_env_azure: {} pulp_env_gcp: {} pulp_env_s3: {} @@ -52,6 +53,8 @@ pulp_settings: - "/tmp" allowed_import_paths: - "/tmp" + envvar_header_content_guard_allowed_vars: + - "ENVVAR_HEADER_GUARD_TEST_SECRET" api_root: "/pulp/" content_path_prefix: "/somewhere/else/" csrf_trusted_origins: