diff --git a/.jules/palette.md b/.jules/palette.md index 1ba61391..b9b39817 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -5,5 +5,10 @@ ## 2026-08-04 - Backend API Developer Experience -**Learning:** 백엔드 전용 프로젝트(프론트엔드가 없는 경우)에서는 'UX(사용자 경험)'가 주로 'DX(개발자 경험)'로 해석됩니다. OpenAPI/Swagger 스키마에 `json_schema_extra={"example": ...}`와 같은 구체적인 예시를 추가하면 API를 사용하는 개발자들의 인터페이스 이해도를 높일 수 있습니다. -**Action:** 향후 백엔드 API 중심의 프로젝트에서는 Pydantic 스키마 정의에 풍부한 문서화와 예제 데이터가 포함되어 있는지 확인하여 개발자 경험을 개선할 것입니다. +**Learning:** 백엔드 전용 프로젝트에서는 Swagger UI와 OpenAPI 스키마도 실제 사용자 인터페이스입니다. 예제와 설명은 개발자가 계약을 이해하는 데 직접 영향을 주므로, Pydantic 스키마의 실제 제약과 어긋나지 않는 범위에서 구체적으로 제공해야 합니다. +**Action:** API 스키마를 바꿀 때는 생성된 OpenAPI와 `/docs` 동작을 함께 검증하고, 예제 데이터가 실제 검증 규칙을 통과하는지 확인합니다. + +## 2026-09-01 - Swagger UI authorization persistence + +**Learning:** Swagger UI의 `persistAuthorization`은 새로고침 뒤에도 인증 값을 유지하지만 기본값은 `false`입니다. 개발 편의를 위해 이를 켜더라도 운영·공용 브라우저까지 일괄 적용하면 Bearer 토큰의 브라우저 잔존 범위를 불필요하게 늘립니다. 또한 FastAPI 기본 Swagger UI는 외부 정적 자산과 인라인 초기화 스크립트를 사용하므로 `default-src 'none'`만 적용하면 UI 자체가 실행되지 않습니다. +**Action:** `persistAuthorization`은 명시적 development runtime에서만 활성화합니다. `/docs`에 필요한 CSP 예외는 문서 경로에만 한정하고, API 응답의 기본 `default-src 'none'` 경계는 유지합니다. 설정 변경은 생성된 Swagger HTML, CSP 헤더, 비문서 경로의 보안 헤더 회귀 테스트로 검증합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2398ea5c..40aa840c 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.16.0,<7.0` (lock 6.16.2; CVE-2026-84309/84310/84311, 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..4e90ab5e 100644 --- a/docs/doctoring/dependency-security-baseline.md +++ b/docs/doctoring/dependency-security-baseline.md @@ -29,7 +29,7 @@ NewsDOM accepts untrusted PDF uploads. A parser denial of service is therefore a 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 +additional MEDIUM findings, CVE-2026-84309 and CVE-2026-84310, against the locked 6.14.2 artifact. The same repository had already produced a hash-locked 6.15.0 resolution on an isolated branch; that exact head completed the Security Scan successfully without suppressing either finding. The shared direct floor and lock @@ -123,11 +123,14 @@ Open Source Vulnerabilities. (2026a). *CVE-2026-59935*. Retrieved August 4, Open Source Vulnerabilities. (2026b). *CVE-2026-59890*. Retrieved August 4, 2026, from https://osv.dev/vulnerability/CVE-2026-59890 -Open Source Vulnerabilities. (2026c). *CVE-2026-71852*. Retrieved August 9, - 2026, from https://osv.dev/vulnerability/CVE-2026-71852 +Open Source Vulnerabilities. (2026c). *CVE-2026-84309*. Retrieved August 9, + 2026, from https://osv.dev/vulnerability/CVE-2026-84309 -Open Source Vulnerabilities. (2026d). *CVE-2026-71870*. Retrieved August 9, - 2026, from https://osv.dev/vulnerability/CVE-2026-71870 +Open Source Vulnerabilities. (2026d). *CVE-2026-84310*. Retrieved August 9, + 2026, from https://osv.dev/vulnerability/CVE-2026-84310 + +Open Source Vulnerabilities. (2026e). *CVE-2026-84311*. Retrieved September 3, + 2026, from https://osv.dev/vulnerability/CVE-2026-84311 Python Packaging Authority. (2026a). *Digital attestations*. PyPI Docs. Retrieved August 4, 2026, from https://docs.pypi.org/attestations/ diff --git a/pyproject.toml b/pyproject.toml index 7a29144e..c3a38b71 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.16.0,<7.0", ] [project.optional-dependencies] diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index f61aafc2..8b45d0f2 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -27,6 +27,7 @@ from .config import ( AuthenticationMode, MAX_BEARER_HEADER_BYTES, + RuntimeProfile, RuntimeSettings, load_runtime_settings, ) @@ -50,6 +51,25 @@ SERVICE_UNAVAILABLE_DETAIL = "Service Unavailable" LOGGER = logging.getLogger("newsdom_api") BEARER_SCHEME = HTTPBearer(auto_error=False, scheme_name="BearerAuth") +LOCKED_DOWN_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" +SWAGGER_DOCS_CSP = ( + "default-src 'none'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data: https://fastapi.tiangolo.com; " + "font-src 'self' data: https://cdn.jsdelivr.net; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; " + "form-action 'self'" +) +REDOC_DOCS_CSP = ( + "default-src 'none'; " + "script-src 'self' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: https://fastapi.tiangolo.com; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; " + "form-action 'self'" +) tags_metadata = [ {"name": "Parser", "description": "Core PDF parsing endpoints."}, @@ -65,9 +85,14 @@ def _apply_security_headers(response: Response, request: Request) -> Response: response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" - response.headers["Content-Security-Policy"] = ( - "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" - ) + path = request.scope.get("path") + if path in {"/docs", "/docs/oauth2-redirect"}: + csp = SWAGGER_DOCS_CSP + elif path == "/redoc": + csp = REDOC_DOCS_CSP + else: + csp = LOCKED_DOWN_CSP + response.headers["Content-Security-Policy"] = csp response.headers["Referrer-Policy"] = "no-referrer" response.headers["Cache-Control"] = "no-store, no-cache, max-age=0" forwarded_proto = request.headers.get("x-forwarded-proto", "") @@ -321,6 +346,10 @@ def create_app( "displayRequestDuration": True, "syntaxHighlight.theme": "monokai", "tryItOutEnabled": True, + "persistAuthorization": ( + application_settings.runtime_profile is RuntimeProfile.DEVELOPMENT + ), + "validatorUrl": None, }, ) application.state.runtime_settings = application_settings diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 324cb086..ca4f4541 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.16.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, 16, 0) diff --git a/tests/test_pypdf_security_floor.py b/tests/test_pypdf_security_floor.py index 6a641e83..5120052a 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) -_CURRENT_PYPDF_CVES = ("CVE-2026-71852", "CVE-2026-71870") -_LOCKED_PYPDF_REQUIREMENT = '{ name = "pypdf", specifier = ">=6.15.0,<7.0" },' +_REQUIRED_PYPDF_VERSION = (6, 16, 0) +_CURRENT_PYPDF_CVES = ("CVE-2026-84309", "CVE-2026-84310", "CVE-2026-84311") +_LOCKED_PYPDF_REQUIREMENT = '{ name = "pypdf", specifier = ">=6.16.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.16.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.16.0,<7.0`" in changelog def test_trivy_registry_exception_is_scoped_to_the_example_manifest() -> None: diff --git a/tests/test_swagger_docs_security.py b/tests/test_swagger_docs_security.py new file mode 100644 index 00000000..d2ba99ff --- /dev/null +++ b/tests/test_swagger_docs_security.py @@ -0,0 +1,84 @@ +"""Regression tests for the interactive API documentation security boundary.""" + +from fastapi.testclient import TestClient + +from newsdom_api.config import AuthenticationMode, RuntimeProfile, RuntimeSettings +from newsdom_api.main import create_app + +LOCKED_DOWN_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" + + +def _settings(profile: RuntimeProfile) -> RuntimeSettings: + """Build an authenticated runtime configuration for one profile.""" + + return RuntimeSettings( + authentication_mode=AuthenticationMode.REQUIRED, + runtime_profile=profile, + api_token="swagger-test-token", + ) + + +def test_swagger_authorization_persistence_is_development_only() -> None: + """Persist bearer authorization only in the explicit development profile.""" + + production = TestClient(create_app(_settings(RuntimeProfile.PRODUCTION))) + development = TestClient(create_app(_settings(RuntimeProfile.DEVELOPMENT))) + + production_docs = production.get("/docs") + development_docs = development.get("/docs") + + assert production_docs.status_code == 200 + assert development_docs.status_code == 200 + assert '"persistAuthorization": false' in production_docs.text + assert '"persistAuthorization": true' in development_docs.text + assert '"validatorUrl": null' in development_docs.text + + +def test_swagger_csp_allows_only_required_origins_in_each_profile() -> None: + """Swagger UI can execute in each runtime profile with a route-scoped CSP.""" + + for profile in (RuntimeProfile.PRODUCTION, RuntimeProfile.DEVELOPMENT): + client = TestClient(create_app(_settings(profile))) + response = client.get("/docs") + + assert response.status_code == 200 + csp = response.headers["Content-Security-Policy"] + assert "default-src 'none'" in csp + assert "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" in csp + assert "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" in csp + assert "img-src 'self' data: https://fastapi.tiangolo.com" in csp + assert "connect-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + assert "base-uri 'none'" in csp + assert "form-action 'self'" in csp + + +def test_redoc_csp_allows_only_required_origins_in_each_profile() -> None: + """ReDoc can load its script, fonts, schema and favicon in each profile.""" + + for profile in (RuntimeProfile.PRODUCTION, RuntimeProfile.DEVELOPMENT): + client = TestClient(create_app(_settings(profile))) + response = client.get("/redoc") + + assert response.status_code == 200 + csp = response.headers["Content-Security-Policy"] + assert "default-src 'none'" in csp + assert "script-src 'self' https://cdn.jsdelivr.net" in csp + assert "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com" in csp + assert "font-src 'self' https://fonts.gstatic.com" in csp + assert "img-src 'self' data: https://fastapi.tiangolo.com" in csp + assert "connect-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + assert "base-uri 'none'" in csp + assert "form-action 'self'" in csp + + +def test_non_docs_responses_keep_the_locked_down_csp() -> None: + """The docs exception must not weaken the API response security boundary.""" + + for profile in (RuntimeProfile.PRODUCTION, RuntimeProfile.DEVELOPMENT): + client = TestClient(create_app(_settings(profile)), base_url="https://testserver") + response = client.get("/health") + + assert response.status_code == 200 + assert response.headers["Content-Security-Policy"] == LOCKED_DOWN_CSP diff --git a/uv.lock b/uv.lock index a0d133b8..5cd42560 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.16.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.16.2" 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/44/66/54212e75406afd9f3e933d0dda23072f6aecc55c5a273077dc2e0b028b23/pypdf-6.16.2.tar.gz", hash = "sha256:595647f6191de6f402cfde1d0c455d6cbccbd509aac32b34783009c032de5d6e", size = 7008996, upload-time = "2026-08-23T13:50:07.135Z" } 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/13/f1/a2da3b55acd4ab737bf728c97edaaed5ec1d3c1236acb639dcdfa97e42c7/pypdf-6.16.2-py3-none-any.whl", hash = "sha256:c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604", size = 385060, upload-time = "2026-08-23T13:50:05.349Z" }, ] [[package]]