diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 858d9d42..9bf38e2a 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -60,3 +60,7 @@ **Vulnerability:** Path traversal in `media_shrinker.py` via unresolved `..` segments or symlink escapes before deriving conversion output paths. **Learning:** `Path.relative_to()` is only a lexical containment check unless both the source and root have first been resolved into canonical absolute paths. Relative paths and symlinks can otherwise bypass root-boundary assumptions. **Prevention:** Resolve both source and root once, reject sources outside the resolved root with a sanitized `MediaShrinkerError`, and derive `rel_source` from the resolved paths before planning outputs. +## 2024-05-24 - API 키 비교 시 유니코드 처리 취약점 (Unhandled Exception) +**취약점:** `hmac.compare_digest` 함수가 유니코드 문자열을 인자로 받을 경우 `TypeError`를 발생시키며, 이를 통해 서버 에러(500)를 유발하는 DoS 공격이 가능했습니다. +**학습:** 악의적인 사용자가 의도적으로 ASCII 범위를 벗어난 값이 포함된 `x-api-key` 헤더를 전송하여 어플리케이션을 크래시시킬 수 있었습니다. 외부 입력값은 항상 바이트로 변환한 후 안전하게 비교해야 합니다. +**예방:** `hmac.compare_digest`를 호출하기 전에 두 문자열 인자를 항상 명시적으로 바이트(`.encode('utf-8')`)로 인코딩하여 타입 에러를 방지합니다. diff --git a/create_pull_request_mock.sh b/create_pull_request_mock.sh new file mode 100755 index 00000000..c3e47fae --- /dev/null +++ b/create_pull_request_mock.sh @@ -0,0 +1,9 @@ +#!/bin/bash +echo "Creating PR..." +echo "Title: 🛡️ Sentinel: [CRITICAL] API 키 유니코드 DoS 취약점 수정" +echo "Desc:" +echo "🚨 **Severity:** CRITICAL" +echo "💡 **Vulnerability:** \`hmac.compare_digest\` 함수가 유니코드 문자열 처리 시 발생하는 \`TypeError\`로 인해 악의적인 헤더 전송 시 서버 500 에러를 유발하는 서비스 거부(DoS) 취약점이 발견되었습니다." +echo "🎯 **Impact:** 공격자가 특수 문자가 포함된 인증 헤더를 지속적으로 전송하여 서버 가용성을 저하시킬 수 있습니다." +echo "🔧 **Fix:** 키 비교 전에 입력값과 저장된 키를 명시적으로 바이트 형식(\`.encode('utf-8')\`)으로 변환하여 안전한 비교가 이루어지도록 수정했습니다." +echo "✅ **Verification:** 유니코드 문자가 포함된 헤더를 전송하는 단위 테스트를 추가하여 401 응답이 정상적으로 반환됨을 확인했습니다." diff --git a/saas_web.py b/saas_web.py index 3a7b0352..c7e5f3d2 100644 --- a/saas_web.py +++ b/saas_web.py @@ -114,7 +114,7 @@ async def require_api_key(request: Request, call_next): if configured_keys and not (request.method == "GET" and request.url.path == "/"): provided_key = request.headers.get("x-api-key", "") if not any( - hmac.compare_digest(provided_key, key) for key in configured_keys + hmac.compare_digest(provided_key.encode('utf-8'), key.encode('utf-8')) for key in configured_keys ): return JSONResponse( status_code=401, diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 57b879d1..b87c8ef1 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -629,6 +629,21 @@ def test_missing_header_rejected_when_keys_configured(self): self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) self.assertNotIn("secret-key", response.text) + def test_wrong_key_unicode_gracefully_rejected(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + request = SimpleNamespace( + method="POST", + url=SimpleNamespace(path="/shrink"), + headers={"x-api-key": "🔑"} + ) + + async def fake_call_next(request): + return Response() + + response = asyncio.run(saas_web.require_api_key(request, fake_call_next)) + + self.assertEqual(response.status_code, 401) + def test_wrong_key_rejected(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): response = self._post_shrink(headers={"X-API-Key": "wrong-key"})