Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def get_auth_dependency(
return rh_identity.RHIdentityAuthDependency(
required_entitlements=rh_identity_config.required_entitlements,
virtual_path=virtual_path,
max_header_size=rh_identity_config.max_header_size,
)
case constants.AUTH_MOD_APIKEY_TOKEN:
return api_key_token.APIKeyTokenAuthDependency(
Expand Down
23 changes: 22 additions & 1 deletion src/authentication/rh_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

from authentication.interface import NO_AUTH_TUPLE, AuthInterface, AuthTuple
from configuration import configuration
from constants import DEFAULT_VIRTUAL_PATH, NO_USER_TOKEN
from constants import (
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
DEFAULT_VIRTUAL_PATH,
NO_USER_TOKEN,
)
from log import get_logger

logger = get_logger(__name__)
Expand Down Expand Up @@ -198,15 +202,20 @@ def __init__(
self,
required_entitlements: Optional[list[str]] = None,
virtual_path: str = DEFAULT_VIRTUAL_PATH,
max_header_size: int = DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
) -> None:
"""Initialize RH Identity authentication dependency.

Args:
required_entitlements: Services to require (ALL must be present)
virtual_path: Virtual path for authorization checks
max_header_size: Maximum allowed size in bytes for the base64-encoded
x-rh-identity header. Headers exceeding this size are rejected
before decoding.
"""
self.required_entitlements = required_entitlements
self.virtual_path = virtual_path
self.max_header_size = max_header_size
self.skip_userid_check = False

async def __call__(self, request: Request) -> AuthTuple:
Expand Down Expand Up @@ -234,6 +243,18 @@ async def __call__(self, request: Request) -> AuthTuple:
logger.warning("Missing x-rh-identity header")
raise HTTPException(status_code=401, detail="Missing x-rh-identity header")

# Enforce header size limit before decoding
if len(identity_header) > self.max_header_size:
logger.warning(
"x-rh-identity header size %d exceeds maximum allowed size %d",
len(identity_header),
self.max_header_size,
)
raise HTTPException(
status_code=400,
detail="x-rh-identity header exceeds maximum allowed size",
)

# Decode base64
try:
decoded_bytes = base64.b64decode(identity_header, validate=True)
Expand Down
2 changes: 2 additions & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@
}
)
DEFAULT_AUTHENTICATION_MODULE = AUTH_MOD_NOOP
# Maximum allowed size for base64-encoded x-rh-identity header (bytes)
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE = 8192
DEFAULT_JWT_UID_CLAIM = "user_id"
DEFAULT_JWT_USER_NAME_CLAIM = "username"

Expand Down
7 changes: 7 additions & 0 deletions src/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,13 @@ class RHIdentityConfiguration(ConfigurationBase):
description="List of all required entitlements.",
)

max_header_size: PositiveInt = Field(
default=constants.DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
title="Maximum header size",
description="Maximum allowed size in bytes for the base64-encoded x-rh-identity header. "
"Headers exceeding this size are rejected before decoding.",
)


class APIKeyTokenConfiguration(ConfigurationBase):
"""API Key Token configuration."""
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/authentication/test_rh_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,50 @@ async def test_non_probe_paths_require_auth_when_skip_enabled(
with pytest.raises(HTTPException) as exc_info:
await auth_dep(request)
assert exc_info.value.status_code == 401


class TestRHIdentityHeaderSizeLimit:
"""Test suite for x-rh-identity header size limit enforcement."""

@pytest.mark.asyncio
async def test_header_at_exact_limit_accepted(
self, mocker: MockerFixture, user_identity_data: dict
) -> None:
"""Test that a header at exactly the size limit is accepted."""
header_value = create_auth_header(user_identity_data)
auth_dep = RHIdentityAuthDependency(max_header_size=len(header_value))
request = create_request_with_header(mocker, header_value)

user_id, username, _, _ = await auth_dep(request)

assert user_id == "abc123"
assert username == "user@redhat.com"

@pytest.mark.asyncio
@pytest.mark.parametrize(
"header_size,max_size",
[
(9000, 8192), # Well over default limit
(101, 100), # One byte over custom limit
(200, 100), # Well over custom limit
],
)
async def test_header_exceeding_limit_rejected(
self,
mocker: MockerFixture,
header_size: int,
max_size: int,
) -> None:
"""Test oversized headers rejected with HTTP 400 and a warning logged."""
mock_warning = mocker.patch("authentication.rh_identity.logger.warning")
auth_dep = RHIdentityAuthDependency(max_header_size=max_size)
request = create_request_with_header(mocker, "x" * header_size)

with pytest.raises(HTTPException) as exc_info:
await auth_dep(request)

assert exc_info.value.status_code == 400
assert "exceeds maximum" in str(exc_info.value.detail)
mock_warning.assert_called_once()
assert mock_warning.call_args.args[1] == header_size
assert mock_warning.call_args.args[2] == max_size
30 changes: 30 additions & 0 deletions tests/unit/models/config/test_authentication_configuration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Unit tests for AuthenticationConfiguration model."""

from contextlib import AbstractContextManager, nullcontext
from pathlib import Path

import pytest
Expand All @@ -23,6 +24,7 @@
AUTH_MOD_JWK_TOKEN,
AUTH_MOD_RH_IDENTITY,
AUTH_MOD_APIKEY_TOKEN,
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
)


Expand Down Expand Up @@ -572,3 +574,31 @@ def test_authentication_configuration_api_key_but_insufficient_config() -> None:
k8s_cluster_api=None,
skip_for_health_probes=True,
)


def test_rh_identity_max_header_size_default() -> None:
"""Test that RHIdentityConfiguration default max_header_size matches the constant."""
config = RHIdentityConfiguration()
assert config.max_header_size == DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE


@pytest.mark.parametrize(
"max_header_size,expectation",
[
(4096, nullcontext()),
(0, pytest.raises(ValidationError)),
(-1, pytest.raises(ValidationError)),
],
)
def test_rh_identity_max_header_size_validation(
max_header_size: int,
expectation: AbstractContextManager,
) -> None:
"""Test that RHIdentityConfiguration validates max_header_size correctly.

Verify that PositiveInt accepts valid custom values and rejects zero and
negative values.
"""
with expectation:
config = RHIdentityConfiguration(max_header_size=max_header_size)
assert config.max_header_size == max_header_size
Loading