Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@
provider `base_url` values must fail closed unless they are HTTPS, exact-host
allowlisted by `ALLOWED_LLM_BASE_URL_HOSTS`, and resolve only to global
addresses.
- OIDC issuer and JWKS URLs are outbound identity-provider fetch surfaces. They
must use HTTPS, must not include userinfo or fragments, must reject localhost
and non-global IP literals, and must be exact-host allowlisted by
`ALLOWED_OIDC_HOSTS` before any JWKS preload or token verification path can
use them.
- Email-derived tasks must stay source-linked to the email/thread and tenant
owner scope. Do not expose new sequential database ids through task APIs; use
opaque public ids for user-visible ticket tasks. Task titles are plain text:
Expand Down Expand Up @@ -192,8 +197,10 @@
Pydantic import tracebacks for missing runtime settings.
- DAV/WebDAV/CalDAV routes are private integration surfaces unless explicitly
documented otherwise. Register them with the default signed-session dependency,
escape XML response fields before interpolation, and keep path values separate
from log-safe display values.
require the signed-session dependency in handler signatures, enforce route
owner scope before capability/discovery/read/writeback responses, escape XML
response fields before interpolation, and keep path values separate from
log-safe display values.
- Self-hosted runner WebSocket routes must validate both a signed bearer session
and a server-side WorkspaceRunnerConfig registration token before accepting the
socket. Do not use the raw path token as identity, a log value, or the sole
Expand Down
29 changes: 27 additions & 2 deletions backend/api/dav.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,48 @@
import logging
from html import escape as escape_xml_text

from fastapi import APIRouter, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response

from api.auth import AuthContext, get_auth_context

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/dav", tags=["dav"])


def _dav_path_owner_user_id(path: str) -> str | None:
normalized_path = path.strip("/")
if not normalized_path:
return None
owner_user_id, _, _ = normalized_path.partition("/")
return owner_user_id or None


