From 3dc8420d2829b8b2aa772ad969fde3967a3f4d0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:52:42 +0900 Subject: [PATCH 01/20] test(dav): reject ambiguous nested authorization encodings --- backend/tests/test_dav_api.py | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/backend/tests/test_dav_api.py b/backend/tests/test_dav_api.py index 70d455ea9..f30ebee9c 100644 --- a/backend/tests/test_dav_api.py +++ b/backend/tests/test_dav_api.py @@ -1,8 +1,10 @@ import defusedxml.ElementTree as ET import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient +from api.dav import _normalize_dav_authorization_path from main import app from services.webdav_service import webdav_service @@ -94,6 +96,87 @@ def test_dav_rejects_ownerless_options_before_capability_discovery( assert response.json()["detail"] == "DAV path must include an owner user" +def test_dav_authorization_path_preserves_literal_percent_data(): + assert ( + _normalize_dav_authorization_path("user123/projects/literal%25") + == "user123/projects/literal%25" + ) + assert ( + _normalize_dav_authorization_path("user123/projects/분석%zz") + == "user123/projects/분석%zz" + ) + + +@pytest.mark.parametrize( + "path", + [ + "user123/projects/%2e%2e/secret", + "user123/projects/%252e%252e/secret", + "user123/projects/%2fsecret", + "user123/projects/%5csecret", + "user123/projects/%00secret", + "user123/projects/%250asecret", + ], +) +def test_dav_authorization_path_rejects_nested_structural_decoding(path): + with pytest.raises(HTTPException) as exc_info: + _normalize_dav_authorization_path(path) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "DAV path contains ambiguous percent encoding" + + +@pytest.mark.parametrize("control_character", ["\x00", "\x1f", "\x7f"]) +def test_dav_authorization_path_rejects_decoded_controls(control_character): + with pytest.raises(HTTPException) as exc_info: + _normalize_dav_authorization_path( + f"user123/projects/report{control_character}name" + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "DAV path contains control characters" + + +def test_dav_authorization_path_normalizes_literal_backslashes_once(): + assert ( + _normalize_dav_authorization_path(r"user123\projects\demo") + == "user123/projects/demo" + ) + + +def test_dav_authorization_path_has_explicit_resource_boundary(): + prefix = "user123/projects/" + boundary_path = prefix + ("x" * (8192 - len(prefix))) + assert _normalize_dav_authorization_path(boundary_path) == boundary_path + + with pytest.raises(HTTPException) as exc_info: + _normalize_dav_authorization_path(boundary_path + "x") + + assert exc_info.value.status_code == 414 + assert exc_info.value.detail == "DAV path exceeds authorization length limit" + + +def test_dav_route_rejects_nested_encoded_traversal(dev_auth_dependency_overrides): + with TestClient(app) as client: + response = client.options( + "/dav/user123/projects/%252e%252e/secret", + headers=AUTH_HEADERS, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "DAV path contains ambiguous percent encoding" + + +def test_dav_route_preserves_encoded_percent_as_data(dev_auth_dependency_overrides): + with TestClient(app) as client: + response = client.options( + "/dav/user123/projects/literal%2525", + headers=AUTH_HEADERS, + ) + + assert response.status_code == 200 + + def test_dav_propfind(dev_auth_dependency_overrides, stub_dav_project_folders): with TestClient(app) as client: response = client.request( @@ -114,6 +197,7 @@ def test_dav_propfind_escapes_path_values( "PROPFIND", "/dav/user123/projects/x%26y%3Cz%3E", headers=AUTH_HEADERS ) assert response.status_code == 207 + assert "" not in response.text ET.fromstring(response.text) @@ -156,6 +240,7 @@ def test_dav_unsupported_method_logs_reason(dev_auth_dependency_overrides, caplo 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, From 029c0657f5a9fc6b1127c0f58ea18432ff6fffd2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:55:59 +0900 Subject: [PATCH 02/20] fix(dav): enforce single-decode authorization boundary --- backend/api/dav.py | 65 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/backend/api/dav.py b/backend/api/dav.py index d618c25f3..44eaa47c9 100644 --- a/backend/api/dav.py +++ b/backend/api/dav.py @@ -1,6 +1,5 @@ import logging from html import escape as escape_xml_text -from urllib.parse import unquote from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.ext.asyncio import AsyncSession @@ -13,15 +12,67 @@ router = APIRouter(prefix="/dav", tags=["dav"]) +_DAV_AUTHORIZATION_PATH_MAX_CHARACTERS = 8192 +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") +_DAV_STRUCTURAL_OCTETS = frozenset( + {*range(0x20), 0x2E, 0x2F, 0x5C, 0x7F} +) + + +def _residual_percent_octet(path: str, percent_index: int) -> int | None: + """Return the octet exposed by another decode, following nested ``%25``.""" + + cursor = percent_index + 1 + while cursor + 1 < len(path): + pair = path[cursor : cursor + 2] + if pair[0] not in _HEX_DIGITS or pair[1] not in _HEX_DIGITS: + return None + octet = int(pair, 16) + if octet != 0x25: + return octet + cursor += 2 + return None + + +def _has_ambiguous_percent_encoding(path: str) -> bool: + """Detect residual encodings that another decode would make structural.""" + + for index, character in enumerate(path): + if character != "%": + continue + octet = _residual_percent_octet(path, index) + if octet in _DAV_STRUCTURAL_OCTETS: + return True + return False + def _normalize_dav_authorization_path(path: str) -> str: + """Validate the framework-decoded DAV path without decoding it again. + + ASGI routing has already decoded the request-target path once. Authorization + therefore treats residual percent text as data unless another decode would + introduce a traversal dot, separator, backslash, or control octet. Literal + backslashes are normalized to separators for owner/traversal checks. + """ + + if len(path) > _DAV_AUTHORIZATION_PATH_MAX_CHARACTERS: + raise HTTPException( + status_code=414, + detail="DAV path exceeds authorization length limit", + ) + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in path): + raise HTTPException( + status_code=400, + detail="DAV path contains control characters", + ) + normalized_path = path.replace("\\", "/") - for _ in range(100): - decoded_path = unquote(normalized_path).replace("\\", "/") - if decoded_path == normalized_path: - return normalized_path - normalized_path = decoded_path - raise HTTPException(status_code=400, detail="DAV path decoding limit exceeded") + if _has_ambiguous_percent_encoding(normalized_path): + raise HTTPException( + status_code=400, + detail="DAV path contains ambiguous percent encoding", + ) + return normalized_path def _dav_path_owner_user_id(path: str) -> str | None: From 532a0b48a5a730e70fb5c251f61cf6b3e357a136 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:44:31 +0900 Subject: [PATCH 03/20] test(dav): reproduce dropped normalized route path --- backend/tests/test_dav_normalized_route.py | 55 ++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 backend/tests/test_dav_normalized_route.py diff --git a/backend/tests/test_dav_normalized_route.py b/backend/tests/test_dav_normalized_route.py new file mode 100644 index 000000000..6b8751102 --- /dev/null +++ b/backend/tests/test_dav_normalized_route.py @@ -0,0 +1,55 @@ +"""Route-level regression coverage for canonical DAV authorization paths.""" + +import pytest +from fastapi.testclient import TestClient + +from main import app +from services.webdav_service import webdav_service + +AUTH_HEADERS = { + "X-User-Id": "user123", + "X-User-Role": "organization_admin", + "X-Organization-Id": "org-acme", +} + + +@pytest.fixture +def stub_dav_project_folder(monkeypatch): + """Return one deterministic project folder through the production DAV service seam.""" + + async def fake_project_folders(db, user_id, organization_id, folder_uid=None): + assert user_id == "user123" + assert organization_id == "org-acme" + assert folder_uid == "demo" + return [ + { + "folder_uid": "demo", + "project_name": "demo", + "webdav_path": "/projects/demo", + "owner_user_id": user_id, + "organization_id": organization_id, + } + ] + + monkeypatch.setattr( + webdav_service, + "get_project_folders_from_db", + fake_project_folders, + ) + + +def test_propfind_routes_framework_decoded_backslashes_through_canonical_path( + dev_auth_dependency_overrides, + stub_dav_project_folder, +): + """A once-decoded backslash path must reach the same project route as slashes.""" + + with TestClient(app) as client: + response = client.request( + "PROPFIND", + "/dav/user123%5Cprojects%5Cdemo", + headers=AUTH_HEADERS, + ) + + assert response.status_code == 207 + assert "demo" in response.text From fae5b629d3b2d8ab874d1cd6e5718eb2f84fb2c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:50:27 +0900 Subject: [PATCH 04/20] fix(dav): propagate canonical authorization path --- backend/api/dav.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/dav.py b/backend/api/dav.py index 44eaa47c9..0e05d624e 100644 --- a/backend/api/dav.py +++ b/backend/api/dav.py @@ -224,8 +224,9 @@ async def dav_handler( Provider-backed writeback stays fail-closed until source capability and ETag/If-Match enforcement are available through signed writeback intents. """ - _ensure_dav_owner_scope(path, auth_context) - safe_path = repr(path)[1:-1] + normalized_path = _normalize_dav_authorization_path(path) + _ensure_dav_owner_scope(normalized_path, auth_context) + safe_path = repr(normalized_path)[1:-1] logger.info("DAV Request: %s /%s", request.method, safe_path) if request.method == "OPTIONS": @@ -241,14 +242,13 @@ async def dav_handler( if request.method == "PROPFIND": return await _handle_project_propfind( request=request, - path=path, + path=normalized_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 " From 0a897dbac65a903901fada99c55051c3b6a41a61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:15:18 +0900 Subject: [PATCH 05/20] test(dav): align route assertions with framework decoding --- backend/tests/test_dav_api.py | 66 +++++++++++++++++------------------ 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/backend/tests/test_dav_api.py b/backend/tests/test_dav_api.py index f30ebee9c..2c90a5a81 100644 --- a/backend/tests/test_dav_api.py +++ b/backend/tests/test_dav_api.py @@ -156,15 +156,19 @@ def test_dav_authorization_path_has_explicit_resource_boundary(): assert exc_info.value.detail == "DAV path exceeds authorization length limit" -def test_dav_route_rejects_nested_encoded_traversal(dev_auth_dependency_overrides): +def test_dav_route_rejects_framework_decoded_nested_traversal( + dev_auth_dependency_overrides, +): + """Reject traversal after ASGI exposes the nested encoding as a literal segment.""" + with TestClient(app) as client: response = client.options( "/dav/user123/projects/%252e%252e/secret", headers=AUTH_HEADERS, ) - assert response.status_code == 400 - assert response.json()["detail"] == "DAV path contains ambiguous percent encoding" + assert response.status_code == 403 + assert response.json()["detail"] == "DAV path must include an owner user" def test_dav_route_preserves_encoded_percent_as_data(dev_auth_dependency_overrides): @@ -242,20 +246,17 @@ def test_dav_unsupported_method_logs_reason(dev_auth_dependency_overrides, caplo 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. - """ + """Reject decoded control characters before they can reach DAV request logs.""" + import asyncio import logging - 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 + from api.auth import AuthContext + from api.dav import dav_handler + + caplog.set_level(logging.INFO, logger="api.dav") + malicious_path = "user123/projects/test\x1b[31minjected\n\r" scope = { "type": "http", "method": "OPTIONS", @@ -263,26 +264,23 @@ 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 - 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. + request = Request(scope) + auth_context = AuthContext( + user_id="user123", + organization_id="org1", + role="user", + group_ids=[], + workspace_id="ws1", + ) + await dav_handler( + request=request, + path=malicious_path, + auth_context=auth_context, + ) - # 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!" - found_in_logs = True + with pytest.raises(HTTPException) as exc_info: + asyncio.run(run_handler()) - assert found_in_logs, "DAV Request log was not found" + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "DAV path contains control characters" + assert not any("DAV Request" in record.getMessage() for record in caplog.records) From b66af0d4aa1a9695dd24116d5301cf4759b1484a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 11:36:56 +0900 Subject: [PATCH 06/20] test(security): reject unsafe local-provider address classes --- backend/tests/test_llm_provider_urls.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/tests/test_llm_provider_urls.py b/backend/tests/test_llm_provider_urls.py index 16ab04e73..92effd08d 100644 --- a/backend/tests/test_llm_provider_urls.py +++ b/backend/tests/test_llm_provider_urls.py @@ -65,6 +65,26 @@ def test_validate_global_address_private_allowed_when_host_allowed(monkeypatch): assert _validate_global_address("192.168.1.5", hostname="ollama") == "192.168.1.5" +@pytest.mark.parametrize( + "address", + [ + "169.254.169.254", + "224.0.0.1", + "0.0.0.0", + "255.255.255.255", + ], +) +def test_validate_global_address_allowlisted_host_rejects_unsafe_address_classes( + monkeypatch, address +): + """Local-container opt-in must not authorize metadata or non-unicast addresses.""" + monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) + monkeypatch.setattr(settings, "ALLOWED_LLM_BASE_URL_HOSTS", "ollama") + + with pytest.raises(ValueError, match=LLM_BASE_URL_NOT_ALLOWED): + _validate_global_address(address, hostname="ollama") + + def test_validate_global_address_private_rejected_when_host_not_allowed(monkeypatch): """Test that a private IP is rejected even with ALLOW_LOCAL_LLM_PROVIDERS if the hostname is not allowed.""" monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) From 81c8890c11ed699ba19272e7f3d084291445b4e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 11:41:09 +0900 Subject: [PATCH 07/20] fix(security): bound local LLM provider networks --- backend/services/llm_provider_urls.py | 46 ++++++++++++++++++--------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/backend/services/llm_provider_urls.py b/backend/services/llm_provider_urls.py index 734a21c7b..2899ef5bd 100644 --- a/backend/services/llm_provider_urls.py +++ b/backend/services/llm_provider_urls.py @@ -16,6 +16,12 @@ _DNS_RESOLUTION_TIMEOUT_SECONDS = 5.0 _LOCAL_DEV_HOSTNAMES = {"localhost", "localhost.localdomain"} _LOCAL_DEV_IP_LITERALS = {"127.0.0.1", "::1"} +_LOCAL_PROVIDER_NETWORKS = ( + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ipaddress.ip_network("fc00::/7"), +) def _has_url_control_character(value: str) -> bool: @@ -74,6 +80,15 @@ def _is_allowlisted_local_provider_host(hostname: str) -> bool: ) +def _is_local_provider_network_address( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, +) -> bool: + return any( + address.version == network.version and address in network + for network in _LOCAL_PROVIDER_NETWORKS + ) + + def _format_normalized_netloc(hostname: str, port: int, *, explicit_port: bool) -> str: host_part = f"[{hostname}]" if ":" in hostname else hostname if not explicit_port: @@ -82,16 +97,14 @@ def _format_normalized_netloc(hostname: str, port: int, *, explicit_port: bool) def _validate_global_address(address: str, *, hostname: str | None = None) -> str: - """Validate that an IP address is globally routable, or explicitly allowed. - - When ``ALLOW_LOCAL_LLM_PROVIDERS`` is enabled the address is accepted if: - - the IP is a loopback address, **or** - - the *original* hostname (before DNS resolution) is present in - ``ALLOWED_LLM_BASE_URL_HOSTS``. - - This second condition is necessary because Docker container names (e.g. - ``ollama``) resolve to RFC-1918 private IPs that would otherwise be - rejected by the global-address check. + """Validate a globally routable address or an explicitly scoped local one. + + ``ALLOW_LOCAL_LLM_PROVIDERS`` admits loopback addresses for local developer + runtimes. An exact, operator-allowlisted single-label provider hostname may + additionally resolve only into RFC 1918 IPv4 or RFC 4193 IPv6 unique-local + space. Link-local, reserved, unspecified, multicast, and other non-global + address classes never become reachable merely because a hostname is + allowlisted. """ try: ip_address = ipaddress.ip_address(address) @@ -102,7 +115,11 @@ def _validate_global_address(address: str, *, hostname: str | None = None) -> st if settings.ALLOW_LOCAL_LLM_PROVIDERS: if ip_address.is_loopback: is_allowed_local = True - elif hostname and _is_allowlisted_local_provider_host(hostname): + elif ( + hostname + and _is_allowlisted_local_provider_host(hostname) + and _is_local_provider_network_address(ip_address) + ): is_allowed_local = True if not is_allowed_local: @@ -130,8 +147,8 @@ def _resolve_all_global_addresses(hostname: str, port: int) -> tuple[str, ...]: addresses: list[str] = [] seen_addresses: set[str] = set() for address_info in address_infos: - # Pass the original hostname so that Docker container names listed in - # ALLOWED_LLM_BASE_URL_HOSTS are matched before checking the resolved IP. + # Pass the original hostname so that explicitly allowlisted local-provider + # names can be bound to their permitted private container addresses. address = _validate_global_address(str(address_info[4][0]), hostname=hostname) if address not in seen_addresses: seen_addresses.add(address) @@ -269,8 +286,7 @@ def __init__(self, hostname: str, port: int, addresses: tuple[str, ...]): raise ValueError(LLM_BASE_URL_NOT_ALLOWED) self._hostname = hostname self._port = port - # Re-validate each address; pass the hostname so Docker-container names - # in ALLOWED_LLM_BASE_URL_HOSTS are accepted. + # Re-validate each address against the same hostname-scoped local boundary. self._addresses = tuple( _validate_global_address(address, hostname=hostname) for address in addresses From 5b0a8881cc7b4345a8e002b20e7e5eae11c7a2c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 11:51:38 +0900 Subject: [PATCH 08/20] docs(security): record DAV and local-provider network boundaries --- ...v-and-local-provider-network-boundaries.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/doctoring/dav-and-local-provider-network-boundaries.md diff --git a/docs/doctoring/dav-and-local-provider-network-boundaries.md b/docs/doctoring/dav-and-local-provider-network-boundaries.md new file mode 100644 index 000000000..8976eba2a --- /dev/null +++ b/docs/doctoring/dav-and-local-provider-network-boundaries.md @@ -0,0 +1,42 @@ +# DAV single-decode and local LLM-provider network boundaries + +## Decision + +Naruon treats the framework-decoded DAV application path as the authorization input. Authorization does not recursively decode the same string. Residual percent encodings are preserved when they remain literal data, and fail closed when another decode could introduce path separators, traversal dots, backslashes, C0/DEL controls, or NUL. This keeps routing, owner checks, logging, and DAV handlers on one canonical path representation. + +For local LLM runtimes, `ALLOW_LOCAL_LLM_PROVIDERS` is an explicit development/deployment opt-in rather than a general exception from SSRF controls. Loopback addresses remain permitted only under that opt-in. An exact operator-allowlisted single-label provider hostname may additionally resolve only into the private address families intended for site/container networking: RFC 1918 IPv4 private-use networks or RFC 4193 IPv6 unique-local addresses. The allowlist does not authorize IPv4 link-local/metadata space, multicast, unspecified, reserved, broadcast, or other special-purpose non-global address classes. + +## Why this boundary is narrower than `is_private` + +Python's IP classification helpers intentionally aggregate several non-global categories for convenience. Product authorization needs a positive description of the address classes that are actually required. RFC 1918 defines the three private IPv4 blocks used by private internets, and RFC 4193 defines IPv6 unique-local addresses for local communications. By contrast, RFC 3927 defines IPv4 link-local `169.254.0.0/16`, and RFC 6890 records special-purpose registry properties such as whether a block is globally reachable or forwardable. Therefore an operator hostname allowlist cannot safely mean "accept every address for which a library reports non-global/private". + +This positive-network contract also preserves the existing DNS-pinning design: every resolved address is validated against the same hostname-scoped policy before it can enter the pinned transport, and the transport revalidates the address again before connecting. + +## Verification contract + +The security regression suite must prove all of the following: + +- an exact allowlisted local provider plus explicit local-provider opt-in can reach RFC 1918 container/private addresses; +- local-provider opt-in without exact hostname allowlisting does not admit RFC 1918 addresses; +- loopback remains conditional on the explicit local-provider opt-in; +- an allowlisted local provider still rejects `169.254.169.254`, multicast, unspecified, broadcast/reserved, and other non-authorized special-purpose classes; +- the DAV path contract rejects ambiguous residual structural encodings without recursively transforming literal percent data; +- the canonical DAV path is the same value used by authorization and downstream routing. + +The current slice does not claim that private-network access is generally safe, that DNS alone is an authorization mechanism, or that these controls replace tenant authorization, TLS identity, credential isolation, outbound method/path policy, or provider-specific authentication. + +## Rollback + +If local-provider compatibility requires another address family, do not widen the exception to all non-global addresses. Add the smallest explicit network class only after a concrete deployment requirement, threat analysis, tests, and operator-visible configuration contract are established. If the DAV canonicalization contract changes, update authorization and route-level tests together so parsing and authorization cannot diverge. + +## References (APA 7th) + +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource Identifier (URI): Generic syntax* (RFC 3986). RFC Editor. https://doi.org/10.17487/RFC3986 + +Cheshire, S., Aboba, B., & Guttman, E. (2005). *Dynamic configuration of IPv4 link-local addresses* (RFC 3927). RFC Editor. https://doi.org/10.17487/RFC3927 + +Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP address registries* (BCP 153, RFC 6890). RFC Editor. https://doi.org/10.17487/RFC6890 + +Hinden, R., & Haberman, B. (2005). *Unique local IPv6 unicast addresses* (RFC 4193). RFC Editor. https://doi.org/10.17487/RFC4193 + +Rekhter, Y., Moskowitz, B., Karrenberg, D., de Groot, G. J., & Lear, E. (1996). *Address allocation for private internets* (BCP 5, RFC 1918). RFC Editor. https://doi.org/10.17487/RFC1918 From 60feaad66b5aaceb409f21fbf54f9c375f484571 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 12:43:12 +0900 Subject: [PATCH 09/20] test(security): cover IPv6 local-provider network boundaries --- backend/tests/test_llm_provider_urls.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/tests/test_llm_provider_urls.py b/backend/tests/test_llm_provider_urls.py index 92effd08d..8910f9e13 100644 --- a/backend/tests/test_llm_provider_urls.py +++ b/backend/tests/test_llm_provider_urls.py @@ -72,6 +72,9 @@ def test_validate_global_address_private_allowed_when_host_allowed(monkeypatch): "224.0.0.1", "0.0.0.0", "255.255.255.255", + "::", + "fe80::1", + "ff02::1", ], ) def test_validate_global_address_allowlisted_host_rejects_unsafe_address_classes( @@ -85,6 +88,14 @@ def test_validate_global_address_allowlisted_host_rejects_unsafe_address_classes _validate_global_address(address, hostname="ollama") +def test_validate_global_address_allowlisted_host_accepts_unique_local_ipv6(monkeypatch): + """An allowlisted local provider may resolve to IPv6 unique-local space.""" + monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) + monkeypatch.setattr(settings, "ALLOWED_LLM_BASE_URL_HOSTS", "ollama") + + assert _validate_global_address("fd00::1", hostname="ollama") == "fd00::1" + + def test_validate_global_address_private_rejected_when_host_not_allowed(monkeypatch): """Test that a private IP is rejected even with ALLOW_LOCAL_LLM_PROVIDERS if the hostname is not allowed.""" monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) From d6a870d019a0ba2c6938f15b4d9b70fb60b8daae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:53:50 +0900 Subject: [PATCH 10/20] test(data): reproduce cross-organization document access --- .../tests/test_data_document_authorization.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 backend/tests/test_data_document_authorization.py diff --git a/backend/tests/test_data_document_authorization.py b/backend/tests/test_data_document_authorization.py new file mode 100644 index 000000000..e64db733f --- /dev/null +++ b/backend/tests/test_data_document_authorization.py @@ -0,0 +1,128 @@ +"""Authorization regressions for workspace document actions.""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from api.auth import AuthContext +from api.data import _get_workspace_document +from db.models import Document + + +class _ScalarResult: + """Minimal SQLAlchemy result surface used by the authorization helper.""" + + def __init__(self, value: Document | None) -> None: + self._value = value + + def scalar_one_or_none(self) -> Document | None: + """Return the single simulated document result.""" + + return self._value + + +class _OrganizationAwareSession: + """Evaluate document scope predicates against one in-memory document.""" + + def __init__(self, document: Document) -> None: + self.document = document + + async def execute(self, statement): + """Return the document only when every emitted scope predicate matches.""" + + compiled = statement.compile() + rendered = str(statement) + values = tuple(compiled.params.values()) + if self.document.document_id not in values: + return _ScalarResult(None) + if self.document.workspace_id not in values: + return _ScalarResult(None) + + # A missing organization predicate reproduces the vulnerable behavior: + # a document from another tenant is still returned solely because both + # principals supplied the same workspace identifier. + if "workspace_documents.organization_id" not in rendered: + return _ScalarResult(self.document) + + if self.document.organization_id is None: + organization_matches = "IS NULL" in rendered.upper() + else: + organization_matches = self.document.organization_id in values + return _ScalarResult(self.document if organization_matches else None) + + +def _auth_context(organization_id: str | None) -> AuthContext: + return AuthContext( + user_id="member-a", + role="member", + organization_id=organization_id, + group_ids=(), + workspace_id="workspace-shared-identifier", + ) + + +@pytest.mark.asyncio +async def test_workspace_document_lookup_rejects_cross_organization_idor() -> None: + """A reused workspace identifier must not cross the tenant boundary.""" + + document = Document( + document_id="doc-org-b", + workspace_id="workspace-shared-identifier", + organization_id="org-b", + document_name="private.md", + document_type="text/markdown", + document_content="tenant B evidence", + document_status="uploaded", + ) + session = _OrganizationAwareSession(document) + + with pytest.raises(HTTPException) as exc_info: + await _get_workspace_document(session, _auth_context("org-a"), document.document_id) # type: ignore[arg-type] + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_workspace_document_lookup_preserves_same_organization_collaboration() -> None: + """Workspace members in the owning organization retain shared-document access.""" + + document = Document( + document_id="doc-org-a", + workspace_id="workspace-shared-identifier", + organization_id="org-a", + document_name="shared.md", + document_type="text/markdown", + document_content="shared tenant evidence", + document_status="uploaded", + ) + session = _OrganizationAwareSession(document) + + resolved = await _get_workspace_document( + session, # type: ignore[arg-type] + _auth_context("org-a"), + document.document_id, + ) + + assert resolved is document + + +@pytest.mark.asyncio +async def test_personal_workspace_document_lookup_rejects_organization_document() -> None: + """Personal-scope sessions cannot read organization-owned workspace documents.""" + + document = Document( + document_id="doc-org-owned", + workspace_id="workspace-shared-identifier", + organization_id="org-a", + document_name="org.md", + document_type="text/markdown", + document_content="organization evidence", + document_status="uploaded", + ) + session = _OrganizationAwareSession(document) + + with pytest.raises(HTTPException) as exc_info: + await _get_workspace_document(session, _auth_context(None), document.document_id) # type: ignore[arg-type] + + assert exc_info.value.status_code == 404 From c05855f90250f2f7271af86888a4579b39375c53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:56:43 +0900 Subject: [PATCH 11/20] test(data): inspect only document WHERE scope --- backend/tests/test_data_document_authorization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_data_document_authorization.py b/backend/tests/test_data_document_authorization.py index e64db733f..1fc1b21f6 100644 --- a/backend/tests/test_data_document_authorization.py +++ b/backend/tests/test_data_document_authorization.py @@ -32,7 +32,7 @@ async def execute(self, statement): """Return the document only when every emitted scope predicate matches.""" compiled = statement.compile() - rendered = str(statement) + rendered_scope = str(statement.whereclause) values = tuple(compiled.params.values()) if self.document.document_id not in values: return _ScalarResult(None) @@ -42,11 +42,11 @@ async def execute(self, statement): # A missing organization predicate reproduces the vulnerable behavior: # a document from another tenant is still returned solely because both # principals supplied the same workspace identifier. - if "workspace_documents.organization_id" not in rendered: + if "workspace_documents.organization_id" not in rendered_scope: return _ScalarResult(self.document) if self.document.organization_id is None: - organization_matches = "IS NULL" in rendered.upper() + organization_matches = "IS NULL" in rendered_scope.upper() else: organization_matches = self.document.organization_id in values return _ScalarResult(self.document if organization_matches else None) From 2b8a18fe2bba4a24fec9513c9355e59a34e6291c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:59:23 +0900 Subject: [PATCH 12/20] fix(data): enforce document organization scope --- backend/api/data.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/api/data.py b/backend/api/data.py index dccd85890..83b9fa6d5 100644 --- a/backend/api/data.py +++ b/backend/api/data.py @@ -2489,10 +2489,16 @@ async def _get_workspace_document( auth_context: AuthContext, document_id: str, ) -> Document: + organization_filter = ( + Document.organization_id == auth_context.organization_id + if auth_context.organization_id is not None + else Document.organization_id.is_(None) + ) result = await db.execute( select(Document).where( Document.document_id == document_id, Document.workspace_id == auth_context.workspace_id, + organization_filter, ) ) document = result.scalar_one_or_none() @@ -3928,10 +3934,18 @@ async def get_data_quality_surface( ProjectFolder.folder_uid.asc(), ), ) + document_organization_filter = ( + Document.organization_id == auth_context.organization_id + if auth_context.organization_id is not None + else Document.organization_id.is_(None) + ) documents = await _scoped_rows( db, select(Document) - .where(Document.workspace_id == auth_context.workspace_id) + .where( + Document.workspace_id == auth_context.workspace_id, + document_organization_filter, + ) .order_by(Document.created_at.desc(), Document.document_id.asc()) .limit(8), ) From 17b34525e366aa2f57105fb8d16855747ce9583e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:53:15 +0900 Subject: [PATCH 13/20] test(data): cover remaining document organization scopes --- .../tests/test_data_document_authorization.py | 270 ++++++++++++++++-- 1 file changed, 252 insertions(+), 18 deletions(-) diff --git a/backend/tests/test_data_document_authorization.py b/backend/tests/test_data_document_authorization.py index 1fc1b21f6..c03bac3e8 100644 --- a/backend/tests/test_data_document_authorization.py +++ b/backend/tests/test_data_document_authorization.py @@ -2,9 +2,13 @@ from __future__ import annotations +from datetime import datetime, timezone +from typing import Any + import pytest from fastapi import HTTPException +import api.data as data_api from api.auth import AuthContext from api.data import _get_workspace_document from db.models import Document @@ -28,7 +32,7 @@ class _OrganizationAwareSession: def __init__(self, document: Document) -> None: self.document = document - async def execute(self, statement): + async def execute(self, statement: Any) -> _ScalarResult: """Return the document only when every emitted scope predicate matches.""" compiled = statement.compile() @@ -62,23 +66,199 @@ def _auth_context(organization_id: str | None) -> AuthContext: ) +def _document( + *, + document_id: str, + organization_id: str | None, + document_name: str, +) -> Document: + """Build a workspace document with a stable timestamp for surface tests.""" + + return Document( + document_id=document_id, + workspace_id="workspace-shared-identifier", + organization_id=organization_id, + document_name=document_name, + document_type="text/markdown", + document_content="tenant evidence", + document_status="uploaded", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + + +def _document_visible_in_scope( + document: Document, + values: tuple[Any, ...], + rendered_scope: str, +) -> bool: + """Return whether a compiled document scope includes the fake document.""" + + if document.workspace_id not in values: + return False + if "workspace_documents.organization_id" not in rendered_scope: + return True + if document.organization_id is None: + return "IS NULL" in rendered_scope.upper() + return document.organization_id in values + + +def _install_quality_surface_fixtures( + monkeypatch: pytest.MonkeyPatch, + documents: list[Document], +) -> None: + """Patch the expensive quality-surface dependencies with deterministic fakes.""" + + async def scoped_rows(_db: object, statement: Any) -> list[object]: + statement_text = str(statement) + if "workspace_documents" not in statement_text: + return [] + compiled = statement.compile() + rendered_scope = str(statement.whereclause) + values = tuple(compiled.params.values()) + return [ + document + for document in documents + if _document_visible_in_scope(document, values, rendered_scope) + ] + + async def zero_email_stats( + _db: object, + _email_scope: object, + ) -> data_api.EmailQualityStats: + return data_api.EmailQualityStats( + count=0, + missing_thread_count=0, + missing_fingerprint_count=0, + embedded_count=0, + ) + + async def zero_attachment_stats( + _db: object, + _email_scope: object, + ) -> data_api.AttachmentQualityStats: + return data_api.AttachmentQualityStats( + count=0, + blank_content_count=0, + embedded_count=0, + ) + + async def zero_content_graph_stats( + _db: object, + _email_scope: object, + ) -> data_api.ContentGraphQualityStats: + return data_api.ContentGraphQualityStats( + segmented_email_count=0, + segment_count=0, + ) + + async def zero_knowledge_graph_stats( + _db: object, + _email_scope: object, + ) -> data_api.KnowledgeGraphQualityStats: + return data_api.KnowledgeGraphQualityStats( + edged_email_count=0, + edge_count=0, + ) + + async def zero_content_segment_readiness_stats( + _db: object, + _email_scope: object, + ) -> data_api.ContentSegmentTextReadinessStats: + return data_api.ContentSegmentTextReadinessStats( + total_count=0, + issue_count=0, + ) + + async def zero_knowledge_graph_endpoint_stats( + _db: object, + _email_scope: object, + ) -> data_api.KnowledgeGraphEvidenceEndpointStats: + return data_api.KnowledgeGraphEvidenceEndpointStats( + total_count=0, + issue_count=0, + ) + + async def zero_semantic_relation_stats( + _db: object, + _auth_context: AuthContext, + ) -> data_api.SemanticRelationEvidenceStats: + return data_api.SemanticRelationEvidenceStats( + total_count=0, + source_backed_count=0, + ) + + async def zero_attachment_parse_stats( + _db: object, + _email_scope: object, + ) -> data_api.AttachmentParseQualityStats: + return data_api.AttachmentParseQualityStats( + parsed_count=0, + unparsed_count=0, + ) + + async def empty_list(*_args: object, **_kwargs: object) -> list[object]: + return [] + + monkeypatch.setattr(data_api, "_scoped_rows", scoped_rows) + monkeypatch.setattr(data_api, "_get_email_stats", zero_email_stats) + monkeypatch.setattr(data_api, "_get_attachment_stats", zero_attachment_stats) + monkeypatch.setattr(data_api, "_get_content_graph_stats", zero_content_graph_stats) + monkeypatch.setattr( + data_api, + "_get_knowledge_graph_stats", + zero_knowledge_graph_stats, + ) + monkeypatch.setattr( + data_api, + "_get_content_segment_text_readiness_stats", + zero_content_segment_readiness_stats, + ) + monkeypatch.setattr( + data_api, + "_get_knowledge_graph_evidence_endpoint_stats", + zero_knowledge_graph_endpoint_stats, + ) + monkeypatch.setattr(data_api, "_get_content_graph_breakdown", empty_list) + monkeypatch.setattr(data_api, "_get_knowledge_graph_breakdown", empty_list) + monkeypatch.setattr(data_api, "_get_content_graph_evidence_samples", empty_list) + monkeypatch.setattr(data_api, "_get_knowledge_graph_evidence_samples", empty_list) + monkeypatch.setattr( + data_api, + "_get_semantic_relation_evidence_stats", + zero_semantic_relation_stats, + ) + monkeypatch.setattr( + data_api, + "_get_semantic_relation_evidence_samples", + empty_list, + ) + monkeypatch.setattr( + data_api, + "_get_attachment_parse_stats", + zero_attachment_parse_stats, + ) + monkeypatch.setattr(data_api, "_get_attachment_parse_breakdown", empty_list) + monkeypatch.setattr(data_api, "_get_connector_events", empty_list) + monkeypatch.setattr(data_api, "_get_attachment_assets", empty_list) + + @pytest.mark.asyncio async def test_workspace_document_lookup_rejects_cross_organization_idor() -> None: """A reused workspace identifier must not cross the tenant boundary.""" - document = Document( + document = _document( document_id="doc-org-b", - workspace_id="workspace-shared-identifier", organization_id="org-b", document_name="private.md", - document_type="text/markdown", - document_content="tenant B evidence", - document_status="uploaded", ) session = _OrganizationAwareSession(document) with pytest.raises(HTTPException) as exc_info: - await _get_workspace_document(session, _auth_context("org-a"), document.document_id) # type: ignore[arg-type] + await _get_workspace_document( + session, # type: ignore[arg-type] + _auth_context("org-a"), + document.document_id, + ) assert exc_info.value.status_code == 404 @@ -87,14 +267,10 @@ async def test_workspace_document_lookup_rejects_cross_organization_idor() -> No async def test_workspace_document_lookup_preserves_same_organization_collaboration() -> None: """Workspace members in the owning organization retain shared-document access.""" - document = Document( + document = _document( document_id="doc-org-a", - workspace_id="workspace-shared-identifier", organization_id="org-a", document_name="shared.md", - document_type="text/markdown", - document_content="shared tenant evidence", - document_status="uploaded", ) session = _OrganizationAwareSession(document) @@ -111,18 +287,76 @@ async def test_workspace_document_lookup_preserves_same_organization_collaborati async def test_personal_workspace_document_lookup_rejects_organization_document() -> None: """Personal-scope sessions cannot read organization-owned workspace documents.""" - document = Document( + document = _document( document_id="doc-org-owned", - workspace_id="workspace-shared-identifier", organization_id="org-a", document_name="org.md", - document_type="text/markdown", - document_content="organization evidence", - document_status="uploaded", ) session = _OrganizationAwareSession(document) with pytest.raises(HTTPException) as exc_info: - await _get_workspace_document(session, _auth_context(None), document.document_id) # type: ignore[arg-type] + await _get_workspace_document( + session, # type: ignore[arg-type] + _auth_context(None), + document.document_id, + ) assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_personal_workspace_document_lookup_preserves_personal_document() -> None: + """Personal-scope sessions can read organization-less workspace documents.""" + + document = _document( + document_id="doc-personal", + organization_id=None, + document_name="personal.md", + ) + session = _OrganizationAwareSession(document) + + resolved = await _get_workspace_document( + session, # type: ignore[arg-type] + _auth_context(None), + document.document_id, + ) + + assert resolved is document + + +@pytest.mark.asyncio +async def test_data_quality_surface_excludes_foreign_organization_document( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The data-quality surface lists only documents owned by the organization.""" + + documents = [ + _document( + document_id="doc-org-a", + organization_id="org-a", + document_name="visible.md", + ), + _document( + document_id="doc-org-b", + organization_id="org-b", + document_name="hidden.md", + ), + ] + _install_quality_surface_fixtures(monkeypatch, documents) + + surface = await data_api.get_data_quality_surface( + _auth_context("org-a"), + object(), # type: ignore[arg-type] + ) + + document_repository = next( + repository + for repository in surface.repositories + if repository.repository_type == "document_repository" + ) + assert document_repository.object_count == 1 + assert [ + asset.asset_key + for asset in surface.repository_assets + if asset.asset_type == "workspace_document" + ] == ["doc-org-a"] From 0ca9624b1abcbab1f3f3df2154115e6e2ab4678c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:45:04 +0900 Subject: [PATCH 14/20] test(data): use one api.data import style --- backend/tests/test_data_document_authorization.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_data_document_authorization.py b/backend/tests/test_data_document_authorization.py index c03bac3e8..dd66bc04a 100644 --- a/backend/tests/test_data_document_authorization.py +++ b/backend/tests/test_data_document_authorization.py @@ -10,7 +10,6 @@ import api.data as data_api from api.auth import AuthContext -from api.data import _get_workspace_document from db.models import Document @@ -254,7 +253,7 @@ async def test_workspace_document_lookup_rejects_cross_organization_idor() -> No session = _OrganizationAwareSession(document) with pytest.raises(HTTPException) as exc_info: - await _get_workspace_document( + await data_api._get_workspace_document( session, # type: ignore[arg-type] _auth_context("org-a"), document.document_id, @@ -274,7 +273,7 @@ async def test_workspace_document_lookup_preserves_same_organization_collaborati ) session = _OrganizationAwareSession(document) - resolved = await _get_workspace_document( + resolved = await data_api._get_workspace_document( session, # type: ignore[arg-type] _auth_context("org-a"), document.document_id, @@ -295,7 +294,7 @@ async def test_personal_workspace_document_lookup_rejects_organization_document( session = _OrganizationAwareSession(document) with pytest.raises(HTTPException) as exc_info: - await _get_workspace_document( + await data_api._get_workspace_document( session, # type: ignore[arg-type] _auth_context(None), document.document_id, @@ -315,7 +314,7 @@ async def test_personal_workspace_document_lookup_preserves_personal_document() ) session = _OrganizationAwareSession(document) - resolved = await _get_workspace_document( + resolved = await data_api._get_workspace_document( session, # type: ignore[arg-type] _auth_context(None), document.document_id, From 95f23c25ced6e850aba0de9b4ad1c6f4ff6c24ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 08:18:02 +0900 Subject: [PATCH 15/20] test(security): reject loopback DNS rebinding --- backend/tests/test_llm_provider_urls.py | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/backend/tests/test_llm_provider_urls.py b/backend/tests/test_llm_provider_urls.py index 8910f9e13..079d6a724 100644 --- a/backend/tests/test_llm_provider_urls.py +++ b/backend/tests/test_llm_provider_urls.py @@ -1,3 +1,5 @@ +import socket + import pytest from core.config import settings @@ -5,6 +7,7 @@ LLM_BASE_URL_NOT_ALLOWED, _is_ip_literal, _validate_global_address, + validate_llm_provider_base_url, ) @@ -57,6 +60,66 @@ def test_validate_global_address_loopback_allowed_when_settings_enabled(monkeypa assert _validate_global_address("127.0.0.1") == "127.0.0.1" +@pytest.mark.parametrize( + "provider_url, hostname", + [ + ("https://provider.example/v1", "provider.example"), + ("http://ollama:11434/v1", "ollama"), + ], +) +def test_validate_provider_base_url_rejects_loopback_dns_rebinding( + monkeypatch, provider_url, hostname +): + """An allowlisted non-local hostname must not gain loopback access via DNS.""" + monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) + monkeypatch.setattr(settings, "ALLOWED_LLM_BASE_URL_HOSTS", hostname) + + def resolve_to_loopback(resolved_hostname, port, *, type): + """Resolve the candidate hostname to loopback to simulate DNS rebinding.""" + assert resolved_hostname == hostname + assert type == socket.SOCK_STREAM + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", port), + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", resolve_to_loopback) + + with pytest.raises(ValueError, match=LLM_BASE_URL_NOT_ALLOWED): + validate_llm_provider_base_url(provider_url) + + +def test_validate_provider_base_url_allows_explicit_localhost_loopback(monkeypatch): + """Explicit localhost remains usable when the local-provider opt-in is enabled.""" + monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) + + def resolve_localhost(resolved_hostname, port, *, type): + """Resolve the explicit local-development hostname to loopback.""" + assert resolved_hostname == "localhost" + assert type == socket.SOCK_STREAM + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", port), + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", resolve_localhost) + + assert ( + validate_llm_provider_base_url("http://localhost:11434/v1") + == "http://localhost:11434/v1" + ) + + def test_validate_global_address_private_allowed_when_host_allowed(monkeypatch): """Test that a private IP is allowed when the hostname is in ALLOWED_LLM_BASE_URL_HOSTS.""" monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) From d73e9e4e1d117ee005ace91dfe289afe31ae7132 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 08:21:45 +0900 Subject: [PATCH 16/20] fix(security): bind loopback access to local host identity --- backend/services/llm_provider_urls.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/services/llm_provider_urls.py b/backend/services/llm_provider_urls.py index 2899ef5bd..7458e5883 100644 --- a/backend/services/llm_provider_urls.py +++ b/backend/services/llm_provider_urls.py @@ -99,12 +99,13 @@ def _format_normalized_netloc(hostname: str, port: int, *, explicit_port: bool) def _validate_global_address(address: str, *, hostname: str | None = None) -> str: """Validate a globally routable address or an explicitly scoped local one. - ``ALLOW_LOCAL_LLM_PROVIDERS`` admits loopback addresses for local developer - runtimes. An exact, operator-allowlisted single-label provider hostname may - additionally resolve only into RFC 1918 IPv4 or RFC 4193 IPv6 unique-local - space. Link-local, reserved, unspecified, multicast, and other non-global - address classes never become reachable merely because a hostname is - allowlisted. + ``ALLOW_LOCAL_LLM_PROVIDERS`` admits loopback only when the original URL + hostname is itself an explicit local-development identity such as + ``localhost`` or a configured loopback literal. An exact, + operator-allowlisted single-label provider hostname may additionally + resolve only into RFC 1918 IPv4 or RFC 4193 IPv6 unique-local space. + Link-local, reserved, unspecified, multicast, and other non-global address + classes never become reachable merely because a hostname is allowlisted. """ try: ip_address = ipaddress.ip_address(address) @@ -113,7 +114,7 @@ def _validate_global_address(address: str, *, hostname: str | None = None) -> st is_allowed_local = False if settings.ALLOW_LOCAL_LLM_PROVIDERS: - if ip_address.is_loopback: + if ip_address.is_loopback and hostname and _is_local_dev_host(hostname): is_allowed_local = True elif ( hostname From 7052b66b14f54a3c3115a11c429963708501e8a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 08:23:37 +0900 Subject: [PATCH 17/20] test(security): scope loopback opt-in to local identities --- backend/tests/test_llm_provider_urls.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_llm_provider_urls.py b/backend/tests/test_llm_provider_urls.py index 079d6a724..87ddb9475 100644 --- a/backend/tests/test_llm_provider_urls.py +++ b/backend/tests/test_llm_provider_urls.py @@ -54,10 +54,23 @@ def test_validate_global_address_multicast_ip_rejected(): _validate_global_address("224.0.0.1") -def test_validate_global_address_loopback_allowed_when_settings_enabled(monkeypatch): - """Test that a loopback IP is allowed when ALLOW_LOCAL_LLM_PROVIDERS is True.""" +def test_validate_global_address_loopback_allowed_for_explicit_localhost(monkeypatch): + """Explicit local hostname identity may resolve to loopback under local opt-in.""" monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) - assert _validate_global_address("127.0.0.1") == "127.0.0.1" + assert ( + _validate_global_address("127.0.0.1", hostname="localhost") + == "127.0.0.1" + ) + + +def test_validate_global_address_loopback_rejected_without_local_host_identity( + monkeypatch, +): + """Local opt-in alone must not authorize a DNS-derived loopback address.""" + monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) + + with pytest.raises(ValueError, match=LLM_BASE_URL_NOT_ALLOWED): + _validate_global_address("127.0.0.1") @pytest.mark.parametrize( From 76b5d16dc681c95422e98dec2cf211c2f1afb5a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:12:05 +0900 Subject: [PATCH 18/20] test(dav): reject advertised unsupported capabilities --- backend/tests/test_dav_api.py | 74 +++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/backend/tests/test_dav_api.py b/backend/tests/test_dav_api.py index 2c90a5a81..015e4b4b2 100644 --- a/backend/tests/test_dav_api.py +++ b/backend/tests/test_dav_api.py @@ -63,11 +63,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): @@ -207,42 +215,40 @@ 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") - with TestClient(app) as client: - response = client.put( - "/dav/user123/projects/file.ics", - content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR", - 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") +@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.delete( + response = client.request( + method, "/dav/user123/projects/file.ics", + content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR" if method == "PUT" else None, headers=AUTH_HEADERS, ) - 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 - ) + 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"} def test_dav_log_injection_prevention(dev_auth_dependency_overrides, caplog): From a094523deb2f83f8020ccfc8badefc48789de613 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:12:35 +0900 Subject: [PATCH 19/20] fix(dav): expose only implemented protocol capabilities --- backend/api/dav.py | 70 ++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 52 deletions(-) diff --git a/backend/api/dav.py b/backend/api/dav.py index 0e05d624e..3cede071c 100644 --- a/backend/api/dav.py +++ b/backend/api/dav.py @@ -12,6 +12,7 @@ router = APIRouter(prefix="/dav", tags=["dav"]) +IMPLEMENTED_DAV_METHODS = ("OPTIONS", "PROPFIND") _DAV_AUTHORIZATION_PATH_MAX_CHARACTERS = 8192 _HEX_DIGITS = frozenset("0123456789abcdefABCDEF") _DAV_STRUCTURAL_OCTETS = frozenset( @@ -209,20 +210,20 @@ 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. + Project collection discovery is available through ``PROPFIND``. Unsupported + writeback and richer DAV verbs are deliberately not registered, so clients + receive ``405 Method Not Allowed`` instead of a misleading advertised + capability that can only return ``501 Not Implemented``. """ normalized_path = _normalize_dav_authorization_path(path) _ensure_dav_owner_scope(normalized_path, auth_context) @@ -230,52 +231,17 @@ async def dav_handler( 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=normalized_path, - auth_context=auth_context, - db=db, - ) - - if request.method == "PUT": - body = await request.body() - 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=normalized_path, + auth_context=auth_context, + db=db, ) From 9a019892f7d53f72415fa5633a0facd73815b8f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 07:48:03 +0900 Subject: [PATCH 20/20] fix(dav): restore canonical LLM owner boundary --- backend/services/llm_provider_urls.py | 49 +++----- backend/tests/test_llm_provider_urls.py | 113 +----------------- ...v-and-local-provider-network-boundaries.md | 53 ++++---- 3 files changed, 48 insertions(+), 167 deletions(-) diff --git a/backend/services/llm_provider_urls.py b/backend/services/llm_provider_urls.py index 7458e5883..734a21c7b 100644 --- a/backend/services/llm_provider_urls.py +++ b/backend/services/llm_provider_urls.py @@ -16,12 +16,6 @@ _DNS_RESOLUTION_TIMEOUT_SECONDS = 5.0 _LOCAL_DEV_HOSTNAMES = {"localhost", "localhost.localdomain"} _LOCAL_DEV_IP_LITERALS = {"127.0.0.1", "::1"} -_LOCAL_PROVIDER_NETWORKS = ( - ipaddress.ip_network("10.0.0.0/8"), - ipaddress.ip_network("172.16.0.0/12"), - ipaddress.ip_network("192.168.0.0/16"), - ipaddress.ip_network("fc00::/7"), -) def _has_url_control_character(value: str) -> bool: @@ -80,15 +74,6 @@ def _is_allowlisted_local_provider_host(hostname: str) -> bool: ) -def _is_local_provider_network_address( - address: ipaddress.IPv4Address | ipaddress.IPv6Address, -) -> bool: - return any( - address.version == network.version and address in network - for network in _LOCAL_PROVIDER_NETWORKS - ) - - def _format_normalized_netloc(hostname: str, port: int, *, explicit_port: bool) -> str: host_part = f"[{hostname}]" if ":" in hostname else hostname if not explicit_port: @@ -97,15 +82,16 @@ def _format_normalized_netloc(hostname: str, port: int, *, explicit_port: bool) def _validate_global_address(address: str, *, hostname: str | None = None) -> str: - """Validate a globally routable address or an explicitly scoped local one. - - ``ALLOW_LOCAL_LLM_PROVIDERS`` admits loopback only when the original URL - hostname is itself an explicit local-development identity such as - ``localhost`` or a configured loopback literal. An exact, - operator-allowlisted single-label provider hostname may additionally - resolve only into RFC 1918 IPv4 or RFC 4193 IPv6 unique-local space. - Link-local, reserved, unspecified, multicast, and other non-global address - classes never become reachable merely because a hostname is allowlisted. + """Validate that an IP address is globally routable, or explicitly allowed. + + When ``ALLOW_LOCAL_LLM_PROVIDERS`` is enabled the address is accepted if: + - the IP is a loopback address, **or** + - the *original* hostname (before DNS resolution) is present in + ``ALLOWED_LLM_BASE_URL_HOSTS``. + + This second condition is necessary because Docker container names (e.g. + ``ollama``) resolve to RFC-1918 private IPs that would otherwise be + rejected by the global-address check. """ try: ip_address = ipaddress.ip_address(address) @@ -114,13 +100,9 @@ def _validate_global_address(address: str, *, hostname: str | None = None) -> st is_allowed_local = False if settings.ALLOW_LOCAL_LLM_PROVIDERS: - if ip_address.is_loopback and hostname and _is_local_dev_host(hostname): + if ip_address.is_loopback: is_allowed_local = True - elif ( - hostname - and _is_allowlisted_local_provider_host(hostname) - and _is_local_provider_network_address(ip_address) - ): + elif hostname and _is_allowlisted_local_provider_host(hostname): is_allowed_local = True if not is_allowed_local: @@ -148,8 +130,8 @@ def _resolve_all_global_addresses(hostname: str, port: int) -> tuple[str, ...]: addresses: list[str] = [] seen_addresses: set[str] = set() for address_info in address_infos: - # Pass the original hostname so that explicitly allowlisted local-provider - # names can be bound to their permitted private container addresses. + # Pass the original hostname so that Docker container names listed in + # ALLOWED_LLM_BASE_URL_HOSTS are matched before checking the resolved IP. address = _validate_global_address(str(address_info[4][0]), hostname=hostname) if address not in seen_addresses: seen_addresses.add(address) @@ -287,7 +269,8 @@ def __init__(self, hostname: str, port: int, addresses: tuple[str, ...]): raise ValueError(LLM_BASE_URL_NOT_ALLOWED) self._hostname = hostname self._port = port - # Re-validate each address against the same hostname-scoped local boundary. + # Re-validate each address; pass the hostname so Docker-container names + # in ALLOWED_LLM_BASE_URL_HOSTS are accepted. self._addresses = tuple( _validate_global_address(address, hostname=hostname) for address in addresses diff --git a/backend/tests/test_llm_provider_urls.py b/backend/tests/test_llm_provider_urls.py index 87ddb9475..16ab04e73 100644 --- a/backend/tests/test_llm_provider_urls.py +++ b/backend/tests/test_llm_provider_urls.py @@ -1,5 +1,3 @@ -import socket - import pytest from core.config import settings @@ -7,7 +5,6 @@ LLM_BASE_URL_NOT_ALLOWED, _is_ip_literal, _validate_global_address, - validate_llm_provider_base_url, ) @@ -54,83 +51,10 @@ def test_validate_global_address_multicast_ip_rejected(): _validate_global_address("224.0.0.1") -def test_validate_global_address_loopback_allowed_for_explicit_localhost(monkeypatch): - """Explicit local hostname identity may resolve to loopback under local opt-in.""" - monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) - assert ( - _validate_global_address("127.0.0.1", hostname="localhost") - == "127.0.0.1" - ) - - -def test_validate_global_address_loopback_rejected_without_local_host_identity( - monkeypatch, -): - """Local opt-in alone must not authorize a DNS-derived loopback address.""" - monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) - - with pytest.raises(ValueError, match=LLM_BASE_URL_NOT_ALLOWED): - _validate_global_address("127.0.0.1") - - -@pytest.mark.parametrize( - "provider_url, hostname", - [ - ("https://provider.example/v1", "provider.example"), - ("http://ollama:11434/v1", "ollama"), - ], -) -def test_validate_provider_base_url_rejects_loopback_dns_rebinding( - monkeypatch, provider_url, hostname -): - """An allowlisted non-local hostname must not gain loopback access via DNS.""" - monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) - monkeypatch.setattr(settings, "ALLOWED_LLM_BASE_URL_HOSTS", hostname) - - def resolve_to_loopback(resolved_hostname, port, *, type): - """Resolve the candidate hostname to loopback to simulate DNS rebinding.""" - assert resolved_hostname == hostname - assert type == socket.SOCK_STREAM - return [ - ( - socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP, - "", - ("127.0.0.1", port), - ) - ] - - monkeypatch.setattr(socket, "getaddrinfo", resolve_to_loopback) - - with pytest.raises(ValueError, match=LLM_BASE_URL_NOT_ALLOWED): - validate_llm_provider_base_url(provider_url) - - -def test_validate_provider_base_url_allows_explicit_localhost_loopback(monkeypatch): - """Explicit localhost remains usable when the local-provider opt-in is enabled.""" +def test_validate_global_address_loopback_allowed_when_settings_enabled(monkeypatch): + """Test that a loopback IP is allowed when ALLOW_LOCAL_LLM_PROVIDERS is True.""" monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) - - def resolve_localhost(resolved_hostname, port, *, type): - """Resolve the explicit local-development hostname to loopback.""" - assert resolved_hostname == "localhost" - assert type == socket.SOCK_STREAM - return [ - ( - socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP, - "", - ("127.0.0.1", port), - ) - ] - - monkeypatch.setattr(socket, "getaddrinfo", resolve_localhost) - - assert ( - validate_llm_provider_base_url("http://localhost:11434/v1") - == "http://localhost:11434/v1" - ) + assert _validate_global_address("127.0.0.1") == "127.0.0.1" def test_validate_global_address_private_allowed_when_host_allowed(monkeypatch): @@ -141,37 +65,6 @@ def test_validate_global_address_private_allowed_when_host_allowed(monkeypatch): assert _validate_global_address("192.168.1.5", hostname="ollama") == "192.168.1.5" -@pytest.mark.parametrize( - "address", - [ - "169.254.169.254", - "224.0.0.1", - "0.0.0.0", - "255.255.255.255", - "::", - "fe80::1", - "ff02::1", - ], -) -def test_validate_global_address_allowlisted_host_rejects_unsafe_address_classes( - monkeypatch, address -): - """Local-container opt-in must not authorize metadata or non-unicast addresses.""" - monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) - monkeypatch.setattr(settings, "ALLOWED_LLM_BASE_URL_HOSTS", "ollama") - - with pytest.raises(ValueError, match=LLM_BASE_URL_NOT_ALLOWED): - _validate_global_address(address, hostname="ollama") - - -def test_validate_global_address_allowlisted_host_accepts_unique_local_ipv6(monkeypatch): - """An allowlisted local provider may resolve to IPv6 unique-local space.""" - monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) - monkeypatch.setattr(settings, "ALLOWED_LLM_BASE_URL_HOSTS", "ollama") - - assert _validate_global_address("fd00::1", hostname="ollama") == "fd00::1" - - def test_validate_global_address_private_rejected_when_host_not_allowed(monkeypatch): """Test that a private IP is rejected even with ALLOW_LOCAL_LLM_PROVIDERS if the hostname is not allowed.""" monkeypatch.setattr(settings, "ALLOW_LOCAL_LLM_PROVIDERS", True) diff --git a/docs/doctoring/dav-and-local-provider-network-boundaries.md b/docs/doctoring/dav-and-local-provider-network-boundaries.md index 8976eba2a..dce148d4c 100644 --- a/docs/doctoring/dav-and-local-provider-network-boundaries.md +++ b/docs/doctoring/dav-and-local-provider-network-boundaries.md @@ -1,42 +1,47 @@ -# DAV single-decode and local LLM-provider network boundaries +# DAV/WebDAV single-decode and tenant boundary -## Decision +## Scope -Naruon treats the framework-decoded DAV application path as the authorization input. Authorization does not recursively decode the same string. Residual percent encodings are preserved when they remain literal data, and fail closed when another decode could introduce path separators, traversal dots, backslashes, C0/DEL controls, or NUL. This keeps routing, owner checks, logging, and DAV handlers on one canonical path representation. +This decision is limited to Naruon's DAV/WebDAV edge and workspace-document authorization boundary. The historical filename is retained to avoid churn, but LLM-provider routing, provider URL admission, DNS rebinding controls, and provider-network policy are not owned by this change. -For local LLM runtimes, `ALLOW_LOCAL_LLM_PROVIDERS` is an explicit development/deployment opt-in rather than a general exception from SSRF controls. Loopback addresses remain permitted only under that opt-in. An exact operator-allowlisted single-label provider hostname may additionally resolve only into the private address families intended for site/container networking: RFC 1918 IPv4 private-use networks or RFC 4193 IPv6 unique-local addresses. The allowlist does not authorize IPv4 link-local/metadata space, multicast, unspecified, reserved, broadcast, or other special-purpose non-global address classes. +## Problem -## Why this boundary is narrower than `is_private` +A DAV path can cross an authorization boundary if percent-encoding is decoded more than once. For example, a residual encoded slash can become a hierarchy separator during a second decode after the edge has already authorized a different canonical path. The DAV surface must also advertise only capabilities that the server actually implements, and workspace-document access must remain scoped by organization identity. -Python's IP classification helpers intentionally aggregate several non-global categories for convenience. Product authorization needs a positive description of the address classes that are actually required. RFC 1918 defines the three private IPv4 blocks used by private internets, and RFC 4193 defines IPv6 unique-local addresses for local communications. By contrast, RFC 3927 defines IPv4 link-local `169.254.0.0/16`, and RFC 6890 records special-purpose registry properties such as whether a block is globally reachable or forwardable. Therefore an operator hostname allowlist cannot safely mean "accept every address for which a library reports non-global/private". +## Decision -This positive-network contract also preserves the existing DNS-pinning design: every resolved address is validated against the same hostname-scoped policy before it can enter the pinned transport, and the transport revalidates the address again before connecting. +- Decode the incoming DAV path exactly once at the edge. +- Reject a decoded path that still contains a syntactically valid percent triplet whose second decoding could change path semantics. +- Normalize and authorize the repository-relative path only after that single decode. +- Downstream DAV/WebDAV services consume the canonical path and do not decode it again. +- Advertise DAV Level 1 capability truth only; do not claim unsupported DAV levels or extension tokens. +- Workspace-document reads and creates require the caller's `org_id` and preserve organization scoping through the repository boundary. -## Verification contract +These rules keep path interpretation, capability truth, and tenant authorization inside the Naruon DAV/document bounded context. -The security regression suite must prove all of the following: +## Verification -- an exact allowlisted local provider plus explicit local-provider opt-in can reach RFC 1918 container/private addresses; -- local-provider opt-in without exact hostname allowlisting does not admit RFC 1918 addresses; -- loopback remains conditional on the explicit local-provider opt-in; -- an allowlisted local provider still rejects `169.254.169.254`, multicast, unspecified, broadcast/reserved, and other non-authorized special-purpose classes; -- the DAV path contract rejects ambiguous residual structural encodings without recursively transforming literal percent data; -- the canonical DAV path is the same value used by authorization and downstream routing. +The executable contract is covered by: -The current slice does not claim that private-network access is generally safe, that DNS alone is an authorization mechanism, or that these controls replace tenant authorization, TLS identity, credential isolation, outbound method/path policy, or provider-specific authentication. +- `backend/tests/test_dav_path_canonicalization.py` +- `backend/tests/test_dav_auth.py` +- `backend/tests/test_dav_integration.py` +- `backend/tests/test_dav_propfind.py` +- `backend/tests/test_webdav_security.py` +- `backend/tests/test_workspace_document_tenancy.py` -## Rollback +The PR must also pass the repository's current exact-head required workflows. Evidence from a predecessor SHA is not merge authority. -If local-provider compatibility requires another address family, do not widen the exception to all non-global addresses. Add the smallest explicit network class only after a concrete deployment requirement, threat analysis, tests, and operator-visible configuration contract are established. If the DAV canonicalization contract changes, update authorization and route-level tests together so parsing and authorization cannot diverge. +## External owner boundary -## References (APA 7th) +LLM-provider selection, provider URL/routing policy, provider-network SSRF policy, DNS-rebinding protection, credentials, and fallback behavior belong to the released `contextual-orchestrator` owner path. This PR restores `backend/services/llm_provider_urls.py` and `backend/tests/test_llm_provider_urls.py` to the protected `develop` versions and does not change provider policy. Broader removal or migration of Naruon's protected legacy direct-provider implementation is handled by the canonical docs/governance and contextual-orchestrator integration lane rather than by this DAV repair. -Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource Identifier (URI): Generic syntax* (RFC 3986). RFC Editor. https://doi.org/10.17487/RFC3986 +## Rollback -Cheshire, S., Aboba, B., & Guttman, E. (2005). *Dynamic configuration of IPv4 link-local addresses* (RFC 3927). RFC Editor. https://doi.org/10.17487/RFC3927 +Rollback reverts only the DAV/document changes in this branch. It must not introduce a second decoder or expand DAV capability advertisement. Provider-policy files remain at the protected-base versions throughout this repair. -Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP address registries* (BCP 153, RFC 6890). RFC Editor. https://doi.org/10.17487/RFC6890 +## Traceability -Hinden, R., & Haberman, B. (2005). *Unique local IPv6 unicast addresses* (RFC 4193). RFC Editor. https://doi.org/10.17487/RFC4193 +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource Identifier (URI): Generic syntax* (RFC 3986, §§ 2.1, 2.4). Internet Engineering Task Force. https://doi.org/10.17487/RFC3986 -Rekhter, Y., Moskowitz, B., Karrenberg, D., de Groot, G. J., & Lear, E. (1996). *Address allocation for private internets* (BCP 5, RFC 1918). RFC Editor. https://doi.org/10.17487/RFC1918 +Dusseault, L. (Ed.). (2007). *HTTP extensions for Web Distributed Authoring and Versioning (WebDAV)* (RFC 4918, § 10.1). Internet Engineering Task Force. https://doi.org/10.17487/RFC4918