Skip to content

Commit 6d4e26f

Browse files
deckocursoragent
andcommitted
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 <cursoragent@cursor.com>
1 parent 9163897 commit 6d4e26f

15 files changed

Lines changed: 480 additions & 3 deletions

File tree

.github/workflows/scripts/before_install.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ plugin_name: "pulpcore"
4949
legacy_component_name: "pulpcore"
5050
component_name: "core"
5151
component_version: "${COMPONENT_VERSION}"
52-
pulp_env: {"PULP_CA_BUNDLE": "/etc/pulp/certs/pulp_webserver.crt"}
53-
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}
52+
pulp_env: {"ENVVAR_HEADER_GUARD_TEST_SECRET": "functional-test-secret-value", "PULP_CA_BUNDLE": "/etc/pulp/certs/pulp_webserver.crt"}
53+
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}
5454
pulp_scheme: "https"
5555
image:
5656
name: "pulp"

CHANGES/8007.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added ``EnvVarHeaderContentGuard`` to validate a Base64-encoded header against a content-app environment variable listed in ``ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS``.

docs/admin/reference/settings.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,19 @@ ALLOWED_IMPORT_PATHS = ['/mnt/foo/bar'] # only a subpath is needed
204204

205205
Defaults to `[]`, meaning `file:///` urls are not allowed in any Remote.
206206

207+
### ENVVAR\_HEADER\_CONTENT\_GUARD\_ALLOWED\_VARS
208+
209+
Names of process environment variables that `EnvVarHeaderContentGuard` may read.
210+
211+
```
212+
ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = ["SHARED_SECRET"]
213+
```
214+
215+
Defaults to `[]`, meaning no environment variable may be used. The secret must be present in the
216+
**content app** process environment (not only the API). Creating this guard type is a privileged
217+
operation: only names on this list can be referenced, which prevents using the content app as an
218+
oracle for other process secrets.
219+
207220
### ANALYTICS
208221

209222
If `True`, Pulp will anonymously post analytics information to

