Skip to content

Commit 300737f

Browse files
authored
Merge pull request #805 from seapagan/security/redact-query-logs
fix(security): redact sensitive query params in request logs
2 parents 24eac05 + cac1c76 commit 300737f

File tree

4 files changed

+124
-20
lines changed

4 files changed

+124
-20
lines changed

SECURITY-REVIEW.md

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@
9393

9494
### 5. Sensitive Data in Request Logs
9595

96+
> [!NOTE]
97+
> **Done**: Request logging now redacts sensitive query parameters by name.
98+
9699
**Location**: `app/middleware/logging_middleware.py:44`
97100

98101
- **Issue**: Logs full query strings including:
@@ -526,12 +529,12 @@
526529

527530
**Total Issues Identified: 33**
528531

529-
| Priority | Count | Must Fix Before Production? |
530-
|----------|-------|-----------------------------|
531-
| **CRITICAL** | 5 | ✅ YES - Security vulnerabilities |
532-
| **High** | 9 | ✅ YES - Important security/quality |
533-
| **Medium** | 14 | ⚠️ Recommended - Hardening needed |
534-
| **Low** | 5 | 💡 Optional - Nice to have |
532+
| Priority | Count | Must Fix Before Production? |
533+
|--------------|---------------|-------------------------------------|
534+
| **CRITICAL** | 5 (3 closed) | ✅ YES - Security vulnerabilities |
535+
| **High** | 9 (0 closed) | ✅ YES - Important security/quality |
536+
| **Medium** | 14 (0 closed) | ⚠️ Recommended - Hardening needed |
537+
| **Low** | 5 (0 closed) | 💡 Optional - Nice to have |
535538

536539
**Overall Assessment**: The codebase demonstrates solid security foundations with
537540
proper JWT authentication, password hashing (bcrypt), and SQL injection protection
@@ -633,19 +636,20 @@ rate limiting, token validation, and API key scope enforcement.
633636

634637
The codebase demonstrates several excellent security practices:
635638

636-
**Strong cryptography**: bcrypt for passwords, HMAC-SHA256 for API keys, proper
637-
JWT handling
638-
**SQL injection protection**: SQLAlchemy ORM throughout, no raw SQL
639-
**Token validation**: Format checking before expensive crypto operations (DoS
640-
prevention)
641-
**Secret key validation**: Strong validation at startup, prevents weak keys
642-
**Email enumeration protection**: Password reset correctly prevents enumeration
643-
**Self-ban prevention**: User can't ban themselves
644-
**Last admin protection**: Check in place (though has race condition)
645-
**Category-based logging**: Excellent separation of concerns for security
646-
monitoring
647-
**Proper password hashing**: Automatic salting, modern algorithms
648-
**Database password validation**: Prevents weak defaults in production
639+
-**Strong cryptography**: bcrypt for passwords, HMAC-SHA256 for API keys,
640+
proper JWT handling
641+
-**SQL injection protection**: SQLAlchemy ORM throughout, no raw SQL
642+
-**Token validation**: Format checking before expensive crypto operations
643+
(DoS prevention)
644+
-**Secret key validation**: Strong validation at startup, prevents weak keys
645+
-**Email enumeration protection**: Password reset correctly prevents
646+
enumeration
647+
-**Self-ban prevention**: User can't ban themselves
648+
-**Last admin protection**: Check in place (though has race condition)
649+
-**Category-based logging**: Excellent separation of concerns for security
650+
monitoring
651+
-**Proper password hashing**: Automatic salting, modern algorithms
652+
-**Database password validation**: Prevents weak defaults in production
649653

650654
---
651655

app/middleware/logging_middleware.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import time
66
from typing import TYPE_CHECKING
7+
from urllib.parse import parse_qsl, urlencode
78

89
from loguru import logger
910
from starlette.middleware.base import BaseHTTPMiddleware
@@ -20,6 +21,53 @@
2021
class LoggingMiddleware(BaseHTTPMiddleware):
2122
"""Log HTTP requests and responses if REQUESTS category is enabled."""
2223

