Harden external file connector boundaries

This commit is contained in:
2026-07-21 12:10:23 +02:00
parent 3bc1d3489e
commit f2dfb6c90e
18 changed files with 1167 additions and 64 deletions

View File

@@ -7,7 +7,7 @@ from dataclasses import replace
from datetime import datetime
from io import BytesIO
from typing import Any, Literal
from urllib.parse import quote, urljoin
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from starlette.background import BackgroundTask
@@ -120,6 +120,10 @@ from govoplan_files.backend.storage.connector_browse import (
normalize_connector_browse_path,
)
from govoplan_files.backend.storage.connector_imports import ConnectorImportError, ConnectorImportUnsupported, read_connector_file
from govoplan_files.backend.storage.connector_deployment import (
connector_effective_endpoint_url,
reject_api_controlled_deployment_references,
)
from govoplan_files.backend.storage.connector_profile_store import (
connector_profile_from_row,
create_connector_profile_row,
@@ -571,11 +575,48 @@ def _discovery_profile_from_payload(
password_value=credentials.password,
token_value=credentials.token,
capabilities=("browse",),
metadata=payload.metadata,
# Discovery metadata is untrusted API input and must not be able to
# replace the candidate endpoint or inject a static/fake listing.
metadata={},
source_kind="discovery",
)
def _audit_connector_discovery_attempt(
session: Session,
principal: ApiPrincipal,
*,
provider: str,
endpoint_url: str,
base_path: str | None,
) -> None:
audit_from_principal(
session,
principal,
action="files.connector.discovery_attempted",
object_type="connector_endpoint",
object_id=provider,
details={
"provider": provider,
"endpoint_origin": _redacted_connector_url(endpoint_url),
"base_path": base_path,
},
commit=True,
)
def _redacted_connector_url(value: str) -> str:
try:
parsed = urlsplit(value)
hostname = parsed.hostname or ""
if ":" in hostname:
hostname = f"[{hostname}]"
netloc = f"{hostname}:{parsed.port}" if parsed.port is not None else hostname
return urlunsplit((parsed.scheme, netloc, "", "", ""))
except ValueError:
return "<invalid connector URL>"
def _visible_connector_profile(
session: Session,
principal: ApiPrincipal,
@@ -768,7 +809,11 @@ def _download_connector_payload(
provider=profile.provider,
external_id=f"{payload.library_id}:{source_path}",
external_path=source_path,
external_url=profile.endpoint_url,
external_url=connector_effective_endpoint_url(
provider=profile.provider,
endpoint_url=profile.endpoint_url,
metadata=profile.metadata,
),
operation=operation,
),
profile.policy_sources,
@@ -1108,7 +1153,11 @@ def _connector_space_policy_decision(
provider=profile.provider,
external_id=external_id,
external_path=browse_path,
external_url=profile.endpoint_url,
external_url=connector_effective_endpoint_url(
provider=profile.provider,
endpoint_url=profile.endpoint_url,
metadata=profile.metadata,
),
operation=operation,
),
profile.policy_sources,
@@ -2373,8 +2422,17 @@ def list_connector_providers(
@router.post("/connectors/discover", response_model=FileConnectorDiscoveryResponse)
def discover_connector_endpoint(
payload: FileConnectorDiscoveryRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("files:file:admin", "system:settings:write", "admin:settings:write")),
):
try:
reject_api_controlled_deployment_references(
password_env=payload.credentials.password_env,
token_env=payload.credentials.token_env,
metadata=payload.metadata,
)
except ValueError as exc:
raise _http_error(exc) from exc
if payload.provider not in {"webdav", "nextcloud"}:
return FileConnectorDiscoveryResponse(
provider=payload.provider,
@@ -2387,7 +2445,28 @@ def discover_connector_endpoint(
for endpoint_url in _webdav_discovery_candidates(payload):
profile = _discovery_profile_from_payload(payload, endpoint_url, principal=principal)
try:
_ensure_connector_configuration_allowed(
session,
principal,
connector_id=None,
credential_id=None,
provider=profile.provider,
endpoint_url=endpoint_url,
base_path=profile.base_path,
scope_type="tenant",
scope_id=principal.tenant_id,
operation="discover",
)
_audit_connector_discovery_attempt(
session,
principal,
provider=profile.provider,
endpoint_url=endpoint_url,
base_path=profile.base_path,
)
browse_connector_profile(profile, path=payload.base_path or "")
except ConnectorPolicyDenied as exc:
raise _connector_policy_error(exc) from exc
except (ConnectorBrowseError, ConnectorBrowseUnsupported, OSError, ValueError, json.JSONDecodeError) as exc:
message = str(exc)
if "credentials were rejected" in message.casefold():
@@ -2400,7 +2479,7 @@ def discover_connector_endpoint(
status="credentials_rejected",
message="The endpoint was found, but login failed with these credentials.",
candidates=candidates,
metadata={**payload.metadata, "discovered_by": "webdav-auth-challenge"},
metadata={"discovered_by": "webdav-auth-challenge"},
)
candidates.append({"endpoint_url": endpoint_url, "status": "found", "message": "The endpoint exists, but credentials are required or were rejected."})
return FileConnectorDiscoveryResponse(
@@ -2410,7 +2489,7 @@ def discover_connector_endpoint(
status="found",
message="The endpoint was found. Add working credentials before saving or testing the connection.",
candidates=candidates,
metadata={**payload.metadata, "discovered_by": "webdav-auth-challenge"},
metadata={"discovered_by": "webdav-auth-challenge"},
)
candidates.append({"endpoint_url": endpoint_url, "status": "failed", "message": message})
continue
@@ -2424,7 +2503,7 @@ def discover_connector_endpoint(
status=status_value,
message=message,
candidates=candidates,
metadata={**payload.metadata, "discovered_by": "webdav-propfind"},
metadata={"discovered_by": "webdav-propfind"},
)
return FileConnectorDiscoveryResponse(
provider=payload.provider,
@@ -2741,7 +2820,11 @@ def create_connector_profile(
connector_id=payload.id,
credential_id=payload.credential_profile_id,
provider=payload.provider,
endpoint_url=payload.endpoint_url,
endpoint_url=connector_effective_endpoint_url(
provider=payload.provider,
endpoint_url=payload.endpoint_url,
metadata=payload.metadata,
),
base_path=payload.base_path,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
@@ -2909,7 +2992,11 @@ def browse_connector_profile_items(
credential_id=profile.credential_profile_id,
provider=profile.provider,
external_path=browse_path,
external_url=profile.endpoint_url,
external_url=connector_effective_endpoint_url(
provider=profile.provider,
endpoint_url=profile.endpoint_url,
metadata=profile.metadata,
),
operation="browse",
),
profile.policy_sources,
@@ -2986,7 +3073,11 @@ def update_connector_profile(
connector_id=row.id,
credential_id=credential_profile_id,
provider=provider,
endpoint_url=payload.endpoint_url if payload.endpoint_url is not None else row.endpoint_url,
endpoint_url=connector_effective_endpoint_url(
provider=provider,
endpoint_url=payload.endpoint_url if payload.endpoint_url is not None else row.endpoint_url,
metadata=payload.metadata if payload.metadata is not None else row.metadata_,
),
base_path=payload.base_path if payload.base_path is not None else row.base_path,
scope_type=row.scope_type,
scope_id=row.scope_id,