security: harden connector policy and add dashboard widget

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 93eea839c8
commit 5b868272b9
9 changed files with 416 additions and 32 deletions

View File

@@ -209,6 +209,7 @@ manifest = ModuleManifest(
ViewSurface(id="files.admin.group-connectors", module_id="files", kind="section", label="Group file connections", order=65),
ViewSurface(id="files.admin.user-connectors", module_id="files", kind="section", label="User file connections", order=65),
ViewSurface(id="files.settings.connectors", module_id="files", kind="section", label="Personal file connections", order=20),
ViewSurface(id="files.widget.spaces", module_id="files", kind="section", label="File spaces widget", order=35),
),
),
documentation=(

View File

@@ -348,8 +348,8 @@ def _file_connector_policy_resource_id(scope_type: str, scope_id: str | None) ->
return f"{scope_type.strip().casefold()}:{scope_id or ''}"
async def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes:
data = await upload.read(max_bytes + 1)
def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes:
data = upload.file.read(max_bytes + 1)
if len(data) > max_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
@@ -358,12 +358,12 @@ async def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes:
return data
async def _spool_limited_upload_to_temp(upload: UploadFile, *, max_bytes: int, suffix: str = ".upload") -> str:
def _spool_limited_upload_to_temp(upload: UploadFile, *, max_bytes: int, suffix: str = ".upload") -> str:
tmp = tempfile.NamedTemporaryFile(prefix="govoplan-upload-", suffix=suffix, delete=False)
total = 0
try:
while True:
chunk = await upload.read(1024 * 1024)
chunk = upload.file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
@@ -1205,7 +1205,7 @@ def _cursor_page_size(scope: str, cursor: str | None, explicit_page_size: int |
if explicit_page_size is not None:
return explicit_page_size
if not cursor:
return None
return DEFAULT_FILE_LIST_PAGE_SIZE
try:
values = decode_keyset_cursor(scope, cursor)
except KeysetCursorError as exc:
@@ -2094,7 +2094,7 @@ def list_files(
@router.post("/upload", response_model=FileUploadResponse)
async def upload_files(
def upload_files(
files: list[UploadFile] = FastAPIFile(...),
owner_type: Literal["user", "group"] = Form(default="user"),
owner_id: str | None = Form(default=None),
@@ -2121,7 +2121,7 @@ async def upload_files(
content_type = upload.content_type or None
upload_limit = settings.file_upload_zip_max_bytes if unpack_zip and filename.lower().endswith(".zip") else settings.file_upload_max_bytes
if unpack_zip and filename.lower().endswith(".zip"):
zip_path = await _spool_limited_upload_to_temp(upload, max_bytes=upload_limit, suffix=".zip")
zip_path = _spool_limited_upload_to_temp(upload, max_bytes=upload_limit, suffix=".zip")
try:
extracted = extract_zip_upload(
session,
@@ -2143,7 +2143,7 @@ async def upload_files(
_cleanup_temp_file(zip_path)
uploaded_assets.extend(item.asset for item in extracted)
continue
data = await _read_limited_upload(upload, max_bytes=upload_limit)
data = _read_limited_upload(upload, max_bytes=upload_limit)
stored = create_file_asset(
session,
tenant_id=principal.tenant_id,
@@ -2173,7 +2173,7 @@ async def upload_files(
@router.post("/upload-zip", response_model=FileUploadResponse)
async def upload_zip(
def upload_zip(
file: UploadFile = FastAPIFile(...),
owner_type: Literal["user", "group"] = Form(default="user"),
owner_id: str | None = Form(default=None),
@@ -2194,7 +2194,7 @@ async def upload_zip(
upload_resolutions = _conflict_resolutions([ConflictResolutionRequest(**item) for item in raw_resolutions])
_enforce_connector_policy(source_provenance_json, connector_policy_json, operation="import")
metadata = _source_metadata_from_form(source_provenance_json, source_revision)
zip_path = await _spool_limited_upload_to_temp(file, max_bytes=settings.file_upload_zip_max_bytes, suffix=".zip")
zip_path = _spool_limited_upload_to_temp(file, max_bytes=settings.file_upload_zip_max_bytes, suffix=".zip")
extracted = extract_zip_upload(
session,
tenant_id=principal.tenant_id,

View File

@@ -246,11 +246,11 @@ def _applied_fields(policy: Mapping[str, Any]) -> tuple[str, ...]:
def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[str]) -> bool:
if field == "connectors":
return _matches_exact(request.connector_id, patterns)
return _matches_reference(request.connector_id, patterns)
if field == "credentials":
return _matches_exact(request.credential_id, patterns)
return _matches_reference(request.credential_id, patterns)
if field == "providers":
return _matches_exact(request.provider, patterns)
return _matches_reference(request.provider, patterns)
if field == "external_ids":
return _matches_glob(request.external_id, patterns)
if field == "external_paths":
@@ -260,11 +260,11 @@ def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[s
return False
def _matches_exact(value: str | None, patterns: list[str]) -> bool:
def _matches_reference(value: str | None, patterns: list[str]) -> bool:
if value is None:
return False
clean = value.casefold()
return any(pattern == "*" or clean == pattern.casefold() for pattern in patterns)
return any(fnmatchcase(clean, pattern.casefold()) for pattern in patterns)
def _matches_glob(value: str | None, patterns: list[str]) -> bool:

View File

@@ -185,15 +185,23 @@ class _OutboundPolicyHTTPTransport(httpx.BaseTransport):
self._connection_pool.close()
_CONNECTOR_HTTP_CLIENT = httpx.Client(
transport=_OutboundPolicyHTTPTransport(),
follow_redirects=False,
timeout=15.0,
)
@contextmanager
def _stream_connector_request(method: str, url: str, **kwargs: Any) -> Iterator[httpx.Response]:
with httpx.Client(
transport=_OutboundPolicyHTTPTransport(),
follow_redirects=False,
timeout=kwargs.pop("timeout", 15.0),
) as client:
with client.stream(method, url, **kwargs) as response:
yield response
timeout = kwargs.pop("timeout", 15.0)
with _CONNECTOR_HTTP_CLIENT.stream(
method,
url,
timeout=timeout,
**kwargs,
) as response:
yield response
def request_connector_bytes(