docs/user/guides/protect-content.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,37 @@ pulp content-guard header create --name header-guard --header-name X-Pulp-User -
6161
pulp content-guard header create --name header-guard --header-name X-Auth-Service --header-value true --jq-filter '.authenticated'
6262
```
6363

64+
### EnvVar Header Content Guard
65+
66+
The env-var header content guard checks a request header against a secret stored in a
67+
**content-app** environment variable. Proxies must send `base64(utf-8(secret))` in the configured
68+
header. Pulp reads the plaintext secret from the content app at request time, so rotating the
69+
secret is a deployment change rather than a database update.
70+
71+
The environment variable name must be listed in
72+
`ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS`. Creating this guard is privileged: the name is a
73+
pointer into the content-app process environment, so this type should not be granted to
74+
untrusted tenants.
75+
76+
Set the secret on every content-app replica (and typically the API as well). Setting it only on
77+
the API causes all content requests to be denied.
78+
79+
Pulp CLI commands for this guard type are not available yet. Use the REST API:
80+
81+
```bash
82+
# Allow the env var, then create a guard that checks X-Pulp-Shared-Secret against it
83+
# ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = ["SHARED_SECRET"]
84+
export GUARD_HREF=$(curl -s -X POST :24817/pulp/api/v3/contentguards/core/envvar_header/ \
85+
-H "Content-Type: application/json" \
86+
-d '{"name": "shared-secret-guard", "header_name": "X-Pulp-Shared-Secret", "env_var": "SHARED_SECRET"}' \
87+
| jq -r '.pulp_href')
88+
89+
# Assign it to an existing file distribution (DISTRO_HREF is that distribution's pulp_href)
90+
curl -s -X PATCH :24817${DISTRO_HREF} \
91+
-H "Content-Type: application/json" \
92+
-d "{\"content_guard\": \"${GUARD_HREF}\"}"
93+
```
94+
6495
### Composite Content Guard
6596

6697
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.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 5.2.15 on 2026-08-26
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
import pulpcore.app.models.access_policy
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("core", "0157_distribution_base_path_constraint"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="EnvVarHeaderContentGuard",
17+
fields=[
18+
(
19+
"contentguard_ptr",
20+
models.OneToOneField(
21+
auto_created=True,
22+
on_delete=django.db.models.deletion.CASCADE,
23+
parent_link=True,
24+
primary_key=True,
25+
serialize=False,
26+
to="core.contentguard",
27+
),
28+
),
29+
("header_name", models.TextField()),
30+
("env_var", models.TextField()),
31+
],
32+
options={
33+
"permissions": (
34+
(
35+
"manage_roles_envvarheadercontentguard",
36+
"Can manage role assignments on EnvVar Header content guard",
37+
),
38+
),
39+
"default_related_name": "%(app_label)s_%(model_name)s",
40+
},
41+
bases=("core.contentguard", pulpcore.app.models.access_policy.AutoAddObjPermsMixin),
42+
),
43+
]

pulpcore/app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
CompositeContentGuard,
6262
ContentRedirectContentGuard,
6363
HeaderContentGuard,
64+
EnvVarHeaderContentGuard,
6465
ArtifactDistribution,
6566
)
6667

@@ -149,6 +150,7 @@
149150
"CompositeContentGuard",
150151
"ContentRedirectContentGuard",
151152
"HeaderContentGuard",
153+
"EnvVarHeaderContentGuard",
152154
"ArtifactDistribution",
153155
"Remote",
154156
"Repository",

pulpcore/app/models/publication.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import hashlib
2+
import hmac
23
import json
34
import logging
45
import os
@@ -556,6 +557,77 @@ class Meta:
556557
)
557558

558559

560+
class EnvVarHeaderContentGuard(ContentGuard, AutoAddObjPermsMixin):
561+
"""
562+
Content guard that validates a Base64-encoded header against a server-side environment variable.
563+
564+
Clients and proxies must send the expected secret as a Base64-encoded UTF-8 string in
565+
``header_name``. Pulp decodes the header, then compares the result to the value of
566+
``os.environ[env_var]`` using a timing-safe comparison.
567+
568+
``env_var`` must be listed in ``settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS``.
569+
The expected secret is read from the content-app process environment at request time
570+
so rotation only requires updating the environment and redeploying.
571+
"""
572+
573+
TYPE = "envvar_header"
574+
575+
header_name = models.TextField()
576+
env_var = models.TextField()
577+
578+
def permit(self, request):
579+
if self.env_var not in settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS:
580+
_logger.debug(
581+
"Access not allowed. Environment variable %s is not in "
582+
"ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS.",
583+
self.env_var,
584+
)
585+
raise PermissionError(_("Access denied."))
586+
587+
header_content = request.headers.get(self.header_name)
588+
if not header_content:
589+
_logger.debug("Access not allowed. Header %s not found.", self.header_name)
590+
raise PermissionError(_("Access denied."))
591+
592+
try:
593+
header_decoded_content = b64decode(header_content, validate=True)
594+
except Base64DecodeError:
595+
_logger.debug("Access not allowed - Header content is not Base64 encoded.")
596+
raise PermissionError(_("Access denied.")) from None
597+
598+
try:
599+
header_value = header_decoded_content.decode("utf-8")
600+
except UnicodeDecodeError:
601+
_logger.debug("Access not allowed - Header content is not valid UTF-8.")
602+
raise PermissionError(_("Access denied.")) from None
603+
604+
expected = os.environ.get(self.env_var)
605+
if expected is None or expected.rstrip("\r\n") == "":
606+
_logger.warning(
607+
"Access not allowed. Environment variable %s is unset or empty.", self.env_var
608+
)
609+
raise PermissionError(_("Access denied."))
610+
611+
expected_stripped = expected.rstrip("\r\n")
612+
if not hmac.compare_digest(
613+
header_value.encode("utf-8"),
614+
expected_stripped.encode("utf-8"),
615+
):
616+
_logger.debug("Access not allowed. Header value does not match environment variable.")
617+
raise PermissionError(_("Access denied."))
618+
619+
return
620+
621+
class Meta:
622+
default_related_name = "%(app_label)s_%(model_name)s"
623+
permissions = (
624+
(
625+
"manage_roles_envvarheadercontentguard",
626+
"Can manage role assignments on EnvVar Header content guard",
627+
),
628+
)
629+
630+
559631
class CompositeContentGuard(ContentGuard, AutoAddObjPermsMixin):
560632
"""
561633
Content guard to allow a list of contentguards to be evaluated on access.

pulpcore/app/serializers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
CompositeContentGuardSerializer,
9191
ContentRedirectContentGuardSerializer,
9292
HeaderContentGuardSerializer,
93+
EnvVarHeaderContentGuardSerializer,
9394
ArtifactDistributionSerializer,
9495
)
9596
from .purge import PurgeSerializer

pulpcore/app/serializers/publication.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from gettext import gettext as _
22

3+
from django.conf import settings
34
from django.db.models import Q
45
from drf_spectacular.utils import extend_schema_field
56
from rest_framework import serializers
@@ -173,6 +174,39 @@ class Meta(ContentGuardSerializer.Meta):
173174
fields = ContentGuardSerializer.Meta.fields + ("header_name", "header_value", "jq_filter")
174175

175176

177+
class EnvVarHeaderContentGuardSerializer(ContentGuardSerializer, GetOrCreateSerializerMixin):
178+
"""
179+
A serializer for EnvVarHeaderContentGuard.
180+
181+
The guard expects the request header named ``header_name`` to carry a Base64-encoded
182+
UTF-8 representation of the secret. The plaintext secret is read from ``env_var`` on
183+
the server at request time and is never stored in or returned by the API.
184+
"""
185+
186+
header_name = serializers.CharField(help_text=_("The header name the guard will check on."))
187+
env_var = serializers.CharField(
188+
help_text=_(
189+
"Name of a content-app environment variable holding the expected secret "
190+
"(plaintext UTF-8). Must be listed in ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS. "
191+
"The request header must send that value Base64-encoded. "
192+
"The value is never stored in or returned by the API."
193+
),
194+
)
195+
196+
def validate_env_var(self, value):
197+
if value not in settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS:
198+
raise serializers.ValidationError(
199+
_(
200+
"Environment variable '{}' is not in ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS."
201+
).format(value)
202+
)
203+
return value
204+
205+
class Meta(ContentGuardSerializer.Meta):
206+
model = models.EnvVarHeaderContentGuard
207+
fields = ContentGuardSerializer.Meta.fields + ("header_name", "env_var")
208+
209+
176210
class DistributionSerializer(ModelSerializer):
177211
"""
178212
The Serializer for the Distribution model.

pulpcore/app/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,10 @@
321321

322322
ALLOWED_EXPORT_PATHS = []
323323

324+
# Process environment variable names EnvVarHeaderContentGuard may read.
325+
# Empty list means no variable is allowed.
326+
ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = []
327+
324328
# https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-CACHES
325329
CACHES = {
326330
"default": {

0 commit comments

Comments
 (0)