Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add EnvVarHeaderContentGuard by decko · Pull Request #8015 · pulp/pulpcore · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add EnvVarHeaderContentGuard by decko · Pull Request #8015 · pulp/pulpcore · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add EnvVarHeaderContentGuard by decko · Pull Request #8015 · pulp/pulpcore · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add EnvVarHeaderContentGuard by decko · Pull Request #8015 · pulp/pulpcore · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add EnvVarHeaderContentGuard by decko · Pull Request #8015 · pulp/pulpcore · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add EnvVarHeaderContentGuard by decko · Pull Request #8015 · pulp/pulpcore · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add EnvVarHeaderContentGuard by decko · Pull Request #8015 · pulp/pulpcore · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line numberDiff line numberDiff line change
@@ -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``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line numberDiff line numberDiff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand DownExpand Up@@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All@@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading