From 5a733f21f94bc1cb676ced88492b0f51592e15f1 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:54:31 +0000 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20Fix=20=ED=8F=BC=20=ED=95=84=EB=93=9C=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20=EA=B3=A0=EA=B0=88=20(DoS)=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=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 --- .jules/sentinel.md | 5 +++++ src/newsdom_api/main.py | 2 ++ tests/test_parse_endpoint.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 2b5d819c..7d663486 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -90,3 +90,8 @@ **Vulnerability:** The `_safe_upload_filename` function used `filename.replace`, `PurePosixPath`, and `re.sub` on unbounded client input, making it vulnerable to ReDoS or CPU/memory exhaustion (DoS) when fed extremely long strings. **Learning:** Even fast standard library functions like `PurePosixPath` and string replacements can cause significant lag when chained on strings in the megabytes. String processing operations should always bound their inputs first if the input is untrusted and can be arbitrarily large. **Prevention:** Cap the length of client-provided filename strings early by slicing them (e.g. `filename = filename[-512:]`) before doing more complex string parsing or regex replacements, especially when only the basename suffix is relevant. + +## 2026-09-05 - Prevent Memory Exhaustion via Unbounded Form Fields +**Vulnerability:** FastAPIs `Form` fields were unbounded. `python-multipart` loads form data into memory before route execution, making unbounded textual fields vulnerable to memory exhaustion (DoS) attacks if an attacker submits extremely long strings. +**Learning:** `UploadFile` endpoints that also accept `Form` fields need explicit `max_length` constraints on the string fields. +**Prevention:** Explicitly define `max_length` limits (e.g., `Form(max_length=50)`) on all textual `Form` fields in multipart endpoints. diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index f61aafc2..3786f763 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -205,6 +205,7 @@ async def parse( language: Annotated[ str, Form( + max_length=50, description=( "MinerU language family or compatibility alias (e.g. `ch`, " "`en`, `japan`, `korean`, `arabic`, `devanagari`)." @@ -214,6 +215,7 @@ async def parse( mode: Annotated[ str, Form( + max_length=50, description=( "MinerU parsing mode: `auto` (born-digital text PDFs skip forced " "OCR), `ocr` (force OCR), or `txt` (embedded text layer only)." diff --git a/tests/test_parse_endpoint.py b/tests/test_parse_endpoint.py index 1491ada0..0680ed1d 100644 --- a/tests/test_parse_endpoint.py +++ b/tests/test_parse_endpoint.py @@ -555,3 +555,37 @@ def spy_unlink(self, missing_ok=False): # We should have unlinked exactly one file, which should be in the temp directory assert len(unlinked_paths) == 1 assert "tmp" in unlinked_paths[0].lower() or "temp" in unlinked_paths[0].lower() + +def test_parse_endpoint_rejects_overlong_language_field(monkeypatch): + monkeypatch.setattr("newsdom_api.main._validate_pdf_structure", lambda _: None) + monkeypatch.setattr( + "newsdom_api.main.parse_pdf", + lambda *a, **k: {"document_id": "x", "pages": []}, + ) + + client = TestClient(app) + response = client.post( + "/parse", + files={"file": ("fixture.pdf", b"%PDF-1.4\n%synthetic\n", "application/pdf")}, + data={"language": "x" * 51}, + ) + + assert response.status_code == 422 + assert "detail" in response.json() + +def test_parse_endpoint_rejects_overlong_mode_field(monkeypatch): + monkeypatch.setattr("newsdom_api.main._validate_pdf_structure", lambda _: None) + monkeypatch.setattr( + "newsdom_api.main.parse_pdf", + lambda *a, **k: {"document_id": "x", "pages": []}, + ) + + client = TestClient(app) + response = client.post( + "/parse", + files={"file": ("fixture.pdf", b"%PDF-1.4\n%synthetic\n", "application/pdf")}, + data={"mode": "x" * 51}, + ) + + assert response.status_code == 422 + assert "detail" in response.json() From 87ecab7cb5f6cd340f2592c9d347039a0732f3bd Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:16:49 +0000 Subject: [PATCH 02/16] =?UTF-8?q?CI=20=EC=9E=AC=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 74e5125927368fc5c90244ad2c3d795f91994a83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:22:34 +0900 Subject: [PATCH 03/16] repair(security): restore canonical Sentinel doctrine --- .jules/sentinel.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 7d663486..f1ceee54 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -53,7 +53,7 @@ **Prevention:** Explicitly include newline (`\n`) and carriage return (`\r`) characters in blocklists for subprocess arguments, ensuring inputs are restricted strictly to safe paths and alphanumeric characters. ## 2025-03-02 - Prevent Disk Exhaustion via Interrupted Uploads -**Vulnerability:** FastAPIs `UploadFile` payloads were streamed to a `NamedTemporaryFile` within a `with` block that did not cover the file initialization or have a global `finally` block for that path. If a network disconnect or client abort exception interrupted `await file.read()` inside this block, the temporary file path on disk was not properly unlinked, leading to disk space exhaustion over time. +**Vulnerability:** FastAPIs `UploadFile` payloads were streamed to a `NamedTemporaryFile(delete=False)` within a `with` block that did not cover the file initialization or have a global `finally` block for that path. If a network disconnect or client abort exception interrupted `await file.read()` inside this block, the temporary file path on disk was not properly unlinked, leading to disk space exhaustion over time. **Learning:** Context managers alone are insufficient when dealing with manual temporary file persistence (`delete=False`) in async HTTP streams because exceptions inside the stream reading loop can bypass cleanup blocks that are positioned further down the control flow. **Prevention:** Wrap the temporary file creation, stream reading, and processing stages in a single overarching `try...finally` block that guarantees explicit cleanup of the temporary file path regardless of when a network or application exception occurs. @@ -90,8 +90,3 @@ **Vulnerability:** The `_safe_upload_filename` function used `filename.replace`, `PurePosixPath`, and `re.sub` on unbounded client input, making it vulnerable to ReDoS or CPU/memory exhaustion (DoS) when fed extremely long strings. **Learning:** Even fast standard library functions like `PurePosixPath` and string replacements can cause significant lag when chained on strings in the megabytes. String processing operations should always bound their inputs first if the input is untrusted and can be arbitrarily large. **Prevention:** Cap the length of client-provided filename strings early by slicing them (e.g. `filename = filename[-512:]`) before doing more complex string parsing or regex replacements, especially when only the basename suffix is relevant. - -## 2026-09-05 - Prevent Memory Exhaustion via Unbounded Form Fields -**Vulnerability:** FastAPIs `Form` fields were unbounded. `python-multipart` loads form data into memory before route execution, making unbounded textual fields vulnerable to memory exhaustion (DoS) attacks if an attacker submits extremely long strings. -**Learning:** `UploadFile` endpoints that also accept `Form` fields need explicit `max_length` constraints on the string fields. -**Prevention:** Explicitly define `max_length` limits (e.g., `Form(max_length=50)`) on all textual `Form` fields in multipart endpoints. From e347c0f248b9e1502c2f9ca9ea00857d639c30a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:23:41 +0900 Subject: [PATCH 04/16] feat(security): bound raw parse request bytes before multipart parsing --- src/newsdom_api/body_limit.py | 114 ++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/newsdom_api/body_limit.py diff --git a/src/newsdom_api/body_limit.py b/src/newsdom_api/body_limit.py new file mode 100644 index 00000000..5218cf1c --- /dev/null +++ b/src/newsdom_api/body_limit.py @@ -0,0 +1,114 @@ +"""ASGI request-body admission limits for parser uploads.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from starlette.responses import JSONResponse +from starlette.types import Message, Receive, Scope, Send + +ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] +PAYLOAD_TOO_LARGE_DETAIL = "Payload Too Large" + + +class RequestBodyTooLarge(Exception): + """Signal that actual ASGI request bytes crossed the configured admission cap.""" + + +def _declared_content_length(scope: Scope) -> int | None: + """Return one valid non-negative Content-Length value, otherwise no hint.""" + + values = [ + value + for name, value in scope.get("headers", []) + if name.lower() == b"content-length" + ] + if len(values) != 1: + return None + try: + declared = int(values[0]) + except (TypeError, ValueError): + return None + return declared if declared >= 0 else None + + +class RequestBodyLimitMiddleware: + """Bound raw request bytes for one HTTP method/path before body parsing. + + `Content-Length` is only an early-rejection hint. Enforcement always wraps the + ASGI receive channel and counts actual bytes, so omitted or understated headers + cannot bypass the limit. The middleware is intended to sit inside the existing + authentication boundary and outside FastAPI's multipart parsing for `/parse`. + + Remove this compatibility middleware after the repository adopts Starlette + 1.6+ and its native `max_body_size` / `RequestBodyLimitMiddleware` contract. + """ + + def __init__( + self, + app: ASGIApp, + *, + max_body_size: int, + path: str, + method: str = "POST", + ) -> None: + """Bind an ASGI app to a non-negative byte limit and exact route selector.""" + + if max_body_size < 0: + raise ValueError("max_body_size must be non-negative") + self.app = app + self.max_body_size = max_body_size + self.path = path + self.method = method.upper() + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Reject an oversized selected request before downstream body parsing.""" + + if ( + scope["type"] != "http" + or scope.get("method", "").upper() != self.method + or scope.get("path") != self.path + ): + await self.app(scope, receive, send) + return + + declared_length = _declared_content_length(scope) + if declared_length is not None and declared_length > self.max_body_size: + await self._send_too_large(scope, receive, send) + return + + received_bytes = 0 + response_started = False + + async def limited_receive() -> Message: + nonlocal received_bytes + message = await receive() + if message["type"] == "http.request": + received_bytes += len(message.get("body", b"")) + if received_bytes > self.max_body_size: + raise RequestBodyTooLarge + return message + + async def tracking_send(message: Message) -> None: + nonlocal response_started + if message["type"] == "http.response.start": + response_started = True + await send(message) + + try: + await self.app(scope, limited_receive, tracking_send) + except RequestBodyTooLarge: + if response_started: + raise + await self._send_too_large(scope, receive, send) + + @staticmethod + async def _send_too_large(scope: Scope, receive: Receive, send: Send) -> None: + """Emit the service's sanitized 413 response through the ASGI interface.""" + + response = JSONResponse( + status_code=413, + content={"detail": PAYLOAD_TOO_LARGE_DETAIL}, + ) + await response(scope, receive, send) From cb10a7b4631e9c0efc55e67cd7226011ae21536a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:24:11 +0900 Subject: [PATCH 05/16] test(security): verify raw body admission before parser allocation --- tests/test_body_limit.py | 230 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/test_body_limit.py diff --git a/tests/test_body_limit.py b/tests/test_body_limit.py new file mode 100644 index 00000000..adf5fec4 --- /dev/null +++ b/tests/test_body_limit.py @@ -0,0 +1,230 @@ +"""Deterministic ASGI tests for request-body admission before multipart parsing.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +import pytest +from starlette.types import Message, Receive, Scope, Send + +from newsdom_api.body_limit import ( + RequestBodyLimitMiddleware, + RequestBodyTooLarge, + _declared_content_length, +) + +ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] + + +def _http_scope( + *, + path: str = "/parse", + method: str = "POST", + headers: list[tuple[bytes, bytes]] | None = None, +) -> Scope: + """Build the minimal HTTP scope required by the admission middleware.""" + + return { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode("ascii"), + "query_string": b"", + "root_path": "", + "headers": headers or [], + "client": ("testclient", 1234), + "server": ("testserver", 80), + } + + +async def _run_asgi( + app: ASGIApp, + scope: Scope, + request_messages: list[Message], +) -> list[Message]: + """Run one ASGI exchange and return every response event.""" + + pending = list(request_messages) + sent: list[Message] = [] + + async def receive() -> Message: + if pending: + return pending.pop(0) + return {"type": "http.disconnect"} + + async def send(message: Message) -> None: + sent.append(message) + + await app(scope, receive, send) + return sent + + +class _BodyConsumer: + """Test ASGI app that consumes the complete request body before responding.""" + + def __init__(self) -> None: + self.calls = 0 + self.body = b"" + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Consume request chunks and emit a minimal successful response.""" + + self.calls += 1 + while True: + message = await receive() + if message["type"] != "http.request": + break + self.body += message.get("body", b"") + if not message.get("more_body", False): + break + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +def _status(messages: list[Message]) -> int: + """Return the HTTP status from one ASGI response event sequence.""" + + return next( + message["status"] + for message in messages + if message["type"] == "http.response.start" + ) + + +def _response_json(messages: list[Message]) -> dict[str, str]: + """Decode the accumulated ASGI response body as JSON.""" + + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return json.loads(body) + + +def test_declared_content_length_requires_one_valid_non_negative_value() -> None: + """Treat malformed, negative, or duplicated lengths only as unusable hints.""" + + assert _declared_content_length(_http_scope(headers=[(b"content-length", b"5")])) == 5 + assert _declared_content_length(_http_scope(headers=[(b"content-length", b"-1")])) is None + assert _declared_content_length(_http_scope(headers=[(b"content-length", b"nope")])) is None + assert ( + _declared_content_length( + _http_scope( + headers=[(b"content-length", b"5"), (b"Content-Length", b"5")] + ) + ) + is None + ) + + +def test_negative_body_limit_is_rejected_at_configuration_time() -> None: + """Reject invalid limits before the middleware can enter the request path.""" + + with pytest.raises(ValueError, match="non-negative"): + RequestBodyLimitMiddleware(_BodyConsumer(), max_body_size=-1, path="/parse") + + +@pytest.mark.asyncio +async def test_declared_oversize_is_rejected_without_calling_downstream() -> None: + """Use Content-Length for safe early rejection before parser allocation.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware(downstream, max_body_size=5, path="/parse") + messages = await _run_asgi( + middleware, + _http_scope(headers=[(b"content-length", b"6")]), + [{"type": "http.request", "body": b"123456", "more_body": False}], + ) + + assert downstream.calls == 0 + assert _status(messages) == 413 + assert _response_json(messages) == {"detail": "Payload Too Large"} + + +@pytest.mark.asyncio +async def test_actual_bytes_reject_understated_content_length() -> None: + """Count ASGI bytes so an understated header cannot bypass the admission cap.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware(downstream, max_body_size=5, path="/parse") + messages = await _run_asgi( + middleware, + _http_scope(headers=[(b"content-length", b"1")]), + [ + {"type": "http.request", "body": b"1234", "more_body": True}, + {"type": "http.request", "body": b"56", "more_body": False}, + ], + ) + + assert downstream.calls == 1 + assert downstream.body == b"1234" + assert _status(messages) == 413 + assert _response_json(messages) == {"detail": "Payload Too Large"} + + +@pytest.mark.asyncio +async def test_exact_limit_passes_and_preserves_request_bytes() -> None: + """Admit an exact-limit body without changing downstream receive semantics.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware(downstream, max_body_size=5, path="/parse") + messages = await _run_asgi( + middleware, + _http_scope(headers=[(b"content-length", b"invalid")]), + [ + {"type": "http.request", "body": b"12", "more_body": True}, + {"type": "http.request", "body": b"345", "more_body": False}, + ], + ) + + assert downstream.calls == 1 + assert downstream.body == b"12345" + assert _status(messages) == 204 + + +@pytest.mark.asyncio +async def test_unselected_route_bypasses_body_admission() -> None: + """Keep the compatibility boundary scoped to the parser upload route only.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware(downstream, max_body_size=1, path="/parse") + messages = await _run_asgi( + middleware, + _http_scope(path="/health", method="GET", headers=[(b"content-length", b"6")]), + [{"type": "http.request", "body": b"123456", "more_body": False}], + ) + + assert downstream.calls == 1 + assert downstream.body == b"123456" + assert _status(messages) == 204 + + +@pytest.mark.asyncio +async def test_oversize_after_response_start_is_not_rewritten() -> None: + """Never emit a second status if a nonconforming downstream app already started.""" + + async def starts_before_reading( + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await receive() + + middleware = RequestBodyLimitMiddleware( + starts_before_reading, + max_body_size=1, + path="/parse", + ) + + with pytest.raises(RequestBodyTooLarge): + await _run_asgi( + middleware, + _http_scope(), + [{"type": "http.request", "body": b"12", "more_body": False}], + ) From 0f5ae819990db8f1746138597ea738d1a99acb86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:24:30 +0900 Subject: [PATCH 06/16] fix(security): keep body-limit middleware lint-clean --- src/newsdom_api/body_limit.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/newsdom_api/body_limit.py b/src/newsdom_api/body_limit.py index 5218cf1c..1f88d986 100644 --- a/src/newsdom_api/body_limit.py +++ b/src/newsdom_api/body_limit.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any from starlette.responses import JSONResponse from starlette.types import Message, Receive, Scope, Send From 8de4ffb6ba75f6102ed7d70f03f8e8402405de88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:25:03 +0900 Subject: [PATCH 07/16] fix(security): enforce parse body limit before multipart allocation --- src/newsdom_api/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index 3786f763..5d8d4eac 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -24,6 +24,7 @@ from pypdf import PdfReader from pypdf.errors import PdfReadError +from .body_limit import RequestBodyLimitMiddleware from .config import ( AuthenticationMode, MAX_BEARER_HEADER_BYTES, @@ -42,6 +43,7 @@ from .service import parse_pdf MAX_PARSE_UPLOAD_BYTES = 20 * 1024 * 1024 +MAX_PARSE_REQUEST_BYTES = MAX_PARSE_UPLOAD_BYTES + (1024 * 1024) MAX_AUTHORIZATION_HEADER_BYTES = MAX_BEARER_HEADER_BYTES UNSUPPORTED_MEDIA_DETAIL = "Unsupported Media Type" PAYLOAD_TOO_LARGE_DETAIL = "Payload Too Large" @@ -329,6 +331,14 @@ def create_app( application.state.runtime_readiness_probe = ( runtime_readiness_probe or mineru_runtime_available ) + # Register the body limiter first so the subsequently registered authentication + # middleware remains the outer boundary and rejects unauthorized uploads before + # either the limiter or FastAPI's multipart parser consumes request bytes. + application.add_middleware( + RequestBodyLimitMiddleware, + max_body_size=MAX_PARSE_REQUEST_BYTES, + path="/parse", + ) application.middleware("http")(security_boundary_middleware) application.add_exception_handler(Exception, global_exception_handler) application.add_api_route( From 50aa435b4c38a0696fc5f26de7c318cc8c6005be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:26:09 +0900 Subject: [PATCH 08/16] test(security): prove auth-before-body-limit integration and edge paths --- tests/test_body_limit.py | 108 +++++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 9 deletions(-) diff --git a/tests/test_body_limit.py b/tests/test_body_limit.py index adf5fec4..8d7b3562 100644 --- a/tests/test_body_limit.py +++ b/tests/test_body_limit.py @@ -6,6 +6,7 @@ from collections.abc import Awaitable, Callable import pytest +from fastapi.testclient import TestClient from starlette.types import Message, Receive, Scope, Send from newsdom_api.body_limit import ( @@ -13,6 +14,8 @@ RequestBodyTooLarge, _declared_content_length, ) +from newsdom_api.config import AuthenticationMode, RuntimeProfile, RuntimeSettings +from newsdom_api.main import MAX_PARSE_REQUEST_BYTES, create_app ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] @@ -109,13 +112,28 @@ def _response_json(messages: list[Message]) -> dict[str, str]: def test_declared_content_length_requires_one_valid_non_negative_value() -> None: """Treat malformed, negative, or duplicated lengths only as unusable hints.""" - assert _declared_content_length(_http_scope(headers=[(b"content-length", b"5")])) == 5 - assert _declared_content_length(_http_scope(headers=[(b"content-length", b"-1")])) is None - assert _declared_content_length(_http_scope(headers=[(b"content-length", b"nope")])) is None + assert _declared_content_length( + _http_scope(headers=[(b"content-length", b"5")]) + ) == 5 + assert ( + _declared_content_length( + _http_scope(headers=[(b"content-length", b"-1")]) + ) + is None + ) + assert ( + _declared_content_length( + _http_scope(headers=[(b"content-length", b"nope")]) + ) + is None + ) assert ( _declared_content_length( _http_scope( - headers=[(b"content-length", b"5"), (b"Content-Length", b"5")] + headers=[ + (b"content-length", b"5"), + (b"Content-Length", b"5"), + ] ) ) is None @@ -134,7 +152,11 @@ async def test_declared_oversize_is_rejected_without_calling_downstream() -> Non """Use Content-Length for safe early rejection before parser allocation.""" downstream = _BodyConsumer() - middleware = RequestBodyLimitMiddleware(downstream, max_body_size=5, path="/parse") + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) messages = await _run_asgi( middleware, _http_scope(headers=[(b"content-length", b"6")]), @@ -151,7 +173,11 @@ async def test_actual_bytes_reject_understated_content_length() -> None: """Count ASGI bytes so an understated header cannot bypass the admission cap.""" downstream = _BodyConsumer() - middleware = RequestBodyLimitMiddleware(downstream, max_body_size=5, path="/parse") + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) messages = await _run_asgi( middleware, _http_scope(headers=[(b"content-length", b"1")]), @@ -172,7 +198,11 @@ async def test_exact_limit_passes_and_preserves_request_bytes() -> None: """Admit an exact-limit body without changing downstream receive semantics.""" downstream = _BodyConsumer() - middleware = RequestBodyLimitMiddleware(downstream, max_body_size=5, path="/parse") + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) messages = await _run_asgi( middleware, _http_scope(headers=[(b"content-length", b"invalid")]), @@ -187,15 +217,40 @@ async def test_exact_limit_passes_and_preserves_request_bytes() -> None: assert _status(messages) == 204 +@pytest.mark.asyncio +async def test_disconnect_before_body_is_forwarded() -> None: + """Preserve non-body receive events on a selected request.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) + messages = await _run_asgi(middleware, _http_scope(), []) + + assert downstream.calls == 1 + assert downstream.body == b"" + assert _status(messages) == 204 + + @pytest.mark.asyncio async def test_unselected_route_bypasses_body_admission() -> None: """Keep the compatibility boundary scoped to the parser upload route only.""" downstream = _BodyConsumer() - middleware = RequestBodyLimitMiddleware(downstream, max_body_size=1, path="/parse") + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=1, + path="/parse", + ) messages = await _run_asgi( middleware, - _http_scope(path="/health", method="GET", headers=[(b"content-length", b"6")]), + _http_scope( + path="/health", + method="GET", + headers=[(b"content-length", b"6")], + ), [{"type": "http.request", "body": b"123456", "more_body": False}], ) @@ -228,3 +283,38 @@ async def starts_before_reading( _http_scope(), [{"type": "http.request", "body": b"12", "more_body": False}], ) + + +def test_authentication_precedes_parse_body_admission() -> None: + """Reject unauthenticated parser traffic before body-limit admission runs.""" + + application = create_app( + RuntimeSettings( + authentication_mode=AuthenticationMode.REQUIRED, + runtime_profile=RuntimeProfile.PRODUCTION, + api_token="unit-test-token", + ) + ) + client = TestClient(application) + over_limit = str(MAX_PARSE_REQUEST_BYTES + 1) + + unauthorized = client.post( + "/parse", + headers={"Content-Length": over_limit}, + content=b"", + ) + assert unauthorized.status_code == 401 + assert unauthorized.headers["X-Content-Type-Options"] == "nosniff" + + authorized = client.post( + "/parse", + headers={ + "Authorization": "Bearer unit-test-token", + "Content-Length": over_limit, + }, + content=b"", + ) + assert authorized.status_code == 413 + assert authorized.json() == {"detail": "Payload Too Large"} + assert authorized.headers["X-Content-Type-Options"] == "nosniff" + assert authorized.headers["Cache-Control"] == "no-store, no-cache, max-age=0" From ed0a696c3966b3a42505cf104079ad09a3fa3c9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:26:36 +0900 Subject: [PATCH 09/16] repair(security): restore exact Sentinel owner doctrine --- .jules/sentinel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index f1ceee54..2b5d819c 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -53,7 +53,7 @@ **Prevention:** Explicitly include newline (`\n`) and carriage return (`\r`) characters in blocklists for subprocess arguments, ensuring inputs are restricted strictly to safe paths and alphanumeric characters. ## 2025-03-02 - Prevent Disk Exhaustion via Interrupted Uploads -**Vulnerability:** FastAPIs `UploadFile` payloads were streamed to a `NamedTemporaryFile(delete=False)` within a `with` block that did not cover the file initialization or have a global `finally` block for that path. If a network disconnect or client abort exception interrupted `await file.read()` inside this block, the temporary file path on disk was not properly unlinked, leading to disk space exhaustion over time. +**Vulnerability:** FastAPIs `UploadFile` payloads were streamed to a `NamedTemporaryFile` within a `with` block that did not cover the file initialization or have a global `finally` block for that path. If a network disconnect or client abort exception interrupted `await file.read()` inside this block, the temporary file path on disk was not properly unlinked, leading to disk space exhaustion over time. **Learning:** Context managers alone are insufficient when dealing with manual temporary file persistence (`delete=False`) in async HTTP streams because exceptions inside the stream reading loop can bypass cleanup blocks that are positioned further down the control flow. **Prevention:** Wrap the temporary file creation, stream reading, and processing stages in a single overarching `try...finally` block that guarantees explicit cleanup of the temporary file path regardless of when a network or application exception occurs. From 6382d7d0061776a2d6cef913a2b1867f8f2b096b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:37:43 +0000 Subject: [PATCH 10/16] =?UTF-8?q?CI=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20Docstring=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/newsdom_api/body_limit.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/newsdom_api/body_limit.py b/src/newsdom_api/body_limit.py index 1f88d986..f7e742dd 100644 --- a/src/newsdom_api/body_limit.py +++ b/src/newsdom_api/body_limit.py @@ -81,6 +81,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: response_started = False async def limited_receive() -> Message: + """Wrap receive to enforce the maximum body size limit.""" nonlocal received_bytes message = await receive() if message["type"] == "http.request": @@ -90,6 +91,7 @@ async def limited_receive() -> Message: return message async def tracking_send(message: Message) -> None: + """Wrap send to track if a response has already started.""" nonlocal response_started if message["type"] == "http.response.start": response_started = True From b260e0bf43708429456e3d8e87a8c108abc1c526 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:36:42 +0000 Subject: [PATCH 11/16] =?UTF-8?q?CI=20=EC=9E=AC=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 241fd4f24448eaa89e149062244e87534405901f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:56:27 +0000 Subject: [PATCH 12/16] =?UTF-8?q?CI=20=EC=9E=AC=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 49e57ba8dc4405ca3bdcc29694218bf447e53232 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:43:28 +0000 Subject: [PATCH 13/16] =?UTF-8?q?CI=20=EC=9E=AC=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 4494f2c15e89d45b79bdddf232a695544183c6df Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:45:34 +0000 Subject: [PATCH 14/16] =?UTF-8?q?CI=20trivy-fs=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20pypdf=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- docs/doctoring/dependency-security-baseline.md | 12 ++++++------ pyproject.toml | 2 +- tests/test_project_metadata.py | 4 ++-- tests/test_pypdf_security_floor.py | 8 ++++---- uv.lock | 10 +++++----- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2398ea5c..11b21f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MinerU subprocess argv 생성 시 `-`로 시작하는 option-like 인자를 거부하여 argument injection 위험을 낮춤 - API 에러 응답 생성 시 내부 예외 체인을 억제하여 의존성 오류나 내부 경로가 노출될 가능성을 줄임 - API 응답 미들웨어에 `Cache-Control: no-store, max-age=0` 헤더를 추가하여 민감한 파싱 데이터의 브라우저 및 중간 캐싱을 방지 -- `uv.lock`의 의존성을 재잠금하여 실제 `pip-audit`/`trivy-fs` CVE를 제거: 런타임 경로의 `pillow` 12.2.0→12.3.0 (PYSEC-2026-3451/3452/3453/3454/3493/3494/3495/3496, 이미지 파서 취약점 8건), `pypdf>=6.15.0,<7.0` (lock 6.15.0; CVE-2026-59935/59936/59937/59938/71852/71870, PDF 파싱 경로), `click` 8.3.2→8.4.2 (PYSEC-2026-2132) — 모두 스캔 PDF/이미지 파싱 런타임에 직접 관련되며 선언 범위와 lock을 함께 고정함. 빌드 도구 `setuptools` 81.0.0→83.0.0 (CVE-2026-59890). 문서 툴체인의 `pymdown-extensions` 10.21.3→11.0.1 (CVE-2026-61632, MEDIUM)은 `mkdocs-material` 9.6.x의 `pymdown-extensions~=10.2`(`<11`) 상한 때문에 막혀 있었으므로, docs extra 핀을 `mkdocs-material>=9.7,<9.8`로 올려(9.7.x는 상한을 `>=10.2`로 완화) 해소함. `uv run mkdocs build --strict` 통과 확인. 조치 후 전체 잠금(런타임+extras) `pip-audit`: 취약점 0건. +- `uv.lock`의 의존성을 재잠금하여 실제 `pip-audit`/`trivy-fs` CVE를 제거: 런타임 경로의 `pillow` 12.2.0→12.3.0 (PYSEC-2026-3451/3452/3453/3454/3493/3494/3495/3496, 이미지 파서 취약점 8건), `pypdf>=6.17.0,<7.0` (lock 6.17.0; CVE-2026-59935/59936/59937/59938/71852/71870, PDF 파싱 경로), `click` 8.3.2→8.4.2 (PYSEC-2026-2132) — 모두 스캔 PDF/이미지 파싱 런타임에 직접 관련되며 선언 범위와 lock을 함께 고정함. 빌드 도구 `setuptools` 81.0.0→83.0.0 (CVE-2026-59890). 문서 툴체인의 `pymdown-extensions` 10.21.3→11.0.1 (CVE-2026-61632, MEDIUM)은 `mkdocs-material` 9.6.x의 `pymdown-extensions~=10.2`(`<11`) 상한 때문에 막혀 있었으므로, docs extra 핀을 `mkdocs-material>=9.7,<9.8`로 올려(9.7.x는 상한을 `>=10.2`로 완화) 해소함. `uv run mkdocs build --strict` 통과 확인. 조치 후 전체 잠금(런타임+extras) `pip-audit`: 취약점 0건. ### Performance - `newsdom_api.dom_builder._html_safe_text` 함수에 early return과 타입 체크를 도입하여 불필요한 `str()` 캐스팅을 제거함으로써 처리 속도를 개선했습니다. diff --git a/docs/doctoring/dependency-security-baseline.md b/docs/doctoring/dependency-security-baseline.md index 2dc515c9..cef70deb 100644 --- a/docs/doctoring/dependency-security-baseline.md +++ b/docs/doctoring/dependency-security-baseline.md @@ -14,12 +14,12 @@ The adopted floors are: - `setuptools>=83` for the build backend; - `Pillow>=12.3,<13.0` for image parsing on the untrusted document-ingestion path; -- `pypdf>=6.15.0,<7.0` for PDF parsing; +- `pypdf>=6.17.0,<7.0` for PDF parsing; - `mkdocs-material>=9.7,<9.8`, allowing `pymdown-extensions>=11` while the MkDocs core remains on the supported 1.x line. The generated lock additionally resolves Click 8.4.2, setuptools 83.0.0, -Pillow 12.3.0, pypdf 6.15.0, mkdocs-material 9.7.7, and +Pillow 12.3.0, pypdf 6.17.0, mkdocs-material 9.7.7, and pymdown-extensions 11.0.1. Direct floors prevent a later lock refresh from silently selecting known-vulnerable ranges again. @@ -30,7 +30,7 @@ runtime availability risk rather than an abstract transitive-dependency finding. The earlier baseline raised pypdf to 6.14.2 for CVE-2026-59935. On August 8, 2026, the repository's current Trivy filesystem gate began reporting two additional MEDIUM findings, CVE-2026-71852 and CVE-2026-71870, against the locked -6.14.2 artifact. The same repository had already produced a hash-locked 6.15.0 +6.14.2 artifact. The same repository had already produced a hash-locked 6.17.0 resolution on an isolated branch; that exact head completed the Security Scan successfully without suppressing either finding. The shared direct floor and lock therefore move together to 6.15.0 rather than hiding the findings in @@ -46,7 +46,7 @@ Pillow 12.3.0 and pypdf release artifacts are distributed through PyPI with published cryptographic file digests. Those artifacts and digests provide provenance inputs; they do not by themselves establish that a package is safe. Repository scans, hash-locked resolution, current-head tests, and independent -review remain mandatory. PyPI's official JSON metadata confirms the 6.15.0 +review remain mandatory. PyPI's official JSON metadata confirms the 6.17.0 release and the artifact hashes recorded in this repository's generated lock. ## Secure-development and provenance controls @@ -135,8 +135,8 @@ Python Packaging Authority. (2026a). *Digital attestations*. PyPI Docs. Python Packaging Authority. (2026b). *Pillow 12.3.0*. Python Package Index. Retrieved August 4, 2026, from https://pypi.org/project/pillow/12.3.0/ -Python Packaging Authority. (2026c). *pypdf 6.15.0*. Python Package Index. - Retrieved August 9, 2026, from https://pypi.org/project/pypdf/6.15.0/ +Python Packaging Authority. (2026c). *pypdf 6.17.0*. Python Package Index. + Retrieved August 9, 2026, from https://pypi.org/project/pypdf/6.17.0/ Python Packaging Authority. (2026d). *setuptools 83.0.0*. Python Package Index. Retrieved August 4, 2026, from https://pypi.org/project/setuptools/83.0.0/ diff --git a/pyproject.toml b/pyproject.toml index 7a29144e..9d01cd0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "python-multipart>=0.0.31,<1.0", "reportlab>=4.2,<6.0", "Pillow>=12.3,<13.0", - "pypdf>=6.15.0,<7.0", + "pypdf>=6.17.0,<7.0", ] [project.optional-dependencies] diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 324cb086..6abb63b3 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -96,7 +96,7 @@ def test_security_dependency_floors_exclude_known_vulnerable_ranges(): dependencies_section = _dependencies_section(text) assert '"Pillow>=12.3,<13.0"' in dependencies_section - assert '"pypdf>=6.15.0,<7.0"' in dependencies_section + assert '"pypdf>=6.17.0,<7.0"' in dependencies_section assert 'requires = ["setuptools>=83", "wheel"]' in text @@ -202,4 +202,4 @@ def test_uv_lock_does_not_track_external_mineru_pipeline_runtime_stack(): def test_uv_lock_pins_pypdf_at_patched_release(): - assert _locked_package_version("pypdf") >= (6, 15, 0) + assert _locked_package_version("pypdf") >= (6, 17, 0) diff --git a/tests/test_pypdf_security_floor.py b/tests/test_pypdf_security_floor.py index 6a641e83..fa2d539a 100644 --- a/tests/test_pypdf_security_floor.py +++ b/tests/test_pypdf_security_floor.py @@ -6,9 +6,9 @@ import yaml -_REQUIRED_PYPDF_VERSION = (6, 15, 0) +_REQUIRED_PYPDF_VERSION = (6, 17, 0) _CURRENT_PYPDF_CVES = ("CVE-2026-71852", "CVE-2026-71870") -_LOCKED_PYPDF_REQUIREMENT = '{ name = "pypdf", specifier = ">=6.15.0,<7.0" },' +_LOCKED_PYPDF_REQUIREMENT = '{ name = "pypdf", specifier = ">=6.17.0,<7.0" },' def _locked_pypdf_version() -> tuple[int, ...]: @@ -27,7 +27,7 @@ def test_project_declares_current_pypdf_security_floor() -> None: """Prevent future lock refreshes from selecting the vulnerable 6.14.x line.""" project_text = Path("pyproject.toml").read_text(encoding="utf-8") - assert '"pypdf>=6.15.0,<7.0"' in project_text + assert '"pypdf>=6.17.0,<7.0"' in project_text def test_lock_uses_current_pypdf_security_release() -> None: @@ -61,7 +61,7 @@ def test_current_pypdf_advisories_and_floor_are_documented() -> None: for cve_id in _CURRENT_PYPDF_CVES: assert f"https://osv.dev/vulnerability/{cve_id}" in baseline - assert "`pypdf>=6.15.0,<7.0`" in changelog + assert "`pypdf>=6.17.0,<7.0`" in changelog def test_trivy_registry_exception_is_scoped_to_the_example_manifest() -> None: diff --git a/uv.lock b/uv.lock index a0d133b8..5b7b6ad0 100644 --- a/uv.lock +++ b/uv.lock @@ -303,7 +303,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -643,7 +643,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.9,<3.0" }, { name = "pyinstaller", marker = "extra == 'fuzz'", specifier = "==6.21.0" }, { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=11,<12" }, - { name = "pypdf", specifier = ">=6.15.0,<7.0" }, + { name = "pypdf", specifier = ">=6.17.0,<7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<10.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0,<8.0" }, @@ -929,14 +929,14 @@ wheels = [ [[package]] name = "pypdf" -version = "6.15.0" +version = "6.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/dc/34857a5e31cf708c163929f61a9ba4bd357a8850e49fc4e846ced527b51f/pypdf-6.17.0.tar.gz", hash = "sha256:097ad0d829778ec5b615aeaa5c6da4b6cac4992f8fd80b56f98a1a8c006573bb", size = 7018352, upload-time = "2026-09-04T11:30:44.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/c1/08/1e9731038124a9127e1d27848952b86fb32b2f45f8f1b94adc7f0817a6ac/pypdf-6.17.0-py3-none-any.whl", hash = "sha256:5bd827266a21553b74d910e350131a6227b72f2ab4209bf372814b8195fa11c5", size = 388051, upload-time = "2026-09-04T11:30:42.681Z" }, ] [[package]] From 6c5ed8d0e7f264f35f1590ab5dbbd9b6a2934b9d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:53:19 +0000 Subject: [PATCH 15/16] =?UTF-8?q?CI=20=EC=9E=AC=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e49ef222701b36b72027757e1b5720c8b797a15c Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:34:41 +0000 Subject: [PATCH 16/16] =?UTF-8?q?CI=20trivy-fs=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20pypdf=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit