Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3dc8420
test(dav): reject ambiguous nested authorization encodings
seonghobae Aug 14, 2026
029c065
fix(dav): enforce single-decode authorization boundary
seonghobae Aug 14, 2026
532a0b4
test(dav): reproduce dropped normalized route path
seonghobae Aug 14, 2026
fae5b62
fix(dav): propagate canonical authorization path
seonghobae Aug 14, 2026
b0e530d
Merge branch 'develop' into fix/dav-single-decode-authorization
opencode-agent[bot] Aug 14, 2026
0a897db
test(dav): align route assertions with framework decoding
seonghobae Aug 14, 2026
9e82a88
Merge protected develop into DAV authorization fix
seonghobae Aug 14, 2026
f7abb82
Merge protected develop into DAV authorization fix
seonghobae Aug 14, 2026
b66af0d
test(security): reject unsafe local-provider address classes
seonghobae Aug 15, 2026
81c8890
fix(security): bound local LLM provider networks
seonghobae Aug 15, 2026
5b0a888
docs(security): record DAV and local-provider network boundaries
seonghobae Aug 15, 2026
60feaad
test(security): cover IPv6 local-provider network boundaries
seonghobae Aug 15, 2026
df2bf10
Merge protected develop into security fix lane
seonghobae Aug 15, 2026
d6a870d
test(data): reproduce cross-organization document access
seonghobae Aug 15, 2026
c05855f
test(data): inspect only document WHERE scope
seonghobae Aug 15, 2026
2b8a18f
fix(data): enforce document organization scope
seonghobae Aug 15, 2026
7cfa1bf
merge(develop): integrate current protected base
seonghobae Aug 15, 2026
17b3452
test(data): cover remaining document organization scopes
seonghobae Aug 15, 2026
0ca9624
test(data): use one api.data import style
seonghobae Aug 15, 2026
3a56fed
Merge branch 'develop' into fix/dav-single-decode-authorization
opencode-agent[bot] Aug 15, 2026
95f23c2
test(security): reject loopback DNS rebinding
seonghobae Aug 15, 2026
d73e9e4
fix(security): bind loopback access to local host identity
seonghobae Aug 15, 2026
7052b66
test(security): scope loopback opt-in to local identities
seonghobae Aug 15, 2026
76b5d16
test(dav): reject advertised unsupported capabilities
seonghobae Aug 16, 2026
a094523
fix(dav): expose only implemented protocol capabilities
seonghobae Aug 16, 2026
085aecf
Merge branch 'develop' into fix/dav-single-decode-authorization
seonghobae Aug 17, 2026
bde6998
Merge branch 'develop' into fix/dav-single-decode-authorization
seonghobae Aug 17, 2026
ff6e475
merge(develop): reconcile DAV/SSRF/tenant isolation onto current develop
cursoragent Aug 17, 2026
89d8850
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 20, 2026
07954b2
Merge branch 'develop' into fix/dav-single-decode-authorization
opencode-agent[bot] Aug 22, 2026
8c6a51e
Merge branch 'develop' into fix/dav-single-decode-authorization
seonghobae Aug 26, 2026
8146c56
Merge remote-tracking branch 'origin/develop' into codex/pr1345-repair
seonghobae Sep 5, 2026
9a01989
fix(dav): restore canonical LLM owner boundary
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion backend/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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),
)
Expand Down
73 changes: 62 additions & 11 deletions backend/api/dav.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Comment thread
seonghobae marked this conversation as resolved.


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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _dav_path_owner_user_id(path: str) -> str | None:
Expand Down Expand Up @@ -173,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":
Expand All @@ -190,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 "
Expand Down
46 changes: 31 additions & 15 deletions backend/services/llm_provider_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
)
Comment thread
seonghobae marked this conversation as resolved.
Outdated


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:
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions backend/tests/test_data_document_authorization.py
Original file line number Diff line number Diff line change
@@ -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_scope = str(statement.whereclause)
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_scope:
return _ScalarResult(self.document)

if self.document.organization_id is None:
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)


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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading