Skip to content

Commit a91df03

Browse files
committed
respond to pr comments. adds a blacklist class and exports string equivalency in a util function
1 parent f8d4f4e commit a91df03

7 files changed

Lines changed: 313 additions & 30 deletions

File tree

src/aap_eda/api/blacklist.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright 2026 Red Hat, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
17+
from django.conf import settings
18+
from django.core.cache import cache
19+
from rest_framework.exceptions import AuthenticationFailed
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class BlacklistManager:
25+
"""Rate-limit and blacklist IPs that fail event stream authentication.
26+
27+
Tracks auth failures and invalid UUID probes per client IP using
28+
Django's cache framework. All entries are evicted automatically
29+
by cache TTL — no manual cleanup required.
30+
"""
31+
32+
AUTH_FAILURE_PREFIX = "es_auth_fail"
33+
INVALID_UUID_PREFIX = "es_invalid_uuid"
34+
BLACKLIST_PREFIX = "es_blacklist"
35+
36+
def check_blacklist(self, client_ip: str) -> None:
37+
"""Raise AuthenticationFailed if the IP is globally blacklisted."""
38+
key = f"{self.BLACKLIST_PREFIX}:global:{client_ip}"
39+
if cache.get(key):
40+
raise AuthenticationFailed("Too many failed attempts")
41+
42+
def record_auth_failure(self, client_ip: str) -> None:
43+
"""Record an authentication failure for the given IP.
44+
45+
After EVENT_STREAM_AUTH_FAILURE_THRESHOLD failures within
46+
EVENT_STREAM_AUTH_FAILURE_WINDOW seconds, the IP is globally
47+
blacklisted for EVENT_STREAM_BLACKLIST_DURATION seconds.
48+
"""
49+
counter_key = f"{self.AUTH_FAILURE_PREFIX}:{client_ip}"
50+
failures = cache.get(counter_key, 0) + 1
51+
cache.set(
52+
counter_key,
53+
failures,
54+
settings.EVENT_STREAM_AUTH_FAILURE_WINDOW,
55+
)
56+
57+
if failures >= settings.EVENT_STREAM_AUTH_FAILURE_THRESHOLD:
58+
blacklist_key = f"{self.BLACKLIST_PREFIX}:global:{client_ip}"
59+
cache.set(
60+
blacklist_key,
61+
True,
62+
settings.EVENT_STREAM_BLACKLIST_DURATION,
63+
)
64+
logger.warning(
65+
"Globally blacklisted IP %s after %d auth failures",
66+
client_ip,
67+
failures,
68+
)
69+
cache.delete(counter_key)
70+
71+
def record_invalid_uuid(self, client_ip: str) -> None:
72+
"""Record an invalid event stream UUID attempt for the given IP.
73+
74+
After EVENT_STREAM_INVALID_UUID_THRESHOLD attempts within
75+
EVENT_STREAM_INVALID_UUID_WINDOW seconds, the IP is globally
76+
blacklisted for EVENT_STREAM_BLACKLIST_DURATION seconds.
77+
"""
78+
counter_key = f"{self.INVALID_UUID_PREFIX}:{client_ip}"
79+
failures = cache.get(counter_key, 0) + 1
80+
cache.set(
81+
counter_key,
82+
failures,
83+
settings.EVENT_STREAM_INVALID_UUID_WINDOW,
84+
)
85+
86+
if failures >= settings.EVENT_STREAM_INVALID_UUID_THRESHOLD:
87+
blacklist_key = f"{self.BLACKLIST_PREFIX}:global:{client_ip}"
88+
cache.set(
89+
blacklist_key,
90+
True,
91+
settings.EVENT_STREAM_BLACKLIST_DURATION,
92+
)
93+
logger.warning(
94+
"Globally blacklisted IP %s after %d invalid UUID attempts",
95+
client_ip,
96+
failures,
97+
)
98+
cache.delete(counter_key)

