Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5673c53
fix(security,api): harden CardDAV paths and opaque prompt identifiers
seonghobae Aug 4, 2026
6b8a5af
ci: revalidate security hardening on current head
seonghobae Aug 4, 2026
aa26b22
merge(develop): refresh organization isolation and CardDAV hardening
seonghobae Aug 4, 2026
ed3ed29
merge(develop): refresh security and opaque API hardening
seonghobae Aug 6, 2026
0547162
Merge branch 'develop' into goal/carddav-path-traversal-decode
seonghobae Aug 7, 2026
00205e7
Merge protected develop into security API fixes
seonghobae Aug 14, 2026
d57f161
Merge branch 'develop' into goal/carddav-path-traversal-decode
opencode-agent[bot] Aug 14, 2026
b1fd5dd
Merge protected develop into goal/carddav-path-traversal-decode
seonghobae Aug 15, 2026
ad7987a
Merge develop into goal/carddav-path-traversal-decode
seonghobae Aug 15, 2026
afe981b
test(carddav): reject ambiguous nested path encoding
seonghobae Aug 15, 2026
0715733
test(carddav): reject invalid UTF-8 path octets
seonghobae Aug 15, 2026
952f359
fix(carddav): enforce single-decode TXT path semantics
seonghobae Aug 15, 2026
d5e4286
docs(carddav): record TXT path decode boundary
seonghobae Aug 15, 2026
4c8776e
refactor(carddav): leave document isolation to canonical security lane
seonghobae Aug 15, 2026
808a54c
Merge branch 'develop' into goal/carddav-path-traversal-decode
opencode-agent[bot] Aug 15, 2026
d235e7c
test(auth): prove trusted OIDC admin sessions
seonghobae Aug 15, 2026
eefca3d
fix(auth): trust admin roles only from verified OIDC
seonghobae Aug 15, 2026
59299a7
test(security): prove system admin source-policy boundary
seonghobae Aug 16, 2026
cc90e1e
fix(security): admit system admins inside source tenant boundary
seonghobae Aug 16, 2026
a9c9535
Merge branch 'develop' into goal/carddav-path-traversal-decode
seonghobae Aug 17, 2026
7bdc601
Merge branch 'develop' into goal/carddav-path-traversal-decode
seonghobae Aug 17, 2026
8b99f20
merge(develop): reconcile CardDAV single-decode onto current develop
cursoragent Aug 17, 2026
f15542c
Merge branch 'develop' into goal/carddav-path-traversal-decode
opencode-agent[bot] Aug 20, 2026
d7ae476
Merge branch 'develop' into goal/carddav-path-traversal-decode
opencode-agent[bot] Aug 21, 2026
cb55a7e
Merge branch 'develop' into goal/carddav-path-traversal-decode
seonghobae Aug 26, 2026
a280815
Merge branch 'develop' into goal/carddav-path-traversal-decode
seonghobae Sep 1, 2026
4cae8cd
test(prompts): pin opaque response identifier schema
seonghobae Sep 1, 2026
2533f3a
docs(prompts): record opaque response identifier contract
seonghobae Sep 1, 2026
2389f0b
test(security): reject implicit orgless admin delegation
seonghobae Sep 1, 2026
5eab848
fix(security): require concrete tenant for admin delegation
seonghobae Sep 1, 2026
aba2a03
test(carddav): remove duplicate asyncio marker
seonghobae Sep 1, 2026
43467ed
test(carddav): preserve encoded reserved path identity
seonghobae Sep 7, 2026
882c1dd
fix(carddav): preserve encoded reserved path characters
seonghobae Sep 7, 2026
80a56be
docs(carddav): distinguish validation from reserved wire identity
seonghobae Sep 7, 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
23 changes: 11 additions & 12 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,12 @@ def build_auth_context(authorization: str | None = None) -> AuthContext:
"""
Build runtime identity from verified signed session material.

Client-supplied identity metadata is not authentication material. Only a
bearer token signed by the configured control-plane HMAC secret can supply
identity, role, organization, group, and workspace claims in the runtime
dependency path. Endpoint tests that need fixture identities must continue to
use explicit FastAPI dependency overrides.
Client-supplied identity metadata is not authentication material. Runtime
identity comes only from a bearer token verified by the configured OIDC/JWKS
provider or the control-plane HMAC compatibility secret. Admin roles are
accepted only from the verified OIDC authority; HMAC compatibility sessions
cannot assert admin membership. Endpoint tests that need fixture identities
must continue to use explicit FastAPI dependency overrides.
"""
payload, session_verifier = _verify_signed_session_payload(authorization)
return _auth_context_from_session_payload(payload, session_verifier)
Expand Down Expand Up @@ -374,7 +375,6 @@ def _verify_signed_session_token(token: str) -> tuple[dict[str, Any], SessionVer
raise _authentication_error()
try:
payload = _decode_cached_oidc_session_payload(token)
_reject_signed_session_admin_payload(payload)
return payload, "oidc"
except Exception:
raise _authentication_error() from None
Expand Down Expand Up @@ -403,16 +403,15 @@ def _verify_signed_session_token(token: str) -> tuple[dict[str, Any], SessionVer
raise _authentication_error()
if not isinstance(payload, dict):
raise _authentication_error()
_reject_signed_session_admin_payload(payload)
_reject_hmac_admin_payload(payload)
return payload, "hmac"


def _reject_signed_session_admin_payload(payload: dict[str, Any]) -> None:
def _reject_hmac_admin_payload(payload: dict[str, Any]) -> None:
"""Reject admin membership claims from the HMAC compatibility credential."""
role_claim = payload.get("role")
if not isinstance(role_claim, str):
raise _authentication_error()
# Admin roles require explicit server-side assignment, not externally
# supplied HMAC or enterprise OIDC session claims.
if role_claim in ADMIN_ROLES:
raise _authentication_error()

Expand Down Expand Up @@ -512,7 +511,7 @@ def _auth_context_from_session_payload(
if role_value not in ALLOWED_ROLES:
raise _authentication_error()
role = cast(RoleName, role_value)
if role in TENANT_ADMIN_ROLES and session_verifier not in ("server", "override"):
if role in ADMIN_ROLES and session_verifier not in ("oidc", "server", "override"):
raise _authentication_error()
organization_id = _optional_string_claim(payload, "org")
if organization_id is None:
Expand Down Expand Up @@ -542,4 +541,4 @@ async def get_current_workspace_id(
async def get_current_user_role(
auth_context: AuthContext = Depends(get_auth_context),
) -> str:
return auth_context.role
return auth_context.role
1 change: 0 additions & 1 deletion backend/api/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class PromptCreate(BaseModel):


class PromptResponse(BaseModel):
id: int
prompt_uid: str
title: str
description: Optional[str] = None
Expand Down
10 changes: 9 additions & 1 deletion backend/api/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ def _source_policy(
workspace_id: str,
writeback_enabled: bool,
) -> ResourcePolicy:
"""Build a source policy that preserves tenant scope for every admin tier."""
delegated_user_ids: tuple[str, ...] = (
(auth_context.user_id,)
if (
Expand All @@ -281,7 +282,14 @@ def _source_policy(
return ResourcePolicy(
owner_id=owner_id,
organization_id=organization_id,
permitted_roles=("tenant_admin", "organization_admin", "group_admin", "member"),
permitted_roles=(
"system_admin",
"platform_admin",
"tenant_admin",
"organization_admin",
"group_admin",
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
"member",
),
permitted_group_ids=auth_context.group_ids,
data_region=settings.DATA_REGION,
required_consent_scopes=required_consent,
Expand Down
35 changes: 25 additions & 10 deletions backend/services/carddav_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@

import ipaddress
import logging
import re
import socket
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from urllib.parse import urljoin, urlsplit, urlunsplit
from unicodedata import category
from urllib.parse import unquote, urljoin, urlsplit, urlunsplit

import httpx

Expand All @@ -43,6 +45,9 @@
TxtResolver = Callable[[str], list[str]]
HttpClientFactory = Callable[[], Any]

_MALFORMED_PERCENT_TRIPLET = re.compile(r"%(?![0-9A-Fa-f]{2})")
_REMAINING_PERCENT_TRIPLET = re.compile(r"%[0-9A-Fa-f]{2}")


@dataclass(frozen=True)
class CarddavDiscoveryResult:
Expand Down Expand Up @@ -217,25 +222,35 @@ def _default_txt_resolver(name: str) -> list[str]:


def _txt_context_path(records: list[str]) -> str | None:
"""Extract and validate the RFC 6764 Section 6 TXT ``path`` hint."""
"""Extract and validate a singly decoded RFC 6764 TXT ``path`` hint."""
for record in records:
for part in record.split(";"):
key, _, value = part.strip().partition("=")
if key.strip().lower() != "path":
continue
path = value.strip()
if _MALFORMED_PERCENT_TRIPLET.search(path):
continue
try:
decoded_path = unquote(path, errors="strict")
Comment thread
seonghobae marked this conversation as resolved.
except UnicodeDecodeError:
continue
if _REMAINING_PERCENT_TRIPLET.search(decoded_path):
# A second decode would change the request target. Reject the
# ambiguous value instead of inventing a recursive decode count.
continue
if (
path.startswith("/")
and "://" not in path
and "\\" not in path
and "?" not in path
and "#" not in path
decoded_path.startswith("/")
and "://" not in decoded_path
and "\\" not in decoded_path
and "?" not in decoded_path
and "#" not in decoded_path
and all(
segment not in {".", ".."} for segment in path.split("/")
segment not in {".", ".."} for segment in decoded_path.split("/")
)
and all(ord(ch) >= 32 and ord(ch) != 127 for ch in path)
and all(category(ch) != "Cc" for ch in decoded_path)
):
return path
return decoded_path
Comment thread
seonghobae marked this conversation as resolved.
Outdated
return None


Expand Down
92 changes: 92 additions & 0 deletions backend/tests/test_auth_oidc_admin_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Focused contracts for high-privilege OIDC session authority."""

import time

import pytest
from fastapi import HTTPException

from api import auth as auth_module
from core.config import settings


ADMIN_ROLES = (
"system_admin",
"platform_admin",
"tenant_admin",
"organization_admin",
)


def _oidc_payload(role: str) -> dict[str, object]:
"""Build a short-lived payload from the configured authoritative OIDC issuer."""
return {
"iss": "https://login.example.test/realms/naruon",
"aud": "naruon-api",
"sub": "alice",
"role": role,
"org": "org-acme",
"groups": ["group-1"],
"workspace": "workspace-org-acme",
"exp": int(time.time()) + 300,
}


def _hmac_payload(role: str) -> dict[str, object]:
"""Build compatibility-session metadata without granting membership authority."""
return {
"ver": 1,
"iss": auth_module.SESSION_ISSUER,
"aud": auth_module.SESSION_AUDIENCE,
"sub": "alice",
"role": role,
"org": "org-acme",
"groups": ["group-1"],
"workspace": "workspace-org-acme",
"exp": int(time.time()) + 300,
}


@pytest.mark.parametrize("admin_role", ADMIN_ROLES)
def test_trusted_oidc_session_can_supply_admin_role(monkeypatch, admin_role: str) -> None:
"""A configured JWKS-backed IdP remains usable for authorized administrators."""
previous_issuer_url = settings.OIDC_ISSUER_URL
previous_client_id = settings.OIDC_CLIENT_ID
settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon"
settings.OIDC_CLIENT_ID = "naruon-api"
payload = _oidc_payload(admin_role)

monkeypatch.setattr(auth_module, "jwks_client", object())
monkeypatch.setattr(
auth_module,
"_decode_cached_oidc_session_payload",
lambda _token: payload,
)

try:
verified_payload, verifier = auth_module._verify_signed_session_token(
"trusted-idp-token"
)
context = auth_module._auth_context_from_session_payload(
verified_payload, verifier
)
finally:
settings.OIDC_ISSUER_URL = previous_issuer_url
settings.OIDC_CLIENT_ID = previous_client_id

assert verifier == "oidc"
assert context.role == admin_role
assert context.organization_id == "org-acme"
assert context.workspace_id == "workspace-org-acme"
assert context.session_verifier == "oidc"


@pytest.mark.parametrize("admin_role", ADMIN_ROLES)
def test_hmac_compatibility_session_cannot_supply_admin_role(admin_role: str) -> None:
"""HMAC compatibility credentials never become authoritative admin membership."""
with pytest.raises(HTTPException) as exc:
auth_module._auth_context_from_session_payload(
_hmac_payload(admin_role), "hmac"
)

assert exc.value.status_code == 401
assert exc.value.detail == "Authentication required"
30 changes: 30 additions & 0 deletions backend/tests/test_carddav_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,36 @@ def txt_resolver(name):
assert result.base_url == "https://dav.example.com/"


@pytest.mark.asyncio
@pytest.mark.parametrize(
"txt_path",
[
"/%2e%2e%2fescape",
"/%252e%252e%252fescape",
"/%5c..%5cescape",
"/%255c..%255cescape",
"/safe%0aheader",
],
)
@pytest.mark.asyncio
async def test_encoded_unsafe_txt_path_is_ignored(txt_path):
Comment thread
seonghobae marked this conversation as resolved.
Outdated
response = FakeResponse(404)

def resolver(name):
if name == "_carddavs._tcp.example.com":
return [("dav.example.com", 443)]
return []

result = await discover_carddav(
"example.com",
http_client_factory=_factory(response),
srv_resolver=resolver,
txt_resolver=lambda name: [f"path={txt_path}"],
)
assert result is not None
assert result.base_url == "https://dav.example.com/"


@pytest.mark.asyncio
async def test_no_discovery_returns_none():
response = FakeResponse(404)
Expand Down
44 changes: 44 additions & 0 deletions backend/tests/test_carddav_encoded_path_canonicalization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Regression tests for canonical CardDAV TXT context-path execution."""

import pytest

from services.carddav_discovery import _txt_context_path


def test_fully_encoded_leading_slash_is_canonicalized() -> None:
"""Execute the same singly decoded representation that passed validation."""
assert _txt_context_path(["path=%2Fsafe"]) == "/safe"


def test_percent_encoded_unicode_path_is_canonicalized() -> None:
"""Preserve a safe Unicode path after one percent-decoding pass."""
assert _txt_context_path(["path=/%EC%A3%BC%EC%86%8C%EB%A1%9D"]) == "/주소록"


@pytest.mark.parametrize(
"txt_path",
[
"/literal%252Fsegment",
"/%252e%252e%252fescape",
"/safe%2525control",
],
)
def test_nested_percent_encoding_is_rejected(txt_path: str) -> None:
"""Reject values whose meaning would change under a second decode pass."""
assert _txt_context_path([f"path={txt_path}"]) is None


@pytest.mark.parametrize("txt_path", ["/safe%", "/safe%2", "/safe%2G"])
def test_malformed_percent_triplets_are_rejected(txt_path: str) -> None:
"""Reject malformed URI percent encodings instead of forwarding ambiguity."""
assert _txt_context_path([f"path={txt_path}"]) is None


def test_invalid_utf8_percent_octet_is_rejected() -> None:
"""Reject invalid UTF-8 rather than accepting a replacement-character path."""
assert _txt_context_path(["path=/safe%FF"]) is None


def test_encoded_literal_percent_is_preserved_after_one_decode() -> None:
"""Allow a single encoded percent when it does not form another triplet."""
assert _txt_context_path(["path=/discount-100%25"]) == "/discount-100%"
5 changes: 5 additions & 0 deletions backend/tests/test_carddav_unicode_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from services.carddav_discovery import _txt_context_path


def test_txt_context_path_rejects_unicode_c1_control():
assert _txt_context_path(["path=/safe%C2%85header"]) is None
11 changes: 10 additions & 1 deletion backend/tests/test_prompts_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,22 @@ def test_prompt_crud(auth_client):
data = resp.json()
assert data["title"] == "Test Prompt"
assert data["prompt_uid"].startswith("prompt_")
# Identity is the opaque prompt_uid; the sequential DB surrogate must never
# be exposed on the API surface (see CLAUDE.md "never expose sequential
# database ids").
assert "id" not in data
assert mock_session.items[0].organization_id == "org-acme"
assert mock_session.items[0].workspace_id == "workspace-org-acme"

# List
resp = auth_client.get("/api/prompts")
assert resp.status_code == 200
assert len(resp.json()) == 1
listed = resp.json()
assert len(listed) == 1
assert all(
"id" not in item and item["prompt_uid"].startswith("prompt_")
for item in listed
)


def test_prompt_list_scopes_shared_prompts_to_current_workspace(auth_client):
Expand Down
Loading
Loading