From 3ce6155592ef8fda0c802c073688b57863802147 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:38:03 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20HMAC=EC=97=90=EC=84=9C=20=EC=B2=98=EB=A6=AC=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=9C=A0=EB=8B=88=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=97=90=EB=9F=AC=EB=A5=BC=20=ED=86=B5=ED=95=9C=20?= =?UTF-8?q?500=20DoS=20=EC=B7=A8=EC=95=BD=EC=A0=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **๐Ÿšจ ์‹ฌ๊ฐ๋„ (Severity):** CRITICAL **๐Ÿ’ก ์ทจ์•ฝ์  (Vulnerability):** `saas_web.py`์˜ `require_api_key` ๋ฏธ๋“ค์›จ์–ด์—์„œ API ํ‚ค๋ฅผ ๊ฒ€์ฆํ•  ๋•Œ Python์˜ `hmac.compare_digest()`๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์ด ํ•จ์ˆ˜๋Š” ASCII๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋œ ๋ฌธ์ž์—ด์„ ๋น„๊ตํ•  ๋•Œ `TypeError`๋ฅผ ๋ฐœ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. **๐ŸŽฏ ์˜ํ–ฅ (Impact):** ์•…์˜์ ์ธ ํด๋ผ์ด์–ธํŠธ๊ฐ€ `X-API-Key` ํ—ค๋”์— ๋น„ ASCII ๋ฌธ์ž(์˜ˆ: ์ด๋ชจ์ง€)๋ฅผ ํฌํ•จํ•˜์—ฌ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด, ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์—ฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด 500 Internal Server Error๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜๋ฉฐ, ์ด๋ฅผ ํ†ตํ•ด ์„œ๋น„์Šค ๊ฑฐ๋ถ€(DoS) ๊ณต๊ฒฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. **๐Ÿ”ง ์ˆ˜์ • ์‚ฌํ•ญ (Fix):** `hmac.compare_digest()`๋ฅผ ํ˜ธ์ถœํ•˜๊ธฐ ์ „์— `provided_key`์™€ ์„ค์ •๋œ `key`๋ฅผ ๋ชจ๋‘ ๋ช…์‹œ์ ์œผ๋กœ UTF-8 ๋ฐ”์ดํŠธ๋กœ ์ธ์ฝ”๋”ฉ(`.encode('utf-8')`)ํ•˜๋„๋ก ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ธ์ฝ”๋”ฉ ์˜ค๋ฒ„ํ—ค๋“œ๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•ด ๋ฐ˜๋ณต๋ฌธ ์ „์— ์ œ๊ณต๋œ ํ‚ค๋ฅผ ๋ฏธ๋ฆฌ ์ธ์ฝ”๋”ฉํ–ˆ์Šต๋‹ˆ๋‹ค. **โœ… ํ™•์ธ ๋ฐฉ๋ฒ• (Verification):** `X-API-Key` ํ—ค๋”์— ๋น„ ASCII ๋ฌธ์ž๋ฅผ ํฌํ•จํ•˜๋Š” ์š”์ฒญ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๋Š” ๋‹จ์œ„ ํ…Œ์ŠคํŠธ(`test_require_api_key_non_ascii_header`)๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ, ์˜ˆ์™ธ ์—†์ด 401 Unauthorized๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ๋ฐ˜ํ™˜๋˜๋Š”์ง€ ๊ฒ€์ฆํ–ˆ์Šต๋‹ˆ๋‹ค. --- .jules/sentinel.md | 5 +++++ saas_web.py | 3 ++- tests/test_saas_web.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..9040d758 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -65,3 +65,8 @@ **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-31 - [Sentinel: Unhandled UnicodeError in hmac.compare_digest] +**์ทจ์•ฝ์ :** `hmac.compare_digest()` ์‚ฌ์šฉ ์‹œ ๋น„ ASCII ๋ฌธ์ž๊ฐ€ ์ž…๋ ฅ์œผ๋กœ ๋“ค์–ด์˜ฌ ๋•Œ ๋ฐœ์ƒํ•˜๋Š” `TypeError`๋ฅผ ํ†ตํ•œ 500 ์„œ๋ฒ„ ์—๋Ÿฌ (DoS ์ทจ์•ฝ์ ). +**ํ•™์Šต:** Python์˜ `hmac.compare_digest()`๋Š” ๋น„ ASCII ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋œ ๋ฌธ์ž์—ด(`str`) ์ธ์Šคํ„ด์Šค๋ฅผ ๋น„๊ตํ•  ๋•Œ ์˜ˆ์™ธ(`TypeError`)๋ฅผ ๋ฐœ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ์›น ํ”„๋ ˆ์ž„์›Œํฌ ๋ฏธ๋“ค์›จ์–ด์—์„œ ์ธ์ฆ ํ—ค๋”์™€ ๊ฐ™์ด ์‚ฌ์šฉ์ž ์ œ์–ด๊ฐ€ ๊ฐ€๋Šฅํ•œ ์ž…๋ ฅ์„ ์ด ํ•จ์ˆ˜๋กœ ์ง์ ‘ ๋น„๊ตํ•˜๋ฉด ์•…์˜์ ์ธ ์‚ฌ์šฉ์ž๊ฐ€ ๋น„ ASCII ๋ฌธ์ž(์˜ˆ: ์ด๋ชจ์ง€)๋ฅผ ์ „์†กํ•˜์—ฌ ์„œ๋น„์Šค ๊ฑฐ๋ถ€ ์ƒํƒœ๋ฅผ ์œ ๋ฐœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +**์˜ˆ๋ฐฉ:** `hmac.compare_digest()`์— ์ „๋‹ฌ๋˜๋Š” ๋ชจ๋“  ๋ฌธ์ž์—ด ์ธ์ˆ˜๋Š” ๋ช…์‹œ์ ์œผ๋กœ ๋ฐ”์ดํŠธ(`.encode('utf-8')`)๋กœ ์ธ์ฝ”๋”ฉํ•˜์—ฌ ๋น„๊ตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. diff --git a/saas_web.py b/saas_web.py index 63265e94..7a1b8900 100644 --- a/saas_web.py +++ b/saas_web.py @@ -113,8 +113,9 @@ async def require_api_key(request: Request, call_next): configured_keys = get_configured_api_keys() if configured_keys and not (request.method == "GET" and request.url.path == "/"): provided_key = request.headers.get("x-api-key", "") + provided_key_bytes = provided_key.encode('utf-8') if not any( - hmac.compare_digest(provided_key, key) for key in configured_keys + hmac.compare_digest(provided_key_bytes, 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..c3c8468f 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -32,6 +32,36 @@ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)" ) class TestSaasWeb(unittest.TestCase): + def test_require_api_key_non_ascii_header(self): + from starlette.requests import Request + from starlette.responses import JSONResponse + import asyncio + + scope = { + 'type': 'http', + 'method': 'POST', + 'path': '/jobs', + 'headers': [(b'x-api-key', b'test\xf0\x9f\x98\x80')] # 'test๐Ÿ˜€' in utf-8 + } + request = Request(scope) + + async def mock_call_next(req): + return JSONResponse(status_code=200, content={"status": "ok"}) + + import os + import saas_web + + original_keys = os.environ.get("CODEC_CARVER_API_KEYS") + os.environ["CODEC_CARVER_API_KEYS"] = "testkey" + try: + response = asyncio.run(saas_web.require_api_key(request, mock_call_next)) + self.assertEqual(response.status_code, 401) + finally: + if original_keys is not None: + os.environ["CODEC_CARVER_API_KEYS"] = original_keys + else: + del os.environ["CODEC_CARVER_API_KEYS"] + def test_get_ui(self): response = client.get("/") self.assertEqual(response.status_code, 200)