Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
141 changes: 79 additions & 62 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,68 @@

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(
{*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 @@ -158,73 +210,38 @@ 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``.
"""
_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":
headers = {
"DAV": "1, 2, 3, calendar-access, addressbook",
"Allow": (
"OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, "
"PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT"
),
}
return Response(status_code=200, headers=headers)

if request.method == "PROPFIND":
return await _handle_project_propfind(
request=request,
path=path,
auth_context=auth_context,
db=db,
)

if request.method == "PUT":
body = await request.body()
safe_path = repr(path)[1:-1]
logger.info("DAV PUT received %s bytes at /%s", len(body), safe_path)
logger.warning(
"DAV PUT rejected at /%s: provider-backed DAV writeback is not "
"implemented; signed writeback-intent API is required",
safe_path,
)
return Response(
content=(
"Provider-backed DAV writeback is not implemented; use signed "
"writeback-intent APIs until source, capability, and "
"ETag/If-Match checks are enforced."
),
media_type="text/plain",
status_code=501,
status_code=200,
headers={
"DAV": "1",
"Allow": ", ".join(IMPLEMENTED_DAV_METHODS),
},
)

logger.warning(
"DAV %s rejected at /%s: method is not implemented for the "
"provider-backed DAV gateway",
request.method,
safe_path,
)
return Response(
content=(
"Provider-backed DAV method is not implemented; use supported "
"PROPFIND/OPTIONS discovery or signed writeback-intent APIs."
),
media_type="text/plain",
status_code=501,
return await _handle_project_propfind(
request=request,
path=normalized_path,
auth_context=auth_context,
db=db,
)
Loading
Loading