Harden external file connector boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user