def _ensure_dav_owner_scope(path: str, auth_context: AuthContext) -> None:
owner_user_id = _dav_path_owner_user_id(path)
if owner_user_id is None or owner_user_id == auth_context.user_id:
return
raise HTTPException(
status_code=403,
detail="DAV path belongs to a different user",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


@router.api_route(
"/{path:path}",
methods=["PROPFIND", "REPORT", "MKCOL", "GET", "PUT", "DELETE", "OPTIONS"],
)
async def dav_handler(request: Request, path: str):
async def dav_handler(
request: Request,
path: str,
auth_context: AuthContext = Depends(get_auth_context),
):
"""
Skeleton endpoint for CalDAV / WebDAV routing.
In the future, this will parse XML namespaces and bridge
Naruon's Tasks and Events into DAV compliant responses.
"""
_ensure_dav_owner_scope(path, auth_context)
safe_path = path.replace("\n", "").replace("\r", "")
logger.info("DAV Request: %s /%s", request.method, safe_path)

Expand Down
20 changes: 20 additions & 0 deletions backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from core.runtime_secrets import (
validate_auth_session_hmac_secret_value,
)
from core.url_validation import parse_allowed_hosts, validate_https_url_host


class Settings(BaseSettings):
Expand Down Expand Up @@ -39,6 +40,7 @@ class Settings(BaseSettings):
OIDC_ISSUER_URL: str | None = None
OIDC_CLIENT_ID: str | None = None
OIDC_JWKS_URL: str | None = None
ALLOWED_OIDC_HOSTS: str = ""

model_config = SettingsConfigDict(
env_file=("~/.env", "../.env", ".env"),
Expand Down Expand Up @@ -69,6 +71,24 @@ def validate_session_secret(self) -> "Settings":
raise ValueError(
"OIDC_ISSUER_URL, OIDC_CLIENT_ID, and OIDC_JWKS_URL must be set together"
)
if len(configured_oidc_values) == len(oidc_values):
allowed_oidc_hosts = parse_allowed_hosts(self.ALLOWED_OIDC_HOSTS)
if not allowed_oidc_hosts:
raise ValueError(
"ALLOWED_OIDC_HOSTS must list trusted OIDC issuer and JWKS hosts"
)
validate_https_url_host(
"OIDC_ISSUER_URL",
self.OIDC_ISSUER_URL or "",
allowed_oidc_hosts,
"ALLOWED_OIDC_HOSTS",
)
validate_https_url_host(
"OIDC_JWKS_URL",
self.OIDC_JWKS_URL or "",
allowed_oidc_hosts,
"ALLOWED_OIDC_HOSTS",
)
return self


Expand Down
56 changes: 56 additions & 0 deletions backend/core/url_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

import ipaddress
from urllib.parse import urlsplit


def parse_allowed_hosts(raw_hosts: str) -> frozenset[str]:
hosts: set[str] = set()
for raw_host in raw_hosts.split(","):
host = _normalize_host(raw_host)
if host:
hosts.add(host)
return frozenset(hosts)


def validate_https_url_host(
setting_name: str,
url_value: str,
allowed_hosts: frozenset[str],
allowed_hosts_setting_name: str,
) -> None:
parsed = urlsplit(url_value)
if parsed.scheme.lower() != "https":
raise ValueError(f"{setting_name} must use https")
if parsed.username or parsed.password:
raise ValueError(f"{setting_name} must not include userinfo")
if parsed.fragment:
raise ValueError(f"{setting_name} must not include a fragment")
if not parsed.hostname:
raise ValueError(f"{setting_name} must include a host")

host = _normalize_host(parsed.hostname)
if host not in allowed_hosts:
raise ValueError(
f"{setting_name} host must be listed in {allowed_hosts_setting_name}"
)
_reject_unsafe_ip_literal(setting_name, host)


def _normalize_host(raw_host: str) -> str:
host = raw_host.strip().lower().rstrip(".")
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
return host


def _reject_unsafe_ip_literal(setting_name: str, host: str) -> None:
try:
ip_address = ipaddress.ip_address(host)
except ValueError:
if host == "localhost" or host.endswith(".localhost"):
raise ValueError(f"{setting_name} host must not be localhost")
return

if not ip_address.is_global:
raise ValueError(f"{setting_name} IP host must be globally routable")
18 changes: 18 additions & 0 deletions backend/scripts/start_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path

from core.runtime_secrets import validate_auth_session_hmac_secret_value
from core.url_validation import parse_allowed_hosts, validate_https_url_host

DEFAULT_SERVER_HOST = "127.0.0.1"
DEFAULT_SERVER_PORT = 8000
Expand Down Expand Up @@ -83,6 +84,23 @@ def validate_runtime_settings() -> list[str]:
messages.append(
"OIDC_ISSUER_URL, OIDC_CLIENT_ID, and OIDC_JWKS_URL must be set together"
)
if len(configured_oidc) == len(OIDC_SETTINGS):
allowed_oidc_hosts = parse_allowed_hosts(values.get("ALLOWED_OIDC_HOSTS", ""))
if not allowed_oidc_hosts:
messages.append(
"ALLOWED_OIDC_HOSTS must list trusted OIDC issuer and JWKS hosts"
)
else:
for setting_name in ("OIDC_ISSUER_URL", "OIDC_JWKS_URL"):
try:
validate_https_url_host(
setting_name,
values[setting_name],
allowed_oidc_hosts,
"ALLOWED_OIDC_HOSTS",
)
except ValueError as exc:
messages.append(str(exc))

return messages

Expand Down
14 changes: 7 additions & 7 deletions backend/tests/test_auth_real.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ async def test_signed_bearer_session_with_oidc(monkeypatch):
previous_issuer_url = settings.OIDC_ISSUER_URL
previous_client_id = settings.OIDC_CLIENT_ID
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon"
settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon"
settings.OIDC_CLIENT_ID = "naruon-api"
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)

Expand All @@ -736,7 +736,7 @@ class MockKey:

def mock_jwt_decode(*args, **kwargs):
return {
"iss": "http://localhost:8081/realms/naruon",
"iss": "https://login.example.test/realms/naruon",
"aud": "naruon-api",
"sub": "alice",
"role": "tenant_admin",
Expand Down Expand Up @@ -771,7 +771,7 @@ async def test_oidc_rejects_unknown_critical_header_before_decode(monkeypatch):
previous_issuer_url = settings.OIDC_ISSUER_URL
previous_client_id = settings.OIDC_CLIENT_ID
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon"
settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon"
settings.OIDC_CLIENT_ID = "naruon-api"
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)

Expand Down Expand Up @@ -823,7 +823,7 @@ async def test_oidc_session_rejects_platform_system_admin_role_claim(
previous_issuer_url = settings.OIDC_ISSUER_URL
previous_client_id = settings.OIDC_CLIENT_ID
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon"
settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon"
settings.OIDC_CLIENT_ID = "naruon-api"
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)

Expand All @@ -836,7 +836,7 @@ class MockKey:

def mock_jwt_decode(*args, **kwargs):
return {
"iss": "http://localhost:8081/realms/naruon",
"iss": "https://login.example.test/realms/naruon",
"aud": "naruon-api",
"sub": "operator",
"role": role_claim,
Expand Down Expand Up @@ -870,7 +870,7 @@ async def test_oidc_validation_failure_does_not_fallback_to_signed_session(monke
previous_issuer_url = settings.OIDC_ISSUER_URL
previous_client_id = settings.OIDC_CLIENT_ID
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon"
settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon"
settings.OIDC_CLIENT_ID = "naruon-api"
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)

Expand Down Expand Up @@ -906,7 +906,7 @@ async def test_oidc_request_path_requires_preloaded_jwks(monkeypatch):
previous_issuer_url = settings.OIDC_ISSUER_URL
previous_client_id = settings.OIDC_CLIENT_ID
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon"
settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon"
settings.OIDC_CLIENT_ID = "naruon-api"
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)
monkeypatch.setattr("api.auth._cached_oidc_signing_keys", ())
Expand Down
66 changes: 66 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,73 @@ def test_oidc_settings_accept_complete_configuration(monkeypatch):
monkeypatch.setenv("OIDC_ISSUER_URL", "https://login.example.com/realms/naruon")
monkeypatch.setenv("OIDC_CLIENT_ID", "naruon-api")
monkeypatch.setenv("OIDC_JWKS_URL", "https://login.example.com/realms/naruon/jwks")
monkeypatch.setenv("ALLOWED_OIDC_HOSTS", "login.example.com")

loaded_settings = _settings_without_env_file()

assert loaded_settings.OIDC_CLIENT_ID == "naruon-api"


def test_oidc_settings_reject_missing_allowed_hosts(monkeypatch):
monkeypatch.setenv(
"DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test_db"
)
monkeypatch.setenv("AUTH_SESSION_HMAC_SECRET", TEST_AUTH_SESSION_HMAC_SECRET)
monkeypatch.setenv("OIDC_ISSUER_URL", "https://login.example.com/realms/naruon")
monkeypatch.setenv("OIDC_CLIENT_ID", "naruon-api")
monkeypatch.setenv("OIDC_JWKS_URL", "https://login.example.com/realms/naruon/jwks")
monkeypatch.delenv("ALLOWED_OIDC_HOSTS", raising=False)

with pytest.raises(
ValidationError,
match="ALLOWED_OIDC_HOSTS must list trusted OIDC issuer and JWKS hosts",
):
_settings_without_env_file()


def test_oidc_settings_reject_untrusted_jwks_host(monkeypatch):
monkeypatch.setenv(
"DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test_db"
)
monkeypatch.setenv("AUTH_SESSION_HMAC_SECRET", TEST_AUTH_SESSION_HMAC_SECRET)
monkeypatch.setenv("OIDC_ISSUER_URL", "https://login.example.com/realms/naruon")
monkeypatch.setenv("OIDC_CLIENT_ID", "naruon-api")
monkeypatch.setenv("OIDC_JWKS_URL", "https://metadata.google.internal/jwks")
monkeypatch.setenv("ALLOWED_OIDC_HOSTS", "login.example.com")

with pytest.raises(
ValidationError,
match="OIDC_JWKS_URL host must be listed in ALLOWED_OIDC_HOSTS",
):
_settings_without_env_file()


def test_oidc_settings_reject_non_https_issuer(monkeypatch):
monkeypatch.setenv(
"DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test_db"
)
monkeypatch.setenv("AUTH_SESSION_HMAC_SECRET", TEST_AUTH_SESSION_HMAC_SECRET)
monkeypatch.setenv("OIDC_ISSUER_URL", "http://login.example.com/realms/naruon")
monkeypatch.setenv("OIDC_CLIENT_ID", "naruon-api")
monkeypatch.setenv("OIDC_JWKS_URL", "https://login.example.com/realms/naruon/jwks")
monkeypatch.setenv("ALLOWED_OIDC_HOSTS", "login.example.com")

with pytest.raises(ValidationError, match="OIDC_ISSUER_URL must use https"):
_settings_without_env_file()


def test_oidc_settings_reject_private_ip_literal_even_when_allowlisted(monkeypatch):
monkeypatch.setenv(
"DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test_db"
)
monkeypatch.setenv("AUTH_SESSION_HMAC_SECRET", TEST_AUTH_SESSION_HMAC_SECRET)
monkeypatch.setenv("OIDC_ISSUER_URL", "https://127.0.0.1/realms/naruon")
monkeypatch.setenv("OIDC_CLIENT_ID", "naruon-api")
monkeypatch.setenv("OIDC_JWKS_URL", "https://127.0.0.1/jwks")
monkeypatch.setenv("ALLOWED_OIDC_HOSTS", "127.0.0.1")

with pytest.raises(
ValidationError,
match="OIDC_ISSUER_URL IP host must be globally routable",
):
_settings_without_env_file()
9 changes: 9 additions & 0 deletions backend/tests/test_dav_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ def test_dav_options(dev_auth_dependency_overrides):
assert "calendar-access" in response.headers.get("DAV", "")


def test_dav_rejects_different_user_path(dev_auth_dependency_overrides):
with TestClient(app) as client:
response = client.request(
"PROPFIND", "/dav/other-user/projects/", headers=AUTH_HEADERS
)
assert response.status_code == 403
assert response.json()["detail"] == "DAV path belongs to a different user"


def test_dav_propfind(dev_auth_dependency_overrides):
with TestClient(app) as client:
response = client.request(
Expand Down
Loading
Loading