diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..04cc3323 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,3 +1,8 @@ +## 2026-07-15 - [Sentinel: 인증 미들웨어에서 처리되지 않은 예외 수정] +**Vulnerability:** X-API-Key 헤더에 비-ASCII 문자가 포함되어 있을 때 처리되지 않은 TypeError (500 Internal Server Error) 발생. +**Learning:** Python의 `hmac.compare_digest` 함수는 비-ASCII 문자가 포함된 문자열을 비교할 때 TypeError를 발생시킵니다. 바이트로 변환하지 않고 이 함수를 통해 인증을 수행하는 미들웨어는 악의적이거나 잘못된 요청으로 인해 의도적으로 500 에러를 유발할 수 있으며, 이로 인해 서비스 거부 공격(DoS)이나 정보 노출의 가능성이 있습니다. +**Prevention:** `hmac.compare_digest`와 같은 암호화 함수에 사용자 입력 문자열을 전달하기 전에는 항상 `utf-8` 바이트로 안전하게 인코딩(encode)해야 합니다. + ## 2026-07-25 - [Cross-platform upload basename normalization] **Behavior:** Upload metadata now interprets both forward slashes and backslashes as path separators before extracting a basename. **Learning:** On POSIX systems, `pathlib.Path(filename).name` retains backslashes because they are ordinary characters there. That caused inconsistent manifest and converter filenames for Windows-style client paths. The upload itself is still written inside a trusted temporary workspace, and batch archive entry names are generated outputs; this change does not establish a filesystem traversal or archive-entry escape. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9313538b..1df70fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,4 @@ ### Fixed - 단일·일괄 대상 크기 입력을 비웠을 때 이전 custom validity와 `aria-invalid` 상태를 즉시 초기화해 현재 필수 입력 상태를 정확히 전달합니다. - 업로드 파일명의 경로 구분자를 정규화하여 POSIX에서도 Windows 형식의 클라이언트 경로가 일관된 basename으로 기록되도록 수정했습니다. +- [보안] API 키에 비-ASCII 문자가 포함된 경우 서버가 500 에러를 반환하는 취약점을 수정했습니다. diff --git a/saas_web.py b/saas_web.py index 63265e94..071b1419 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 3b57e033..15e454ed 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -707,6 +707,13 @@ 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_non_ascii_key_rejected_gracefully(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = self._post_shrink(headers={"X-API-Key": "😅".encode("utf-8")}) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) + 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"})