src/aap_eda/api/event_stream_authentication.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from aap_eda.core.enums import SignatureEncodingType
3535
from aap_eda.core.utils.credentials import validate_x509_subject_match
36+
from aap_eda.core.utils.crypto import timing_safe_compare
3637

3738
logger = logging.getLogger(__name__)
3839
DEFAULT_TIMEOUT = 30
@@ -81,9 +82,7 @@ def authenticate(self, body: bytes):
8182
logger.warning(message)
8283
raise AuthenticationFailed(message)
8384

84-
if not hmac.compare_digest(
85-
expected_signature.encode(), self.signature.encode()
86-
):
85+
if not timing_safe_compare(expected_signature, self.signature):
8786
message = "Signature mismatch, check your payload and secret"
8887
logger.warning(message)
8988
raise AuthenticationFailed(message)
@@ -98,9 +97,7 @@ class TokenAuthentication(EventStreamAuthentication):
9897

9998
def authenticate(self, _body=None):
10099
"""Handle Token authentication."""
101-
if not hmac.compare_digest(
102-
self.token.encode(), _token_sans_bearer(self.value).encode()
103-
):
100+
if not timing_safe_compare(self.token, _token_sans_bearer(self.value)):
104101
message = "Token mismatch, check your token"
105102
logger.warning(message)
106103
raise AuthenticationFailed(message)
@@ -156,9 +153,9 @@ def authenticate(self, _body=None):
156153
if self.authorization.startswith("Basic"):
157154
auth_str = self.authorization.split("Basic ")[1]
158155

159-
user_pass = f"{self.username}:{self.password}"
156+
user_pass = f"{self.username}: {self.password}"
160157
b64_value = base64.b64encode(user_pass.encode()).decode()
161-
if not hmac.compare_digest(auth_str.encode(), b64_value.encode()):
158+
if not timing_safe_compare(auth_str, b64_value):
162159
message = "Credential mismatch"
163160
logger.warning(message)
164161
raise AuthenticationFailed(message)

src/aap_eda/api/views/external_event_stream.py

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
validate_x_trusted_proxy_header,
2424
)
2525
from django.conf import settings
26-
from django.core.cache import cache
2726
from django.core.exceptions import ValidationError
2827
from django.db import transaction
2928
from django.db.models import F
@@ -35,6 +34,7 @@
3534
from rest_framework.permissions import AllowAny
3635
from rest_framework.response import Response
3736