24+
REDACTED_VALUE = "REDACTED"
25+
# Order matters: keep currently used keys early for short-circuit checks.
26+
SENSITIVE_QUERY_KEYS = (
27+
"code",
28+
"token",
29+
"reset_token",
30+
"verification",
31+
"verify",
32+
"access_token",
33+
"refresh_token",
34+
"api_key",
35+
"key",
36+
)
37+
38+
@classmethod
39+
def _should_redact(cls, key: str) -> bool:
40+
"""Check if a query parameter name should be redacted."""
41+
key_lower = key.lower()
42+
for sensitive_key in cls.SENSITIVE_QUERY_KEYS:
43+
if key_lower == sensitive_key:
44+
return True
45+
return False
46+
47+
@classmethod
48+
def _redact_query(cls, query: str) -> str:
49+
"""Redact sensitive query parameters from a raw query string."""
50+
if not query:
51+
return ""
52+
53+
params = parse_qsl(query, keep_blank_values=True)
54+
if not params:
55+
return query
56+
57+
redacted = False
58+
redacted_params: list[tuple[str, str]] = []
59+
for key, value in params:
60+
if cls._should_redact(key):
61+
redacted = True
62+
redacted_params.append((key, cls.REDACTED_VALUE))
63+
continue
64+
redacted_params.append((key, value))
65+
66+
if not redacted:
67+
return query
68+
69+
return urlencode(redacted_params)
70+
2371
async def dispatch(
2472
self,
2573
request: Request,
@@ -42,7 +90,8 @@ async def dispatch(
4290
# Build full path including query string if present
4391
path = request.url.path
4492
if request.url.query:
45-
path = f"{path}?{request.url.query}"
93+
redacted_query = self._redact_query(request.url.query)
94+
path = f"{path}?{redacted_query}"
4695

4796
logger.info(
4897
f'{client_addr} - "{request.method} {path}" '

docs/usage/configuration/environment.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,11 @@ LOG_CATEGORIES=NONE
653653
- Case-insensitive: `auth` = `AUTH`
654654
- Whitespace is trimmed: `AUTH, ERRORS` works fine
655655

656+
!!! note "Request log redaction"
657+
Request logs redact sensitive query parameters by name before writing to
658+
disk (for example `code` and `token`). To customize the list, update
659+
`SENSITIVE_QUERY_KEYS` in `app/middleware/logging_middleware.py`.
660+
656661
### Log Filename
657662

658663
Custom filename for the log file:

tests/unit/test_logging.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ async def test_middleware_logs_when_requests_enabled(
460460
mock_request.client = Mock(host="127.0.0.1")
461461
mock_request.method = "GET"
462462
mock_request.url.path = "/api/users"
463+
mock_request.url.query = ""
463464

464465
mock_response = Mock(spec=Response)
465466
mock_response.status_code = 200
@@ -513,6 +514,51 @@ async def test_middleware_logs_query_parameters(
513514
assert "200" in log_message
514515
assert result == mock_response
515516

517+
async def test_middleware_redacts_sensitive_query_parameters(
518+
self, mocker: MockerFixture
519+
) -> None:
520+
"""Test middleware redacts sensitive query parameters in logs."""
521+
mock_log_config = mocker.patch(
522+
"app.middleware.logging_middleware.log_config"
523+
)
524+
mock_log_config.is_enabled.return_value = True
525+
526+
mock_logger = mocker.patch("app.middleware.logging_middleware.logger")
527+
528+
middleware = LoggingMiddleware(app=Mock())
529+
530+
# Create mock request with sensitive query parameters
531+
mock_request = Mock(spec=Request)
532+
mock_request.client = Mock(host="127.0.0.1")
533+
mock_request.method = "GET"
534+
mock_request.url.path = "/api/auth"
535+
mock_request.url.query = "page=2&code=secret&token=abc&API_KEY=bad"
536+
537+
mock_response = Mock(spec=Response)
538+
mock_response.status_code = 200
539+
mock_call_next = AsyncMock(return_value=mock_response)
540+
541+
result = await middleware.dispatch(mock_request, mock_call_next)
542+
543+
mock_logger.info.assert_called_once()
544+
log_message = mock_logger.info.call_args[0][0]
545+
assert "GET /api/auth?page=2" in log_message
546+
assert "code=REDACTED" in log_message
547+
assert "token=REDACTED" in log_message
548+
assert "API_KEY=REDACTED" in log_message
549+
assert "secret" not in log_message
550+
assert "abc" not in log_message
551+
assert "bad" not in log_message
552+
assert result == mock_response
553+
554+
def test_redact_query_returns_empty_for_blank_query(self) -> None:
555+
"""Test redaction helper returns empty for blank query."""
556+
assert LoggingMiddleware._redact_query("") == ""
557+
558+
def test_redact_query_returns_original_when_no_params(self) -> None:
559+
"""Test redaction helper keeps original when parse yields no params."""
560+
assert LoggingMiddleware._redact_query("&") == "&"
561+
516562
async def test_middleware_logs_without_query_parameters(
517563
self, mocker: MockerFixture
518564
) -> None:

0 commit comments

Comments
 (0)