diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23ef6f82a..094517f99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@
### 보안 패치 (CodeQL extended current-head)
+- `nanoid` `5.1.6`의 High DoS 취약점(`CVE-2026-67214`, `GHSA-28wg-ghj8-5hjv`)을 패치 버전 `5.1.16`으로 올리고, package manifest·workspace override·frozen lockfile을 하나의 보안 계약으로 동기화했습니다. 실제 lockfile 회귀 테스트와 APA 7 근거는 `docs/doctoring/nanoid-cve-2026-67214.md`에 기록했습니다.
- `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다.
- CodeQL `extended` 기본 설정이 current `develop`에서 확인한 Critical 8건·High 21건·Medium 1건을 코드 경계에서 제거합니다. 서버 요청은 검증된 loopback/HTTPS origin, 동일 OIDC issuer origin, 허용 API 경로·쿼리만 재구성하고 redirect를 자동 추종하지 않으며, 공개 IPv6 authority를 보존합니다. UI smoke는 고정 Node/Next 실행 파일과 인자, localhost:3001 allowlist, private `mkdtemp` artifact 디렉터리 및 containment 검사만 사용합니다.
- OIDC token endpoint는 운영 환경에서 서버 전용 `OIDC_ALLOWED_HOSTS` 정확 호스트 allowlist를 필수로 적용합니다. hostname의 모든 DNS 결과가 공인 주소인지 검증한 뒤 해당 주소 집합을 native HTTP(S) 연결의 `lookup`에 고정하고, 원래 issuer hostname은 Host/TLS SNI로 유지해 사설 주소 해석과 DNS rebinding 사이의 TOCTOU를 차단합니다. 실패 로그는 입력 URL·token 대신 고정된 configuration/DNS·transport/response/backend-verification reason code만 남깁니다.
diff --git a/backend/api/dav.py b/backend/api/dav.py
index d618c25f3..37efa5777 100644
--- a/backend/api/dav.py
+++ b/backend/api/dav.py
@@ -13,8 +13,11 @@
router = APIRouter(prefix="/dav", tags=["dav"])
+IMPLEMENTED_DAV_METHODS = ("OPTIONS", "PROPFIND")
+
def _normalize_dav_authorization_path(path: str) -> str:
+ """Decode a DAV path within a bounded authorization-normalization loop."""
normalized_path = path.replace("\\", "/")
for _ in range(100):
decoded_path = unquote(normalized_path).replace("\\", "/")
@@ -25,6 +28,7 @@ def _normalize_dav_authorization_path(path: str) -> str:
def _dav_path_owner_user_id(path: str) -> str | None:
+ """Return the owner segment for a traversal-safe DAV path."""
path = _normalize_dav_authorization_path(path)
if any(segment in {".", ".."} for segment in path.split("/")):
return None
@@ -36,6 +40,7 @@ def _dav_path_owner_user_id(path: str) -> str | None:
def _ensure_dav_owner_scope(path: str, auth_context: AuthContext) -> None:
+ """Require the DAV path owner to match the authenticated user."""
owner_user_id = _dav_path_owner_user_id(path)
if owner_user_id is None:
raise HTTPException(
@@ -51,10 +56,12 @@ def _ensure_dav_owner_scope(path: str, auth_context: AuthContext) -> None:
def _dav_path_segments(path: str) -> list[str]:
+ """Return non-empty DAV path segments."""
return [segment for segment in path.strip("/").split("/") if segment]
def _dav_multistatus_xml(responses: list[str]) -> str:
+ """Render one DAV multistatus document from validated response fragments."""
response_xml = "\n".join(responses)
if response_xml:
response_xml = f"\n{response_xml}\n"
@@ -68,6 +75,7 @@ def _dav_response_xml(
display_name: str,
is_collection: bool = True,
) -> str:
+ """Render one escaped DAV response element."""
resourcetype = "" if is_collection else ""
escaped_href = escape_xml_text(href)
escaped_display_name = escape_xml_text(display_name)
@@ -84,6 +92,7 @@ def _dav_response_xml(
def _dav_xml_response(responses: list[str]) -> Response:
+ """Return a DAV 207 multistatus response."""
return Response(
content=_dav_multistatus_xml(responses),
media_type="application/xml",
@@ -92,6 +101,7 @@ def _dav_xml_response(responses: list[str]) -> Response:
def _dav_depth(request: Request) -> str:
+ """Normalize supported DAV Depth values to zero or one."""
depth = request.headers.get("Depth", "1").strip().lower()
if depth == "0":
return "0"
@@ -99,6 +109,7 @@ def _dav_depth(request: Request) -> str:
def _project_folder_response(path_owner_user_id: str, folder: dict) -> str:
+ """Render one project-folder collection response."""
folder_uid = str(folder["folder_uid"])
project_name = str(folder["project_name"])
return _dav_response_xml(
@@ -115,6 +126,7 @@ async def _handle_project_propfind(
auth_context: AuthContext,
db: AsyncSession,
) -> Response:
+ """Return tenant-scoped project collection metadata for PROPFIND."""
segments = _dav_path_segments(path)
if len(segments) < 2 or segments[1] != "projects":
raise HTTPException(status_code=404, detail="DAV collection not found")
@@ -158,73 +170,37 @@ async def _handle_project_propfind(
@router.api_route(
"/{path:path}",
- methods=["PROPFIND", "REPORT", "MKCOL", "GET", "PUT", "DELETE", "OPTIONS"],
+ methods=list(IMPLEMENTED_DAV_METHODS),
)
async def dav_handler(
request: Request,
path: str,
auth_context: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
-):
- """
- Route the authenticated DAV surface that is implemented for this slice.
+) -> Response:
+ """Serve only the authenticated DAV capabilities implemented in production.
- Collection discovery is served from the server-side project registry.
- Provider-backed writeback stays fail-closed until source capability and
- ETag/If-Match enforcement are available through signed writeback intents.
+ ScopeWeave-compatible clients can discover project collections through
+ ``PROPFIND``. Provider-backed mutation verbs are deliberately not registered;
+ the framework returns ``405 Method Not Allowed`` rather than advertising a
+ handler that can only return ``501 Not Implemented``.
"""
_ensure_dav_owner_scope(path, auth_context)
safe_path = repr(path)[1:-1]
logger.info("DAV Request: %s /%s", request.method, safe_path)
if request.method == "OPTIONS":
- headers = {
- "DAV": "1, 2, 3, calendar-access, addressbook",
- "Allow": (
- "OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, "
- "PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT"
- ),
- }
- return Response(status_code=200, headers=headers)
-
- if request.method == "PROPFIND":
- return await _handle_project_propfind(
- request=request,
- path=path,
- auth_context=auth_context,
- db=db,
- )
-
- if request.method == "PUT":
- body = await request.body()
- safe_path = repr(path)[1:-1]
- logger.info("DAV PUT received %s bytes at /%s", len(body), safe_path)
- logger.warning(
- "DAV PUT rejected at /%s: provider-backed DAV writeback is not "
- "implemented; signed writeback-intent API is required",
- safe_path,
- )
return Response(
- content=(
- "Provider-backed DAV writeback is not implemented; use signed "
- "writeback-intent APIs until source, capability, and "
- "ETag/If-Match checks are enforced."
- ),
- media_type="text/plain",
- status_code=501,
+ status_code=200,
+ headers={
+ "DAV": "1",
+ "Allow": ", ".join(IMPLEMENTED_DAV_METHODS),
+ },
)
- logger.warning(
- "DAV %s rejected at /%s: method is not implemented for the "
- "provider-backed DAV gateway",
- request.method,
- safe_path,
- )
- return Response(
- content=(
- "Provider-backed DAV method is not implemented; use supported "
- "PROPFIND/OPTIONS discovery or signed writeback-intent APIs."
- ),
- media_type="text/plain",
- status_code=501,
+ return await _handle_project_propfind(
+ request=request,
+ path=path,
+ auth_context=auth_context,
+ db=db,
)
diff --git a/backend/docs/dav-production-capabilities.md b/backend/docs/dav-production-capabilities.md
new file mode 100644
index 000000000..678ab4cce
--- /dev/null
+++ b/backend/docs/dav-production-capabilities.md
@@ -0,0 +1,48 @@
+# DAV Production Capability Contract
+
+Naruon exposes only DAV operations that have complete tenant authorization,
+persisted data, deterministic tests, and production handlers.
+
+## Supported DAV methods
+
+| Method | Status | Production behavior |
+| --- | --- | --- |
+| `OPTIONS` | Supported | Returns `DAV: 1` and advertises only `OPTIONS, PROPFIND`. |
+| `PROPFIND` | Supported | Returns tenant-scoped project collections from PostgreSQL. |
+
+The router does not register `GET`, `PUT`, `DELETE`, `MKCOL`, `REPORT`,
+`PROPPATCH`, `COPY`, `MOVE`, `LOCK`, or `UNLOCK`. FastAPI therefore returns
+`405 Method Not Allowed` rather than exposing success-shaped or `501` stubs.
+Mutation is available only through Naruon's authenticated writeback-intent
+workflow until each DAV verb has provider capability discovery, precondition
+handling (`ETag`/`If-Match` where applicable), durable audit evidence, and real
+connector execution.
+
+## Data and identity boundary
+
+- DAV paths must contain the authenticated owner user ID.
+- Project folders come from the persisted `project_folder` registry and are
+ scoped by user and organization.
+- Connected WebDAV accounts come from `webdav_account` records; hard-coded demo
+ accounts and folders are forbidden.
+- Runtime code must not return successful attachment synchronization when no
+ provider write occurred.
+- XML values are escaped and request-path control characters are encoded before
+ logging.
+
+## Verification
+
+Every changed DAV capability requires:
+
+1. a fail-first API regression;
+2. tenant and owner authorization tests;
+3. positive provider or database integration evidence;
+4. negative tests for unsupported methods and stale preconditions;
+5. production statement and branch coverage of 100%; and
+6. exact-head security and required checks before merge.
+
+## Primary standard — APA 7th
+
+Dusseault, L. (2007). *HTTP extensions for Web Distributed Authoring and
+Versioning (WebDAV)* (RFC 4918). Internet Engineering Task Force.
+https://doi.org/10.17487/RFC4918
diff --git a/backend/services/webdav_service.py b/backend/services/webdav_service.py
index 284bd1696..a60d1b6e7 100644
--- a/backend/services/webdav_service.py
+++ b/backend/services/webdav_service.py
@@ -1,4 +1,3 @@
-import logging
from typing import Any, Dict, List
from sqlalchemy import select
@@ -7,103 +6,16 @@
from db.models import Email, ProjectFolder, TicketTask, WebdavAccount
from services.knowledge_extractor import SELF_SENT_KNOWLEDGE_SOURCE
-logger = logging.getLogger(__name__)
-
def safe_webdav_source_label(source_id: str | None) -> str:
+ """Return an operator-safe label that exposes only the opaque source ID."""
if not source_id:
return "WebDAV source"
return f"WebDAV source {source_id}"
-async def sync_webdav_folders(session, user_id: str, organization_id: str | None):
- """
- Fetch folder structures for all WebDAV accounts of the user.
- """
- from urllib.parse import urlsplit
-
- from core.url_validation import _reject_unsafe_ip_literal
-
- logger.info(f"Syncing WebDAV folders for user {user_id}")
- organization_filter = (
- WebdavAccount.organization_id == organization_id
- if organization_id is not None
- else WebdavAccount.organization_id.is_(None)
- )
- stmt = select(WebdavAccount.server_url, WebdavAccount.source_uid).where(
- WebdavAccount.user_id == user_id, organization_filter
- )
- res = await session.execute(stmt)
- accounts = res.all()
- for server_url, source_uid in accounts:
- try:
- if server_url:
- parsed = urlsplit(server_url)
- if parsed.scheme != "https":
- raise ValueError("WebDAV server_url must use HTTPS")
- if not parsed.hostname:
- raise ValueError("WebDAV server_url must include a hostname")
- _reject_unsafe_ip_literal("WebDAV server_url", parsed.hostname)
- except ValueError as exc:
- logger.warning(
- "Invalid WebDAV server URL for source %s: %s",
- source_uid or "unknown",
- exc,
- )
- continue
-
- logger.info(
- "Fetched folder structures for WebDAV source %s",
- source_uid or "unknown",
- )
- return True
-
-
class WebDavService:
- def __init__(self):
- self._mock_accounts = {
- "demo_user": [
- {
- "source_id": "webdav_src_demo_primary",
- "server_url": "https://webdav.naruon.net",
- "username": "demo_user",
- "display_label": "WebDAV source webdav_src_demo_primary",
- "writeback_enabled": True,
- "etag": "etag-webdav-demo-primary",
- }
- ]
- }
- self._mock_folders = {
- "demo_user": [
- {
- "folder_uid": "webdav_folder_demo_roadmap",
- "project_name": "Naruon Roadmap 2026",
- "webdav_path": "/Projects/Naruon_Roadmap_2026",
- "owner_user_id": "demo_user",
- "organization_id": None,
- },
- {
- "folder_uid": "webdav_folder_demo_marketing",
- "project_name": "Marketing Assets",
- "webdav_path": "/Projects/Marketing_Assets",
- "owner_user_id": "demo_user",
- "organization_id": None,
- },
- ]
- }
-
- def get_connected_accounts(self, user_id: str) -> List[Dict[str, Any]]:
- """
- Fetch connected WebDAV accounts for a user.
- In a real implementation, this queries the database.
- """
- return self._mock_accounts.get(user_id, [])
-
- def get_project_folders(self, user_id: str) -> List[Dict[str, Any]]:
- """
- Fetch the list of project folders structured by AI.
- """
- return self._mock_folders.get(user_id, [])
+ """Resolve tenant-scoped WebDAV discovery and signed writeback intents."""
async def get_connected_accounts_from_db(
self,
@@ -112,6 +24,7 @@ async def get_connected_accounts_from_db(
organization_id: str | None = None,
workspace_id: str | None = None,
) -> List[Dict[str, Any]]:
+ """Return database-authoritative WebDAV account capabilities for a scope."""
scope_filters = [
WebdavAccount.user_id == user_id,
WebdavAccount.organization_id == organization_id
@@ -143,6 +56,7 @@ async def get_project_folders_from_db(
organization_id: str | None,
folder_uid: str | None = None,
) -> List[Dict[str, Any]]:
+ """Return tenant-scoped project folders from the persisted registry."""
stmt = select(ProjectFolder).where(
ProjectFolder.user_id == user_id,
ProjectFolder.organization_id == organization_id
@@ -163,28 +77,6 @@ async def get_project_folders_from_db(
for folder in result.scalars().all()
]
- def sync_attachments_to_folder(self, email_id: str, project_name: str) -> bool:
- """
- Organizes an email's attachments into the specified WebDAV project folder.
- """
- logger.info(
- f"Syncing attachments from email {email_id} to project {project_name}"
- )
- # Mock implementation: in reality, this would download from storage and upload via webdavclient3
- return True
-
- def determine_webdav_writeback_intent(
- self, user_id: str, target_source_id: str | None = None
- ) -> Dict[str, Any]:
- """
- Server-authoritative WebDAV writeback source selection.
- """
- accounts = self.get_connected_accounts(user_id)
- return self.determine_webdav_writeback_intent_from_accounts(
- accounts,
- target_source_id=target_source_id,
- )
-
async def determine_webdav_writeback_intent_from_db(
self,
session: AsyncSession,
@@ -193,6 +85,7 @@ async def determine_webdav_writeback_intent_from_db(
workspace_id: str | None = None,
target_source_id: str | None = None,
) -> Dict[str, Any]:
+ """Select a writable persisted account without executing provider writes."""
accounts = await self.get_connected_accounts_from_db(
session, user_id, organization_id, workspace_id
)
@@ -210,6 +103,7 @@ async def determine_knowledge_materialization_intent_from_db(
source_task_id: str,
target_source_id: str | None = None,
) -> Dict[str, Any]:
+ """Create a provenance-bound intent for materializing self-sent knowledge."""
task_result = await session.execute(
select(TicketTask, Email.message_id)
.outerjoin(
@@ -278,6 +172,7 @@ def determine_webdav_writeback_intent_from_accounts(
accounts: List[Dict[str, Any]],
target_source_id: str | None = None,
) -> Dict[str, Any]:
+ """Select one write-enabled account from a server-authoritative inventory."""
writable_accounts = {
account["source_id"]: account
for account in accounts
diff --git a/backend/tests/test_dav_api.py b/backend/tests/test_dav_api.py
index 70d455ea9..050e1d6c0 100644
--- a/backend/tests/test_dav_api.py
+++ b/backend/tests/test_dav_api.py
@@ -1,6 +1,9 @@
-import defusedxml.ElementTree as ET
+import asyncio
+import logging
+import defusedxml.ElementTree as ET
import pytest
+from fastapi import Request
from fastapi.testclient import TestClient
from main import app
@@ -61,11 +64,19 @@ def test_dav_route_uses_signed_session_dependency():
assert response.status_code == 401
-def test_dav_options(dev_auth_dependency_overrides):
+def test_dav_options_advertises_only_implemented_capabilities(
+ dev_auth_dependency_overrides,
+):
with TestClient(app) as client:
response = client.options("/dav/user123/projects/", headers=AUTH_HEADERS)
- assert response.status_code == 200
- assert "calendar-access" in response.headers.get("DAV", "")
+
+ assert response.status_code == 200
+ assert response.headers["DAV"] == "1"
+ assert {
+ method.strip()
+ for method in response.headers["Allow"].split(",")
+ if method.strip()
+ } == {"OPTIONS", "PROPFIND"}
def test_dav_rejects_different_user_path(dev_auth_dependency_overrides):
@@ -119,58 +130,35 @@ def test_dav_propfind_escapes_path_values(
ET.fromstring(response.text)
-def test_dav_put(dev_auth_dependency_overrides, caplog):
- import logging
-
- caplog.set_level(logging.WARNING, logger="api.dav")
+@pytest.mark.parametrize(
+ "method",
+ ["GET", "PUT", "DELETE", "MKCOL", "REPORT", "PROPPATCH", "COPY", "MOVE", "LOCK", "UNLOCK"],
+)
+def test_dav_unimplemented_methods_are_not_registered(
+ dev_auth_dependency_overrides,
+ method,
+):
with TestClient(app) as client:
- response = client.put(
+ response = client.request(
+ method,
"/dav/user123/projects/file.ics",
- content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
+ content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR" if method == "PUT" else None,
headers=AUTH_HEADERS,
)
- assert response.status_code == 501
- assert "Provider-backed DAV writeback is not implemented" in response.text
- assert "etag" not in {header.lower() for header in response.headers}
- assert any(
- "provider-backed DAV writeback is not implemented" in record.getMessage()
- for record in caplog.records
- )
-
-
-def test_dav_unsupported_method_logs_reason(dev_auth_dependency_overrides, caplog):
- import logging
- caplog.set_level(logging.WARNING, logger="api.dav")
- with TestClient(app) as client:
- response = client.delete(
- "/dav/user123/projects/file.ics",
- headers=AUTH_HEADERS,
- )
+ assert response.status_code == 405
+ assert "etag" not in {header.lower() for header in response.headers}
+ assert {
+ allowed.strip()
+ for allowed in response.headers["Allow"].split(",")
+ if allowed.strip()
+ } == {"OPTIONS", "PROPFIND"}
- assert response.status_code == 501
- assert "Provider-backed DAV method is not implemented" in response.text
- assert any(
- "method is not implemented for the provider-backed DAV gateway"
- in record.getMessage()
- for record in caplog.records
- )
def test_dav_log_injection_prevention(dev_auth_dependency_overrides, caplog):
- """
- Test that DAV handlers safely encode control characters in the requested path,
- preventing log injection vulnerabilities.
- """
- import logging
-
+ """DAV request logs encode control characters rather than emitting them."""
caplog.set_level(logging.INFO)
malicious_path = "user123/projects/test\x1b[31minjected\n\r"
-
- # Since HTTP clients block raw control chars and starlette unquotes but might reject it before reaching our route,
- # we test the handler directly to ensure the logger is using repr().
- import asyncio
- from fastapi import Request
-
scope = {
"type": "http",
"method": "OPTIONS",
@@ -180,24 +168,29 @@ def test_dav_log_injection_prevention(dev_auth_dependency_overrides, caplog):
async def run_handler():
req = Request(scope)
from api.auth import AuthContext
- auth_ctx = AuthContext(user_id="user123", organization_id="org1", role="user", group_ids=[], workspace_id="ws1")
-
from api.dav import dav_handler
+
+ auth_ctx = AuthContext(
+ user_id="user123",
+ organization_id="org1",
+ role="user",
+ group_ids=[],
+ workspace_id="ws1",
+ )
await dav_handler(request=req, path=malicious_path, auth_context=auth_ctx)
asyncio.run(run_handler())
- # In some fastapi versions, returning an unexpected path might return 404. Let's just assert the log was captured.
- # The vulnerability is about the logger.
-
- # Assert that the raw ansi escape / newline was not logged, but encoded
raw_ansi = "\x1b[31m"
found_in_logs = False
for record in caplog.records:
if "DAV Request" in record.message:
assert raw_ansi not in record.message, "Raw ANSI escape sequence found in logs!"
assert "\n" not in record.message[12:], "Raw newline found in log message body!"
- assert "\\x1b[31minjected\\n\\r" in record.message or "\\x1b[31minjected\\r\\n" in record.message, "Escaped characters missing from log message!"
+ assert (
+ "\\x1b[31minjected\\n\\r" in record.message
+ or "\\x1b[31minjected\\r\\n" in record.message
+ ), "Escaped characters missing from log message!"
found_in_logs = True
assert found_in_logs, "DAV Request log was not found"
diff --git a/backend/tests/test_dav_sync.py b/backend/tests/test_dav_sync.py
index 8f187eaf3..2b5fe7428 100644
--- a/backend/tests/test_dav_sync.py
+++ b/backend/tests/test_dav_sync.py
@@ -1,5 +1,6 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
import pytest
-from unittest.mock import AsyncMock, patch, MagicMock
def test_webdav_source_label_uses_opaque_source_id():
@@ -15,19 +16,18 @@ def test_webdav_source_label_uses_opaque_source_id():
@pytest.mark.asyncio
async def test_caldav_event_parsing_and_sync():
from services.caldav_service import sync_caldav_accounts
-
+
session_mock = AsyncMock()
- # Mock finding accounts
account_mock = MagicMock()
account_mock.id = 1
account_mock.server_url = "https://alice:secret@caldav.example.com/calendars"
account_mock.username = "user"
account_mock.credentials_encrypted = "pass"
-
+
execute_res = MagicMock()
execute_res.scalars.return_value.all.return_value = [account_mock]
session_mock.execute.return_value = execute_res
-
+
with patch("services.caldav_service.logger") as logger_mock:
synced = await sync_caldav_accounts(session_mock, "user_1")
@@ -39,102 +39,24 @@ async def test_caldav_event_parsing_and_sync():
assert logged_url == "https://caldav.example.com/calendars"
assert "secret" not in logged_url
-@pytest.mark.asyncio
-async def test_webdav_file_listing_and_sync():
- from services.webdav_service import sync_webdav_folders
-
- session_mock = AsyncMock()
- account_mock = MagicMock()
- account_mock.source_uid = "webdav_src_primary"
- account_mock.server_url = "https://webdav.example.com"
-
- execute_res = MagicMock()
- execute_res.scalars.return_value.all.return_value = [account_mock]
- execute_res.all.return_value = [('https://webdav.example.com', 'webdav_src_primary')]
- session_mock.execute.return_value = execute_res
-
- with patch("services.webdav_service.logger") as logger_mock:
- await sync_webdav_folders(session_mock, "user_1", "org_1")
- logger_mock.info.assert_any_call(
- "Fetched folder structures for WebDAV source %s",
- "webdav_src_primary",
- )
- logged_args = " ".join(
- str(arg)
- for call in logger_mock.info.call_args_list
- for arg in call.args
- )
- assert "https://webdav.example.com" not in logged_args
-
- statement_text = str(session_mock.execute.call_args.args[0])
- assert "webdav_accounts.user_id" in statement_text
- assert "webdav_accounts.organization_id" in statement_text
-
-
-@pytest.mark.asyncio
-async def test_webdav_sync_skips_hostless_https_url():
- from services.webdav_service import sync_webdav_folders
-
- session_mock = AsyncMock()
- account_mock = MagicMock()
- account_mock.source_uid = "webdav_src_primary"
- account_mock.server_url = "https:///missing-host"
-
- execute_res = MagicMock()
- execute_res.scalars.return_value.all.return_value = [account_mock]
- execute_res.all.return_value = [('https:///missing-host', 'webdav_src_primary')]
- session_mock.execute.return_value = execute_res
- with patch("services.webdav_service.logger") as logger_mock:
- await sync_webdav_folders(session_mock, "user_1", "org_1")
+def test_webdav_service_has_no_demo_backing_store_or_noop_write_api():
+ from services.webdav_service import WebDavService
- logger_mock.warning.assert_called_once()
- success_calls = [
- call.args
- for call in logger_mock.info.call_args_list
- if call.args[:1]
- == ("Fetched folder structures for WebDAV source %s",)
- ]
- assert success_calls == []
-
- statement_text = str(session_mock.execute.call_args.args[0])
- assert "webdav_accounts.user_id" in statement_text
- assert "webdav_accounts.organization_id" in statement_text
+ service = WebDavService()
+ forbidden_attributes = {
+ "_mock_accounts",
+ "_mock_folders",
+ "get_connected_accounts",
+ "get_project_folders",
+ "sync_attachments_to_folder",
+ "determine_webdav_writeback_intent",
+ }
-@pytest.mark.asyncio
-async def test_webdav_sync_skips_non_https_url():
- """
- Test that sync skips a WebDAV account if the URL is not HTTPS.
- """
- from services.webdav_service import sync_webdav_folders
+ assert forbidden_attributes.isdisjoint(dir(service))
- session_mock = AsyncMock()
- account_mock = MagicMock()
- account_mock.source_uid = "webdav_src_primary"
- account_mock.server_url = "http://webdav.example.com"
- execute_res = MagicMock()
- execute_res.scalars.return_value.all.return_value = [account_mock]
- execute_res.all.return_value = [('http://webdav.example.com', 'webdav_src_primary')]
- session_mock.execute.return_value = execute_res
+def test_webdav_runtime_module_has_no_success_shaped_fake_sync():
+ from services import webdav_service as module
- with patch("services.webdav_service.logger") as logger_mock:
- await sync_webdav_folders(session_mock, "user_1", "org_1")
-
- logger_mock.warning.assert_called_once()
- warning_args = logger_mock.warning.call_args.args
- assert "Invalid WebDAV server URL" in warning_args[0]
- assert isinstance(warning_args[2], ValueError)
- assert str(warning_args[2]) == "WebDAV server_url must use HTTPS"
-
- success_calls = [
- call.args
- for call in logger_mock.info.call_args_list
- if call.args[:1]
- == ("Fetched folder structures for WebDAV source %s",)
- ]
- assert success_calls == []
-
- statement_text = str(session_mock.execute.call_args.args[0])
- assert "webdav_accounts.user_id" in statement_text
- assert "webdav_accounts.organization_id" in statement_text
+ assert not hasattr(module, "sync_webdav_folders")
diff --git a/backend/tests/test_frontend_nanoid_security.py b/backend/tests/test_frontend_nanoid_security.py
index f80b23a01..c465ed802 100644
--- a/backend/tests/test_frontend_nanoid_security.py
+++ b/backend/tests/test_frontend_nanoid_security.py
@@ -1,4 +1,4 @@
-"""Fail closed when the frontend lock resolves the vulnerable Nano ID release."""
+"""Fail closed when the frontend lock resolves a vulnerable Nano ID release."""
from __future__ import annotations
@@ -8,21 +8,21 @@
REPO_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_LOCK = REPO_ROOT / "frontend" / "pnpm-lock.yaml"
-PATCHED_NANOID_VERSION = "3.3.18"
+PATCHED_NANOID_VERSION = "5.1.16"
-def test_frontend_lock_resolves_only_patched_nanoid_3x() -> None:
- """Require PostCSS's Nano ID dependency to resolve to the reviewed patched 3.x release."""
+def test_frontend_lock_resolves_only_patched_nanoid() -> None:
+ """Require PostCSS's Nano ID dependency to resolve to the reviewed patched pin."""
lock = yaml.safe_load(FRONTEND_LOCK.read_text(encoding="utf-8"))
for section_name in ("packages", "snapshots"):
section = lock[section_name]
- nanoid_3x = sorted(
+ nanoid_keys = sorted(
package_key
for package_key in section
- if package_key.startswith("nanoid@3.")
+ if package_key.startswith("nanoid@")
)
- assert nanoid_3x == [f"nanoid@{PATCHED_NANOID_VERSION}"]
+ assert nanoid_keys == [f"nanoid@{PATCHED_NANOID_VERSION}"]
postcss_snapshot = lock["snapshots"]["postcss@8.5.24"]
assert postcss_snapshot["dependencies"]["nanoid"] == PATCHED_NANOID_VERSION
diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py
index a23c70746..5634f85dc 100644
--- a/backend/tests/test_release_governance.py
+++ b/backend/tests/test_release_governance.py
@@ -227,6 +227,96 @@ def test_strix_ci_requirements_use_security_quality_clean_pins() -> None:
assert "python-multipart==0.0.32" in strix_ci_requirements
+def test_cryptography_runtime_pins_are_bleichenbacher_oracle_fixed() -> None:
+ """Require every governed Python surface to use the first oracle-safe release."""
+ backend_requirements = read_repo_text("backend/requirements.txt")
+ backend_project_text = read_repo_text("backend/pyproject.toml")
+ backend_project = tomllib.loads(backend_project_text)
+ backend_lock = tomllib.loads(read_repo_text("backend/uv.lock"))
+ backend_hashes = read_repo_text("backend/requirements-hashes.txt")
+ strix_requirements = read_repo_text("requirements-strix-ci.txt")
+ strix_hashes = read_repo_text("requirements-strix-ci-hashes.txt")
+
+ def pins(text: str, package: str) -> list[str]:
+ return re.findall(rf"(?m)^{re.escape(package)}==[^\s\\]+", text)
+
+ for governed_text in (
+ backend_requirements,
+ backend_hashes,
+ strix_requirements,
+ strix_hashes,
+ ):
+ assert pins(governed_text, "cryptography") == ["cryptography==50.0.0"]
+ assert [
+ dependency
+ for dependency in backend_project["project"]["dependencies"]
+ if dependency.startswith("cryptography")
+ ] == ["cryptography==50.0.0"]
+ cryptography_versions = {
+ package["version"]
+ for package in backend_lock["package"]
+ if package["name"] == "cryptography"
+ }
+ assert cryptography_versions == {"50.0.0"}
+ assert pins(strix_requirements, "protobuf") == ["protobuf==6.33.6"]
+ assert pins(strix_hashes, "protobuf") == ["protobuf==6.33.6"]
+
+
+def test_frontend_postcss_lock_is_cve_2026_69153_fixed() -> None:
+ """Keep every manifest and lock surface on the first currently governed fix."""
+ frontend_package = json.loads(read_repo_text("frontend/package.json"))
+ frontend_workspace = yaml.safe_load(read_repo_text("frontend/pnpm-workspace.yaml"))
+ frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml"))
+
+ assert frontend_package["devDependencies"]["postcss"] == "8.5.24"
+ assert frontend_package["overrides"]["postcss"] == "8.5.24"
+ assert frontend_package["resolutions"]["postcss"] == "8.5.24"
+ assert frontend_workspace["overrides"]["postcss"] == "8.5.24"
+ assert frontend_lock["overrides"]["postcss"] == "8.5.24"
+ assert frontend_lock["importers"]["."]["devDependencies"]["postcss"] == {
+ "specifier": "8.5.24",
+ "version": "8.5.24",
+ }
+
+ for section in ("packages", "snapshots"):
+ postcss_keys = [
+ package
+ for package in frontend_lock[section]
+ if package.startswith("postcss@")
+ ]
+ assert postcss_keys == ["postcss@8.5.24"]
+
+
+def test_frontend_tooling_lock_uses_current_audit_fixed_transitive_versions() -> None:
+ """Keep newly disclosed audit fixes aligned across manifest and pnpm lock."""
+ frontend_package = json.loads(read_repo_text("frontend/package.json"))
+ frontend_workspace = yaml.safe_load(read_repo_text("frontend/pnpm-workspace.yaml"))
+ frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml"))
+
+ assert frontend_package["devDependencies"]["jsdom"] == "^30.0.1"
+ for dependency, expected_version in (
+ ("brace-expansion", "5.0.9"),
+ ("nanoid", "5.1.16"),
+ ("undici", "8.9.0"),
+ ):
+ assert frontend_package["overrides"][dependency] == expected_version
+ assert frontend_package["resolutions"][dependency] == expected_version
+ assert frontend_workspace["overrides"][dependency] == expected_version
+ assert frontend_lock["overrides"][dependency] == expected_version
+
+ for section in ("packages", "snapshots"):
+ locked_keys = [
+ package
+ for package in frontend_lock[section]
+ if package.startswith(f"{dependency}@")
+ ]
+ assert locked_keys == [f"{dependency}@{expected_version}"]
+
+ assert [
+ package for package in frontend_lock["packages"] if package.startswith("jsdom@")
+ ] == ["jsdom@30.0.1"]
+
+
def test_changelog_follows_keep_a_changelog_for_initial_korean_release() -> None:
changelog = read_repo_text("CHANGELOG.md")
@@ -273,6 +363,26 @@ def test_github_actions_are_pinned_to_exact_sha() -> None:
assert missing_version_comments == [], "\n".join(missing_version_comments)
+def test_github_workflows_never_push_repository_source_branches() -> None:
+ """Keep branch publication in reviewed clients, not ephemeral Actions jobs."""
+ governed_workflows = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted(
+ WORKFLOW_DIR.glob("*.yaml")
+ )
+ source_publishers: list[str] = []
+
+ for workflow_path in governed_workflows:
+ workflow = workflow_path.read_text(encoding="utf-8")
+ if "contents: write" in workflow and "git push" in workflow:
+ source_publishers.append(
+ workflow_path.relative_to(REPO_ROOT).as_posix()
+ )
+
+ assert source_publishers == [], (
+ "GitHub workflows must not commit or push repository source branches: "
+ + ", ".join(source_publishers)
+ )
+
+
def test_github_workflows_do_not_define_duplicate_top_level_keys() -> None:
assert WORKFLOW_DIR.exists(), (
"required governance artifact is missing: .github/workflows"
diff --git a/backend/tests/test_workflow_source_publication_governance.py b/backend/tests/test_workflow_source_publication_governance.py
new file mode 100644
index 000000000..2dc84faf8
--- /dev/null
+++ b/backend/tests/test_workflow_source_publication_governance.py
@@ -0,0 +1,195 @@
+"""Semantic regression tests that keep Actions from publishing source refs."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
+SOURCE_REF_ACTIONS = {
+ "ad-m/github-push-action",
+ "endbug/add-and-commit",
+ "peter-evans/create-pull-request",
+ "stefanzweifel/git-auto-commit-action",
+}
+
+
+def _effective_contents_permission(
+ workflow: dict[str, Any], job: dict[str, Any]
+) -> str:
+ """Return the effective GitHub token contents permission for one job."""
+ permissions = job.get("permissions", workflow.get("permissions"))
+ if permissions == "write-all":
+ return "write"
+ if permissions == "read-all":
+ return "read"
+ if not isinstance(permissions, dict):
+ return "implicit"
+ return str(permissions.get("contents", "none")).lower()
+
+
+def _step_publishes_source_ref(step: dict[str, Any]) -> bool:
+ """Detect commands or actions that create or update repository source refs."""
+ run = str(step.get("run", ""))
+ run_lower = run.lower()
+ if re.search(r"\bgit\s+push\b", run_lower):
+ return True
+
+ refs_api = re.search(
+ r"(?:api\.github\.com|\bgh\s+api\b).*?/git/refs",
+ run_lower,
+ re.DOTALL,
+ )
+ mutating_method = re.search(
+ r"(?:-x|--request|--method)\s+(?:post|patch)\b", run_lower
+ )
+ if refs_api and mutating_method:
+ return True
+
+ action = str(step.get("uses", "")).split("@", 1)[0].lower()
+ if action in SOURCE_REF_ACTIONS:
+ return True
+
+ with_values = step.get("with")
+ script = (
+ str(with_values.get("script", ""))
+ if isinstance(with_values, dict)
+ else ""
+ )
+ normalized_script = re.sub(r"\s+", "", script).lower()
+ return any(
+ token in normalized_script
+ for token in (
+ ".rest.git.createref(",
+ ".rest.git.updateref(",
+ "github.git.createref(",
+ "github.git.updateref(",
+ )
+ )
+
+
+def _source_publishing_jobs(workflow: dict[str, Any]) -> Iterator[tuple[str, str]]:
+ """Yield jobs that can publish repository source refs and their permissions."""
+ jobs = workflow.get("jobs")
+ if not isinstance(jobs, dict):
+ return
+ for job_name, raw_job in jobs.items():
+ if not isinstance(raw_job, dict):
+ continue
+ permission = _effective_contents_permission(workflow, raw_job)
+ steps = raw_job.get("steps", [])
+ if not isinstance(steps, list):
+ continue
+ if any(
+ isinstance(step, dict) and _step_publishes_source_ref(step)
+ for step in steps
+ ):
+ yield str(job_name), permission
+
+
+def _load_workflow(text: str) -> dict[str, Any]:
+ """Parse one synthetic workflow and assert its mapping shape."""
+ workflow = yaml.safe_load(text)
+ assert isinstance(workflow, dict)
+ return workflow
+
+
+def test_repository_workflows_do_not_publish_source_refs() -> None:
+ """Reject source-ref publishers while allowing package-only publication."""
+ offenders: list[str] = []
+ governed_workflows = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted(
+ WORKFLOW_DIR.glob("*.yaml")
+ )
+
+ for workflow_path in governed_workflows:
+ workflow = _load_workflow(workflow_path.read_text(encoding="utf-8"))
+ for job_name, permission in _source_publishing_jobs(workflow):
+ offenders.append(
+ f"{workflow_path.relative_to(REPO_ROOT).as_posix()}:{job_name}"
+ f" (contents={permission})"
+ )
+
+ assert offenders == [], (
+ "GitHub workflows must not create or update repository source refs: "
+ + ", ".join(offenders)
+ )
+
+
+@pytest.mark.parametrize(
+ ("workflow_text", "expected"),
+ [
+ (
+ """permissions: write-all
+jobs:
+ publish:
+ steps:
+ - run: curl -X POST https://api.github.com/repos/o/r/git/refs
+""",
+ [("publish", "write")],
+ ),
+ (
+ """permissions:
+ contents: read
+jobs:
+ publish:
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/github-script@0123456789012345678901234567890123456789
+ with:
+ script: |
+ github.rest.git.createRef({
+ owner: 'o', repo: 'r', ref: 'refs/heads/x', sha: 'a'
+ })
+""",
+ [("publish", "write")],
+ ),
+ (
+ """permissions:
+ contents: write
+jobs:
+ publish:
+ steps:
+ - run: git push origin HEAD:refs/heads/generated
+""",
+ [("publish", "write")],
+ ),
+ (
+ """permissions:
+ contents: write
+jobs:
+ publish:
+ steps:
+ - run: |
+ curl --request PATCH \\
+ https://api.github.com/repos/o/r/git/refs/heads/generated
+""",
+ [("publish", "write")],
+ ),
+ (
+ """permissions:
+ contents: read
+ packages: write
+jobs:
+ package:
+ steps:
+ - uses: docker/build-push-action@0123456789012345678901234567890123456789
+ with:
+ push: true
+""",
+ [],
+ ),
+ ],
+)
+def test_source_publication_detector_covers_permission_and_operation_forms(
+ workflow_text: str,
+ expected: list[tuple[str, str]],
+) -> None:
+ """Prove semantic detection across workflow/job permissions and ref writers."""
+ assert list(_source_publishing_jobs(_load_workflow(workflow_text))) == expected
diff --git a/docs/doctoring/nanoid-cve-2026-67214.md b/docs/doctoring/nanoid-cve-2026-67214.md
new file mode 100644
index 000000000..b013ac553
--- /dev/null
+++ b/docs/doctoring/nanoid-cve-2026-67214.md
@@ -0,0 +1,95 @@
+# Dependency Security Doctoring: `nanoid` CVE-2026-67214
+
+## Finding and evidence
+
+`ContextualWisdomLab/naruon#1296` was evaluated at head
+`00bb2d6bd80faee9e0ff5fec6c558003207f7bf3`. The Security Scan and Dependency
+Review workflows rejected `frontend/pnpm-lock.yaml` because the resolved
+`nanoid` version was `5.1.6`. The relevant workflow runs were
+[Security Scan run 31315927356](https://github.com/ContextualWisdomLab/naruon/actions/runs/31315927356)
+and
+[Dependency Review run 31315927337](https://github.com/ContextualWisdomLab/naruon/actions/runs/31315927337).
+
+The GitHub Advisory Database classifies `GHSA-28wg-ghj8-5hjv` / `CVE-2026-67214`
+as High severity. Its reviewed package ranges are `< 3.3.16` and `>= 4.0.0,
+< 5.1.16`, with first patched versions `3.3.16` and `5.1.16`, respectively.
+The non-secure module can loop indefinitely when an attacker-controlled
+negative size reaches `customAlphabet` or `nanoid`, causing denial of service.
+The application therefore treats the advisory as a dependency-closure defect
+even when the affected transitive call path is not a product-facing API.
+
+## Resolution contract
+
+The current graph selects the patched 5.x line in every authority that can
+influence the frontend dependency graph:
+
+1. `frontend/package.json` `overrides.nanoid` and `resolutions.nanoid` are
+ `5.1.16`.
+2. `frontend/pnpm-workspace.yaml` `overrides.nanoid` is `5.1.16`.
+3. `frontend/pnpm-lock.yaml` records the `5.1.16` package resolution,
+ integrity, snapshot, and the `postcss` dependency edge.
+
+The lockfile remains frozen-installable, and the fix does not bypass or weaken
+Dependency Review, OSV, Trivy, or the repository's release-age policy.
+
+## Regression test
+
+`frontend/src/lib/dependency-security.test.ts` reads the checked-in manifest,
+workspace configuration, and lockfile. It keeps the current graph pinned to
+`5.1.16`, parses the `packages` and `snapshots` sections independently, and
+rejects exactly the reviewed affected ranges (`< 3.3.16` and `>= 4.0.0,
+< 5.1.16`). Boundary cases prove that `3.3.16` and `5.1.16` are accepted while
+`3.3.15`, `4.0.0`, and `5.1.15` are rejected. This protects the actual
+supply-chain artifacts without incorrectly rejecting the patched 3.x line.
+
+## Verification
+
+The following evidence was obtained after the dependency change:
+
+- `pnpm install --frozen-lockfile --ignore-scripts` — completed successfully.
+- `pnpm exec vitest run src/lib/dependency-security.test.ts src/lib/csp-headers.test.ts src/lib/backend-url.test.ts --reporter=dot` — 3 files and 20 tests passed on the predecessor test shape.
+- `pnpm exec vitest run --reporter=dot` — 50 files and 428 tests passed on the predecessor test shape.
+- `pnpm exec eslint src/lib/dependency-security.test.ts` — passed on the predecessor test shape.
+- `pnpm run lint` — passed.
+- `pnpm run typecheck` — passed.
+- Historical diagnostic only: `uv run --project backend --frozen --offline pytest -q` on CPython 3.14.4 completed with 1,701 passed, 33 skipped, and ten warnings. Because warnings were present, that run is **not** accepted as backend-contract evidence.
+- Required current-head backend verification is `PYTHONWARNINGS=error DISABLE_BACKGROUND_WORKERS=1 uv run --project backend --frozen --offline pytest -q`. It may be recorded as passing only after it exits successfully with no `Timeout`, `Fatal`, `Warn`, or `Denied` output; current-head CI remains authoritative until such evidence exists.
+- `uv run --project backend --frozen --offline ruff check backend/tests/test_release_governance.py` — passed on the predecessor governance-test shape.
+- Local Trivy filesystem scan of `frontend/pnpm-lock.yaml` at HIGH/CRITICAL severity — 0 findings.
+- `git diff --check` — passed on the predecessor change set.
+
+The direct local OSV lockfile scan still reports `js-yaml@4.3.0`, which is
+inherited from the base dependency graph and is outside this PR's introduced
+`nanoid` change. The central OSV workflow compares base and head results, so
+the merge decision must use its new current-head report rather than treating a
+whole-lock local inventory as a PR-introduced finding.
+
+The GitHub workflows must be re-queried against each newly pushed HEAD before
+the pull request is considered mergeable. A green result from an older head is
+not accepted as evidence for a newer change.
+
+## Rollback criteria
+
+Do not roll back into either reviewed affected range (`< 3.3.16` or `>= 4.0.0,
+< 5.1.16`). A future version change may replace the current `5.1.16` pin only
+after the advisory database confirms the candidate is outside affected ranges,
+the three resolution authorities and lockfile are regenerated together, the
+regression test is updated if the advisory changes, and current-head Security
+Scan and Dependency Review results are green.
+
+## References (APA 7)
+
+GitHub, Inc. (2026). *Nanoid: Non-secure generators can loop indefinitely with
+negative size (CVE-2026-67214; GHSA-28wg-ghj8-5hjv).* GitHub Advisory Database.
+https://github.com/advisories/GHSA-28wg-ghj8-5hjv
+
+GitHub, Inc. (2026). *Release 5.1.16.* GitHub. https://github.com/ai/nanoid/releases/tag/5.1.16
+
+National Institute of Standards and Technology. (2026). *CVE-2026-67214
+detail.* National Vulnerability Database. https://nvd.nist.gov/vuln/detail/CVE-2026-67214
+
+MITRE. (n.d.). *CWE-835: Loop with unreachable exit condition ('Infinite
+Loop').* https://cwe.mitre.org/data/definitions/835.html
+
+pnpm. (n.d.). *ERR_PNPM_OUTDATED_LOCKFILE.* pnpm documentation.
+https://pnpm.io/errors#err_pnpm_outdated-lockfile
diff --git a/frontend/package.json b/frontend/package.json
index 191b7c90b..d60b704a3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -48,12 +48,14 @@
},
"overrides": {
"brace-expansion": "5.0.9",
+ "nanoid": "5.1.16",
"postcss": "8.5.24",
"undici": "8.9.0",
"uuid": "^14.0.0"
},
"resolutions": {
"brace-expansion": "5.0.9",
+ "nanoid": "5.1.16",
"postcss": "8.5.24",
"undici": "8.9.0"
}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 610a0e7ca..d9acf5d69 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -6,6 +6,7 @@ settings:
overrides:
brace-expansion: 5.0.9
+ nanoid: 5.1.16
postcss: 8.5.24
sharp: 0.35.0
undici: 8.9.0
@@ -2246,9 +2247,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.18:
- resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ nanoid@5.1.16:
+ resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
+ engines: {node: ^18 || >=20}
hasBin: true
napi-postinstall@0.3.4:
@@ -5049,7 +5050,7 @@ snapshots:
ms@2.1.3: {}
- nanoid@3.3.18: {}
+ nanoid@5.1.16: {}
napi-postinstall@0.3.4: {}
@@ -5191,7 +5192,7 @@ snapshots:
postcss@8.5.24:
dependencies:
- nanoid: 3.3.18
+ nanoid: 5.1.16
picocolors: 1.1.1
source-map-js: 1.2.1
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
index d028031d2..42c0ccb4c 100644
--- a/frontend/pnpm-workspace.yaml
+++ b/frontend/pnpm-workspace.yaml
@@ -15,6 +15,7 @@ supportedArchitectures:
overrides:
brace-expansion: "5.0.9"
+ nanoid: "5.1.16"
postcss: "8.5.24"
sharp: "0.35.0"
undici: 8.9.0
diff --git a/frontend/src/lib/dependency-security.test.ts b/frontend/src/lib/dependency-security.test.ts
new file mode 100644
index 000000000..0dd37eba0
--- /dev/null
+++ b/frontend/src/lib/dependency-security.test.ts
@@ -0,0 +1,90 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+import { describe, expect, it } from 'vitest';
+
+const frontendRoot = fileURLToPath(new URL('../..', import.meta.url));
+const packageManifest = JSON.parse(
+ readFileSync(`${frontendRoot}/package.json`, 'utf8'),
+) as {
+ overrides?: Record;
+ resolutions?: Record;
+};
+const lockfile = readFileSync(`${frontendRoot}/pnpm-lock.yaml`, 'utf8');
+const workspaceConfig = readFileSync(`${frontendRoot}/pnpm-workspace.yaml`, 'utf8');
+
+type NanoidLockSection = 'packages' | 'snapshots';
+
+const compareVersions = (left: string, right: readonly number[]) => {
+ const parts = left.split('.').map(Number);
+ return parts[0] - right[0] || parts[1] - right[1] || parts[2] - right[2];
+};
+
+const isAffectedNanoidVersion = (version: string) =>
+ compareVersions(version, [3, 3, 16]) < 0 ||
+ (compareVersions(version, [4, 0, 0]) >= 0 &&
+ compareVersions(version, [5, 1, 16]) < 0);
+
+const lockedNanoidVersions = (): Record => {
+ const versions: Record = {
+ packages: [],
+ snapshots: [],
+ };
+ let section: NanoidLockSection | null = null;
+
+ for (const line of lockfile.split('\n')) {
+ if (line === 'packages:') {
+ section = 'packages';
+ continue;
+ }
+ if (line === 'snapshots:') {
+ section = 'snapshots';
+ continue;
+ }
+ if (/^[A-Za-z][A-Za-z0-9_-]*:$/.test(line)) {
+ section = null;
+ continue;
+ }
+ if (section === null) {
+ continue;
+ }
+
+ const match = /^ nanoid@(\d+\.\d+\.\d+):/.exec(line);
+ if (match) {
+ versions[section].push(match[1]);
+ }
+ }
+
+ return versions;
+};
+
+describe('frontend dependency security contract', () => {
+ it.each(['3.3.15', '4.0.0', '5.1.15'])(
+ 'recognizes affected nanoid version %s',
+ (version) => {
+ expect(isAffectedNanoidVersion(version)).toBe(true);
+ },
+ );
+
+ it.each(['3.3.16', '3.3.99', '5.1.16', '6.0.0'])(
+ 'recognizes patched nanoid version %s',
+ (version) => {
+ expect(isAffectedNanoidVersion(version)).toBe(false);
+ },
+ );
+
+ it('keeps each locked nanoid version outside the advisory ranges', () => {
+ expect(packageManifest.overrides?.nanoid).toBe('5.1.16');
+ expect(packageManifest.resolutions?.nanoid).toBe('5.1.16');
+ expect(workspaceConfig).toContain(' nanoid: "5.1.16"\n');
+ expect(lockfile).toContain(' nanoid: 5.1.16\n');
+
+ const versions = lockedNanoidVersions();
+ for (const section of ['packages', 'snapshots'] as const) {
+ expect(versions[section]).toEqual(['5.1.16']);
+ for (const version of versions[section]) {
+ expect(isAffectedNanoidVersion(version)).toBe(false);
+ }
+ }
+ });
+});