Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-03 - Normalize bearer-token comparison to fixed-size digests
**Vulnerability:** Python documents that `hmac.compare_digest()` can theoretically reveal operand type or length when its two inputs have different lengths. Comparing raw bearer credentials therefore leaves the configured token length in the request-time comparison boundary.
**Learning:** A self-comparison such as `compare_digest(credentials, credentials)` after an explicit length mismatch does not normalize the secret-dependent comparison boundary; it only performs extra work whose length is chosen by the caller. The configured token can instead be hashed once during immutable runtime configuration, while each bounded request credential is hashed to the same fixed digest size before comparison.
**Prevention:** Precompute the configured token's SHA-256 digest outside the request path, hash the bounded presented credential to the same digest size, and call `compare_digest()` exactly once on the two equal-length digests. Keep authentication behavior tests separate from timing claims and do not describe wall-clock equality as proven by a 401 response.
36 changes: 36 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Product / Technical Gap Baseline

이 문서는 NewsDOM API의 상용화 Gap을 live code, protected branch, 열린 PR, 실행 테스트와 공식 upstream 계약을 기준으로 관리한다. 서로 다른 PR의 아직 병합되지 않은 변경은 현재 branch의 기능으로 간주하지 않는다.

## Bearer authentication comparison boundary

### 문제

`hmac.compare_digest()`는 내용 기반 short-circuit을 피하지만 Python 공식 문서는 두 operand의 길이가 다르면 type·length 정보가 이론적으로 timing을 통해 드러날 수 있다고 명시한다. 기존 branch의 `len(credentials) != len(expected_token)` 뒤 `compare_digest(credentials, credentials)` 방식은 configured token과 presented credential의 비교 길이를 정규화하지 않는다. caller가 자신의 입력 길이만큼 self-comparison을 한 뒤 실패할 뿐이다.

### 제약과 선택

- Authorization header 전체는 `MAX_BEARER_HEADER_BYTES=4096`에서 먼저 제한한다.
- configured token은 immutable `RuntimeSettings` 생성 시 UTF-8 정규화·길이 검증을 끝낸 뒤 SHA-256 digest를 한 번 계산한다. secret length에 따른 hash 작업을 request path에서 반복하지 않는다.
- request credential은 이미 제한된 bytes를 SHA-256으로 digest한다.
- 실제 equality check는 두 개의 고정 32-byte digest에 대해 `hmac.compare_digest()`를 정확히 한 번 수행한다.
- 이 경계는 wall-clock 시간이 완전히 동일하다고 주장하지 않는다. HTTP stack, hashing, scheduling 등 전체 request latency에는 변동이 있으므로 acceptance는 operand contract와 인증 semantics를 실행 테스트로 검증한다.

### RED → GREEN evidence

- RED `faedf8d9103f8a510b35b49570124071cc875a36`: runtime settings가 fixed-size token digest를 미리 보유하고, 길이가 다른 presented token도 `compare_digest()`에 같은 digest 길이로 들어가며, 정확한 configured token은 인증 경계를 통과해야 한다는 regression을 추가했다.
- GREEN `1d4e61de2a879561cfeeb7871c1d01cc37114fad`: configured token digest를 immutable runtime configuration에서 사전 계산한다.
- GREEN `139c8440b894690a2f0d6dea2d183409bbf5361f`: request credential을 SHA-256 digest로 정규화하고 fixed-size digest끼리 비교한다.
- Documentation repair `e35b37e858b8fafd272d1428b5bceb97e16ae45c`: `credentials` self-comparison이 constant-time 보장을 만든다는 이전 설명을 제거하고 실제 boundary와 Python 문서의 제한을 기록한다.

### 현재 acceptance

현재 branch의 exact head에서 authentication tests, full repository tests, lint/type checks와 security checks가 terminal GREEN이어야 한다. 단순히 서로 다른 길이의 token이 401을 반환한다는 사실은 timing mitigation 증거가 아니다. predecessor head의 checks, source-neutral retrigger, self-approval, scanner suppression, required-gate 완화는 acceptance가 아니다.

### 남은 Gap

현재 bootstrap bearer token은 단일 shared secret이다. 다중 주체·회전·폐기·감사·권한 범위가 필요한 상용 identity 계약은 NewsDOM 내부 source copy나 별도 user store로 확장하지 않고 CWL canonical identity owner인 Keyverse의 released contract를 소비하는 ADR로 분리해야 한다.

## Traceability

Python Software Foundation. (2026). *hmac — Keyed-hashing for message authentication*. Python 3.14.7 documentation. Retrieved September 3, 2026, from https://docs.python.org/3/library/hmac.html
8 changes: 6 additions & 2 deletions src/newsdom_api/config.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 필수 품질 게이트 미검증

현재 환경에 uv와 pytest가 없어 전체 테스트와 100% 분기 커버리지를 실행하지 못했다. CI 결과를 확인해야 한다.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import hashlib
import os
from dataclasses import dataclass, field
from enum import Enum
Expand Down Expand Up @@ -38,6 +39,7 @@
authentication_mode: AuthenticationMode = AuthenticationMode.REQUIRED
runtime_profile: RuntimeProfile = RuntimeProfile.PRODUCTION
api_token: str | None = field(default=None, repr=False)
api_token_digest: bytes | None = field(default=None, init=False, repr=False)

