Skip to content

Commit 0073c36

Browse files
committed
RSPEED-2651: enforce max header size on x-rh-identity before base64 decode
Reject x-rh-identity headers exceeding configurable max_header_size (default 8KB) before base64 decoding to prevent DoS from oversized payloads. Resolves: RSPEED-2651 Signed-off-by: Major Hayden <major@redhat.com>
1 parent c6a2205 commit 0073c36

6 files changed

Lines changed: 109 additions & 1 deletion

File tree

src/authentication/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def get_auth_dependency(
7272
return rh_identity.RHIdentityAuthDependency(
7373
required_entitlements=rh_identity_config.required_entitlements,
7474
virtual_path=virtual_path,
75+
max_header_size=rh_identity_config.max_header_size,
7576
)
7677
case constants.AUTH_MOD_APIKEY_TOKEN:
7778
return api_key_token.APIKeyTokenAuthDependency(

src/authentication/rh_identity.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212

1313
from authentication.interface import NO_AUTH_TUPLE, AuthInterface, AuthTuple
1414
from configuration import configuration
15-
from constants import DEFAULT_VIRTUAL_PATH, NO_USER_TOKEN
15+
from constants import (
16+
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
17+
DEFAULT_VIRTUAL_PATH,
18+
NO_USER_TOKEN,
19+
)
1620
from log import get_logger
1721

1822
logger = get_logger(__name__)
@@ -198,15 +202,20 @@ def __init__(
198202
self,
199203
required_entitlements: Optional[list[str]] = None,
200204
virtual_path: str = DEFAULT_VIRTUAL_PATH,
205+
max_header_size: int = DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
201206
) -> None:
202207
"""Initialize RH Identity authentication dependency.
203208
204209
Args:
205210
required_entitlements: Services to require (ALL must be present)
206211
virtual_path: Virtual path for authorization checks
212+
max_header_size: Maximum allowed size in bytes for the base64-encoded
213+
x-rh-identity header. Headers exceeding this size are rejected
214+
before decoding.
207215
"""
208216
self.required_entitlements = required_entitlements
209217
self.virtual_path = virtual_path
218+
self.max_header_size = max_header_size
210219
self.skip_userid_check = False
211220

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

246+
# Enforce header size limit before decoding
247+
if len(identity_header) > self.max_header_size:
248+
logger.warning(
249+
"x-rh-identity header size %d exceeds maximum allowed size %d",
250+
len(identity_header),
251+
self.max_header_size,
252+
)
253+
raise HTTPException(
254+
status_code=400,
255+
detail="x-rh-identity header exceeds maximum allowed size",
256+
)
257+
237258
# Decode base64
238259
try:
239260
decoded_bytes = base64.b64decode(identity_header, validate=True)

src/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@
123123
}
124124
)
125125
DEFAULT_AUTHENTICATION_MODULE = AUTH_MOD_NOOP
126+
# Maximum allowed size for base64-encoded x-rh-identity header (bytes)
127+
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE = 8192
126128
DEFAULT_JWT_UID_CLAIM = "user_id"
127129
DEFAULT_JWT_USER_NAME_CLAIM = "username"
128130

src/models/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,13 @@ class RHIdentityConfiguration(ConfigurationBase):
11311131
description="List of all required entitlements.",
11321132
)
11331133

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

11351142
class APIKeyTokenConfiguration(ConfigurationBase):
11361143
"""API Key Token configuration."""

tests/unit/authentication/test_rh_identity.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,3 +547,50 @@ async def test_non_probe_paths_require_auth_when_skip_enabled(
547547
with pytest.raises(HTTPException) as exc_info:
548548
await auth_dep(request)
549549
assert exc_info.value.status_code == 401
550+
551+
552+
class TestRHIdentityHeaderSizeLimit:
553+
"""Test suite for x-rh-identity header size limit enforcement."""
554+
555+
@pytest.mark.asyncio
556+
async def test_header_at_exact_limit_accepted(
557+
self, mocker: MockerFixture, user_identity_data: dict
558+
) -> None:
559+
"""Test that a header at exactly the size limit is accepted."""
560+
header_value = create_auth_header(user_identity_data)
561+
auth_dep = RHIdentityAuthDependency(max_header_size=len(header_value))
562+
request = create_request_with_header(mocker, header_value)
563+
564+
user_id, username, _, _ = await auth_dep(request)
565+
566+
assert user_id == "abc123"
567+
assert username == "user@redhat.com"
568+
569+
@pytest.mark.asyncio
570+
@pytest.mark.parametrize(
571+
"header_size,max_size",
572+
[
573+
(9000, 8192), # Well over default limit
574+
(101, 100), # One byte over custom limit
575+
(200, 100), # Well over custom limit
576+
],
577+
)
578+
async def test_header_exceeding_limit_rejected(
579+
self,
580+
mocker: MockerFixture,
581+
header_size: int,
582+
max_size: int,
583+
) -> None:
584+
"""Test oversized headers rejected with HTTP 400 and a warning logged."""
585+
mock_warning = mocker.patch("authentication.rh_identity.logger.warning")
586+
auth_dep = RHIdentityAuthDependency(max_header_size=max_size)
587+
request = create_request_with_header(mocker, "x" * header_size)
588+
589+
with pytest.raises(HTTPException) as exc_info:
590+
await auth_dep(request)
591+
592+
assert exc_info.value.status_code == 400
593+
assert "exceeds maximum" in str(exc_info.value.detail)
594+
mock_warning.assert_called_once()
595+
assert mock_warning.call_args.args[1] == header_size
596+
assert mock_warning.call_args.args[2] == max_size

tests/unit/models/config/test_authentication_configuration.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit tests for AuthenticationConfiguration model."""
22

3+
from contextlib import AbstractContextManager, nullcontext
34
from pathlib import Path
45

56
import pytest
@@ -23,6 +24,7 @@
2324
AUTH_MOD_JWK_TOKEN,
2425
AUTH_MOD_RH_IDENTITY,
2526
AUTH_MOD_APIKEY_TOKEN,
27+
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
2628
)
2729

2830

@@ -572,3 +574,31 @@ def test_authentication_configuration_api_key_but_insufficient_config() -> None:
572574
k8s_cluster_api=None,
573575
skip_for_health_probes=True,
574576
)
577+
578+
579+
def test_rh_identity_max_header_size_default() -> None:
580+
"""Test that RHIdentityConfiguration default max_header_size matches the constant."""
581+
config = RHIdentityConfiguration()
582+
assert config.max_header_size == DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE
583+
584+
585+
@pytest.mark.parametrize(
586+
"max_header_size,expectation",
587+
[
588+
(4096, nullcontext()),
589+
(0, pytest.raises(ValidationError)),
590+
(-1, pytest.raises(ValidationError)),
591+
],
592+
)
593+
def test_rh_identity_max_header_size_validation(
594+
max_header_size: int,
595+
expectation: AbstractContextManager,
596+
) -> None:
597+
"""Test that RHIdentityConfiguration validates max_header_size correctly.
598+
599+
Verify that PositiveInt accepts valid custom values and rejects zero and
600+
negative values.
601+
"""
602+
with expectation:
603+
config = RHIdentityConfiguration(max_header_size=max_header_size)
604+
assert config.max_header_size == max_header_size

0 commit comments

Comments
 (0)