37+
from aap_eda.api.blacklist import BlacklistManager
3838
from aap_eda.api.event_stream_authentication import (
3939
BasicAuthentication,
4040
EcdsaAuthentication,
@@ -50,11 +50,10 @@
5050
from aap_eda.core.utils.credentials import get_resolved_secrets
5151
from aap_eda.services.pg_notify import PGNotify
5252

53-
FAILURE_THRESHOLD = 5
54-
FAILURE_WINDOW = 60 # seconds
5553
logger = logging.getLogger(__name__)
5654
UNSAFE_HEADER_KEYS = {"X-Trusted-Proxy", "X-Forwarded-For", "X-Real-IP"}
5755
REDACTED_STRING = "********"
56+
blacklist_manager = BlacklistManager()
5857

5958

6059
class ExternalEventStreamViewSet(viewsets.GenericViewSet):
@@ -286,41 +285,32 @@ def _handle_auth(self, request, inputs):
286285
raise
287286

288287
def _get_client_ip(self, request):
288+
"""Return the client IP from the request.
289+
290+
Uses X-Forwarded-For when trusted proxy validation is enabled,
291+
otherwise falls back to REMOTE_ADDR.
292+
"""
289293
if settings.EVENT_STREAM_REQUIRE_TRUSTED_PROXY:
290294
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
291295
if x_forwarded_for:
292296
return x_forwarded_for.split(",")[0].strip()
293297
return request.META.get("REMOTE_ADDR")
294298

295-
def _check_rate_limit(self, request, event_stream_uuid):
296-
key = (
297-
f"es_auth_fail: {event_stream_uuid}: "
298-
f"{self._get_client_ip(request)}"
299-
)
300-
failures = cache.get(key, 0)
301-
if failures >= FAILURE_THRESHOLD:
302-
raise AuthenticationFailed("Too many failed attempts")
303-
304-
def _record_failure(self, request, event_stream_uuid):
305-
key = (
306-
f"es_auth_fail: {event_stream_uuid}: "
307-
f"{self._get_client_ip(request)}"
308-
)
309-
failures = cache.get(key, 0)
310-
cache.set(key, failures + 1, FAILURE_WINDOW)
311-
312299
@extend_schema(exclude=True)
313300
@action(detail=True, methods=["POST"], rbac_action=None)
314301
def post(self, request, *_args, **kwargs):
315302
"""Handle posts from external vendors."""
303+
client_ip = self._get_client_ip(request)
304+
blacklist_manager.check_blacklist(client_ip)
305+
316306
try:
317307
self.event_stream = EventStream.objects.get(uuid=kwargs["pk"])
318308
except (EventStream.DoesNotExist, ValidationError) as exc:
309+
blacklist_manager.record_invalid_uuid(client_ip)
319310
raise ParseError("bad uuid specified") from exc
320311

321312
# Validate X-Trusted-Proxy header from Gateway/Envoy
322313
self._validate_trusted_proxy_header(request)
323-
self._check_rate_limit(request, kwargs["pk"])
324314

325315
try:
326316
inputs = get_resolved_secrets(self.event_stream.eda_credential)
@@ -341,10 +331,11 @@ def post(self, request, *_args, **kwargs):
341331
headers=yaml.dump(event_headers),
342332
)
343333
raise ParseError(message)
334+
344335
try:
345336
self._handle_auth(request, inputs)
346337
except AuthenticationFailed:
347-
self._record_failure(request, kwargs["pk"])
338+
blacklist_manager.record_auth_failure(client_ip)
348339
raise
349340

350341
body = self._parse_body(

src/aap_eda/core/utils/crypto/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,18 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
15+
import hmac
16+
17+
18+
def timing_safe_compare(a: str, b: str) -> bool:
19+
"""Compare two strings in constant time using hmac.compare_digest.
20+
21+
Python's hmac.compare_digest raises TypeError when either str
22+
operand contains non-ASCII characters — not just on a str-vs-bytes
23+
mismatch. Since Django provides HTTP header values as str with no
24+
ASCII guarantee, callers comparing credentials from headers must
25+
encode to bytes first. This function handles that conversion so
26+
callers don't need to remember the footgun.
27+
"""
28+
return hmac.compare_digest(a.encode(), b.encode())

src/aap_eda/settings/defaults.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,17 @@
238238
# Set to False for local development without proxy:
239239
# export EDA_EVENT_STREAM_REQUIRE_TRUSTED_PROXY=False
240240
EVENT_STREAM_REQUIRE_TRUSTED_PROXY: bool = True
241+
242+
# IP blacklisting for event stream abuse prevention
243+
# Auth failure: threshold and window before globally blacklisting an IP
244+
EVENT_STREAM_AUTH_FAILURE_THRESHOLD: int = 5
245+
EVENT_STREAM_AUTH_FAILURE_WINDOW: int = 60 # seconds
246+
# Invalid UUID probing: threshold and window before blacklisting an IP globally
247+
EVENT_STREAM_INVALID_UUID_THRESHOLD: int = 3
248+
EVENT_STREAM_INVALID_UUID_WINDOW: int = 60 # seconds
249+
# Duration an IP remains blacklisted after exceeding a threshold
250+
EVENT_STREAM_BLACKLIST_DURATION: int = 3600 # seconds (1 hour)
251+
241252
MAX_PG_NOTIFY_MESSAGE_SIZE: int = 6144
242253

243254
# Database credentials for the event streams user

0 commit comments

Comments
 (0)