def __post_init__(self) -> None:
"""Normalize secrets once and reject unsafe direct construction."""
Expand All @@ -59,7 +61,8 @@
"The configured parser authentication token must not be blank"
)
try:
bearer_value = f"Bearer {normalized_token}".encode("utf-8")
token_bytes = normalized_token.encode("utf-8")
bearer_value = b"Bearer " + token_bytes
except UnicodeEncodeError as exc:
raise RuntimeConfigurationError(
"The configured parser authentication token must be valid UTF-8"
Expand All @@ -69,14 +72,15 @@
"The configured parser authentication token is too long"
)
object.__setattr__(self, "api_token", normalized_token)
object.__setattr__(self, "api_token_digest", hashlib.sha256(token_bytes).digest())

@property
def authentication_ready(self) -> bool:
"""Return whether the authentication configuration can serve traffic safely."""

return (
self.authentication_mode is AuthenticationMode.DISABLED
or self.api_token is not None
or self.api_token_digest is not None
)


Expand Down
9 changes: 6 additions & 3 deletions src/newsdom_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import hashlib
import hmac
import logging
import tempfile
Expand Down Expand Up @@ -114,8 +115,8 @@ def _parse_access_failure(request: Request) -> JSONResponse | None:
settings = _runtime_settings(request)
if settings.authentication_mode is AuthenticationMode.DISABLED:
return None
token = settings.api_token
if token is None:
expected_digest = settings.api_token_digest
if expected_digest is None:
return JSONResponse(
status_code=503,
content={"detail": SERVICE_UNAVAILABLE_DETAIL},
Expand All @@ -131,7 +132,9 @@ def _parse_access_failure(request: Request) -> JSONResponse | None:
scheme, separator, credentials = provided.partition(b" ")
if separator != b" " or scheme.lower() != b"bearer" or not credentials:
return _unauthorized_response()
if not hmac.compare_digest(credentials, token.encode("utf-8")):

provided_digest = hashlib.sha256(credentials).digest()
if not hmac.compare_digest(provided_digest, expected_digest):
return _unauthorized_response()
return None

Expand Down
13 changes: 13 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,16 @@ def test_config_module_exposes_versioned_environment_contract() -> None:
assert config.API_TOKEN_ENV_VAR == "NEWSDOM_API_TOKEN"
assert config.AUTH_MODE_ENV_VAR == "NEWSDOM_AUTH_MODE"
assert config.RUNTIME_PROFILE_ENV_VAR == "NEWSDOM_RUNTIME_PROFILE"

def test_authentication_constant_time_comparison_differing_lengths():
'''Verify that tokens of different lengths do not leak length information.'''
settings = RuntimeSettings(api_token="valid_token")
app = create_app(settings)
client = TestClient(app)

response = client.post(
"/parse",
headers={"Authorization": "Bearer too_short"},
files={"file": ("test.pdf", b"%PDF-1.4\n", "application/pdf")},
)
assert response.status_code == 401
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +411 to +422

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 추가 테스트의 형식 불일치

test_authentication_constant_time_comparison_differing_lengths는 반환 형식 주석과 테스트 간 빈 줄이 없다. 저장소의 일관된 테스트 형식에 맞춰 정리해야 한다.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

64 changes: 64 additions & 0 deletions tests/test_auth_token_digest_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Regression coverage for fixed-size authentication token comparison."""

from __future__ import annotations

import hashlib
import hmac

from fastapi.testclient import TestClient

import newsdom_api.main as main_module
from newsdom_api.config import RuntimeSettings
from newsdom_api.main import create_app


def test_runtime_settings_precomputes_fixed_size_token_digest() -> None:
"""The configured secret is normalized once outside the request path."""

settings = RuntimeSettings(api_token="configured-token")

assert settings.api_token_digest == hashlib.sha256(b"configured-token").digest()
assert len(settings.api_token_digest or b"") == hashlib.sha256().digest_size


def test_mismatched_token_uses_equal_length_digest_operands(monkeypatch) -> None:
"""Request comparison never invokes compare_digest with secret-dependent lengths."""

original_compare_digest = hmac.compare_digest
operands: list[tuple[bytes, bytes]] = []

def record_compare_digest(left: bytes, right: bytes) -> bool:
operands.append((left, right))
return original_compare_digest(left, right)

monkeypatch.setattr(main_module.hmac, "compare_digest", record_compare_digest)
client = TestClient(create_app(RuntimeSettings(api_token="configured-token")))

response = client.post(
"/parse",
headers={"Authorization": "Bearer x"},
files={"file": ("test.pdf", b"%PDF-1.4\n", "application/pdf")},
)

assert response.status_code == 401
assert len(operands) == 1
left, right = operands[0]
assert len(left) == len(right) == hashlib.sha256().digest_size


def test_valid_token_still_authenticates_after_digest_normalization(monkeypatch) -> None:
"""Fixed-size comparison preserves exact configured-token acceptance."""

monkeypatch.setattr(main_module, "parse_pdf", lambda *args, **kwargs: {"nodes": []})
settings = RuntimeSettings(api_token="configured-token")
client = TestClient(create_app(settings, runtime_readiness_probe=lambda: True))

response = client.post(
"/parse",
headers={"Authorization": "Bearer configured-token"},
files={"file": ("test.pdf", b"not-used", "application/pdf")},
)

# Authentication must not reject the exact token. The intentionally invalid
# PDF may fail later at the media boundary, which is sufficient for this test.
assert response.status_code != 401
Loading