diff --git a/tests/auth/test_oauth2_redirect.py b/tests/auth/test_oauth2_redirect.py new file mode 100644 index 000000000..937b45667 --- /dev/null +++ b/tests/auth/test_oauth2_redirect.py @@ -0,0 +1,60 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from starlette.requests import Request + +from veadk.auth.middleware.oauth2_auth import _resolve_redirect_after_auth + + +def _request() -> Request: + return Request( + { + "type": "http", + "method": "GET", + "scheme": "https", + "server": ("studio.example.com", 443), + "path": "/oauth2/login", + "query_string": b"", + "headers": [(b"host", b"studio.example.com")], + } + ) + + +@pytest.mark.parametrize( + "redirect", + [ + "//evil.example/phish", + r"/\\evil.example/phish", + "/safe\nlocation", + "https://evil.example/phish", + ], +) +def test_redirect_rejects_external_or_ambiguous_targets(redirect: str) -> None: + assert _resolve_redirect_after_auth(_request(), redirect) == "/" + + +@pytest.mark.parametrize( + ("redirect", "expected"), + [ + ("/agents?tab=mine", "/agents?tab=mine"), + ("agents", "/agents"), + ( + "https://studio.example.com/agents", + "https://studio.example.com/agents", + ), + ], +) +def test_redirect_keeps_local_targets(redirect: str, expected: str) -> None: + assert _resolve_redirect_after_auth(_request(), redirect) == expected diff --git a/tests/cli/test_generated_agent_backend_codegen.py b/tests/cli/test_generated_agent_backend_codegen.py index 6b85ea672..d129b4e8a 100644 --- a/tests/cli/test_generated_agent_backend_codegen.py +++ b/tests/cli/test_generated_agent_backend_codegen.py @@ -105,6 +105,45 @@ def test_project_policy_allows_mcp_stdio_but_debug_rejects_it() -> None: validate_debug_policy(draft, allow_local_runtime_resources=True) +def test_debug_policy_rejects_custom_model_api_base() -> None: + draft = AgentDraft( + name="demo", + modelApiBase="https://attacker.example/api/v3", + ) + + validate_project_policy(draft) + with pytest.raises(DebugPolicyError, match="Custom modelApiBase"): + validate_debug_policy(draft) + + +@pytest.mark.parametrize( + "model_api_base", + [ + "https://ark.cn-beijing.volces.com/api/v3/", + "https://ark.ap-southeast.bytepluses.com/api/v3", + ], +) +def test_debug_policy_allows_builtin_model_api_bases(model_api_base: str) -> None: + validate_debug_policy(AgentDraft(name="demo", modelApiBase=model_api_base)) + + +def test_debug_policy_requires_https_for_external_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *_args, **_kwargs: [(socket.AF_INET, 0, 0, "", ("203.0.113.10", 443))], + ) + draft = AgentDraft( + name="demo", + mcpTools=[{"transport": "http", "url": "http://tools.example/mcp"}], + ) + + with pytest.raises(DebugPolicyError, match="must use https"): + validate_debug_policy(draft) + + def test_security_rejects_enabled_a2a_registry_without_space_id() -> None: draft = AgentDraft( name="demo", diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py index 9bec29f42..7fe238b79 100644 --- a/tests/cli/test_studio_rbac.py +++ b/tests/cli/test_studio_rbac.py @@ -728,6 +728,104 @@ def test_gateway_role_uses_jwt_and_ignores_local_identity_header( assert response.json()["role"] == "admin" +def test_user_cannot_enumerate_server_credential_resources( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + app = _create_studio_app( + monkeypatch, + tmp_path, + auth_mode="gateway", + admins="admin", + developers="developer", + ) + headers = {"Authorization": f"Bearer {_unsigned_jwt({'sub': 'reader'})}"} + + with TestClient(app) as client: + responses = [ + client.get("/web/a2a-spaces", headers=headers), + client.get("/web/viking-knowledgebases", headers=headers), + client.get("/web/skill-spaces", headers=headers), + client.get("/web/skill-spaces/space-1/skills", headers=headers), + client.get("/web/skill-spaces/space-1/skills/skill-1", headers=headers), + ] + + assert [response.status_code for response in responses] == [403] * 5 + + +def test_user_cannot_access_another_users_local_sessions_or_media( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + app = _create_studio_app( + monkeypatch, + tmp_path, + auth_mode="gateway", + admins="admin", + developers="developer", + ) + headers = {"Authorization": f"Bearer {_unsigned_jwt({'sub': 'reader'})}"} + + async def _echo_run_body(request: Request) -> dict[str, Any]: + return await request.json() + + app.add_api_route("/run_sse", _echo_run_body, methods=["POST"]) + app.router.routes.insert(0, app.router.routes.pop()) + + with TestClient(app) as client: + own_upload = client.post( + "/web/media", + headers=headers, + data={ + "app_name": "demo", + "user_id": "reader", + "session_id": "session", + }, + files={"file": ("canary.txt", b"owner canary", "text/plain")}, + ) + cross_upload = client.post( + "/web/media", + headers=headers, + data={ + "app_name": "demo", + "user_id": "owner", + "session_id": "session", + }, + files={"file": ("canary.txt", b"cross user", "text/plain")}, + ) + cross_session = client.get( + "/apps/site-packages/users/owner/sessions/session", + headers=headers, + ) + cross_delete = client.delete( + "/apps/site-packages/users/owner/sessions/session", + headers=headers, + ) + own_run_payload = { + "appName": "site-packages", + "userId": "reader", + "sessionId": "session", + } + own_run = client.post( + "/run_sse", + headers=headers, + json=own_run_payload, + ) + cross_run = client.post( + "/run_sse", + headers=headers, + json={"appName": "site-packages", "userId": "owner"}, + ) + + assert own_upload.status_code == 200 + assert cross_upload.status_code == 403 + assert cross_session.status_code == 403 + assert cross_delete.status_code == 403 + assert own_run.status_code == 200 + assert own_run.json() == own_run_payload + assert cross_run.status_code == 403 + + def test_non_admin_runtime_list_uses_one_owner_filtered_request( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/multimodal/test_api.py b/tests/multimodal/test_api.py index aa678d3c2..d1bca8f93 100644 --- a/tests/multimodal/test_api.py +++ b/tests/multimodal/test_api.py @@ -15,7 +15,7 @@ from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from veadk.multimodal.api import mount_media_routes @@ -75,3 +75,43 @@ def test_upload_rejects_unsupported_file(tmp_path: Path) -> None: assert response.status_code == 400 assert "Unsupported media type" in response.json()["detail"] + + +def test_media_routes_authorize_every_user_scoped_operation(tmp_path: Path) -> None: + app = FastAPI() + authorized: list[str] = [] + + def authorize(_request: Request, user_id: str) -> None: + authorized.append(user_id) + if user_id != "owner": + raise HTTPException(status_code=403, detail="forbidden") + + mount_media_routes( + app, + MediaService(LocalMediaStorage(tmp_path)), + authorize=authorize, + ) + client = TestClient(app) + + denied_upload = client.post( + "/web/media", + data={"app_name": "demo", "user_id": "other", "session_id": "session"}, + files={"file": ("notes.txt", b"secret", "text/plain")}, + ) + owner_upload = client.post( + "/web/media", + data={"app_name": "demo", "user_id": "owner", "session_id": "session"}, + files={"file": ("notes.txt", b"secret", "text/plain")}, + ) + media_id = owner_upload.json()["id"] + + assert denied_upload.status_code == 403 + assert owner_upload.status_code == 200 + assert ( + client.get(f"/web/media/demo/other/session/{media_id}/content").status_code + == 403 + ) + assert client.get(f"/web/media/demo/other/session/{media_id}").status_code == 403 + assert client.delete(f"/web/media/demo/other/session/{media_id}").status_code == 403 + assert client.delete("/web/media/demo/other/session").status_code == 403 + assert authorized == ["other", "owner", "other", "other", "other", "other"] diff --git a/tests/test_ve_apig.py b/tests/test_ve_apig.py new file mode 100644 index 000000000..28c107c33 --- /dev/null +++ b/tests/test_ve_apig.py @@ -0,0 +1,73 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from types import SimpleNamespace +from typing import Any + +from veadk.integrations.ve_apig.ve_apig import APIGateway + + +def test_disable_route_cors_turns_off_credentials_and_origin_reflection() -> None: + captured: dict[str, Any] = {} + + class _Result: + def __init__(self, value: Any) -> None: + self.value = value + + def get(self) -> Any: + return self.value + + class _Client: + def get_route(self, request: Any, *, async_req: bool) -> _Result: + captured["get_request"] = request + return _Result( + SimpleNamespace( + route=SimpleNamespace( + name="studio-route", + enable=True, + fallback_setting="fallback", + match_rule="match", + priority=10, + upstream_list="upstream", + advanced_setting=SimpleNamespace( + header_operations="headers", + mirror_policies="mirrors", + retry_policy_setting="retry", + timeout_setting="timeout", + url_rewrite_setting="rewrite", + ), + ) + ) + ) + + def update_route(self, request: Any, *, async_req: bool) -> _Result: + captured["request"] = request + captured["async_req"] = async_req + return _Result(None) + + gateway = object.__new__(APIGateway) + gateway.apig_20221112_client = _Client() + + gateway.disable_route_cors("route-1") + + request = captured["request"] + cors = request.advanced_setting.cors_policy_setting + assert request.id == "route-1" + assert request.name == "studio-route" + assert request.match_rule == "match" + assert request.upstream_list == "upstream" + assert request.advanced_setting.timeout_setting == "timeout" + assert cors.enable is False + assert cors.allow_credentials is False + assert captured["async_req"] is True diff --git a/veadk/auth/middleware/oauth2_auth.py b/veadk/auth/middleware/oauth2_auth.py index a5d95387b..f20635dc4 100644 --- a/veadk/auth/middleware/oauth2_auth.py +++ b/veadk/auth/middleware/oauth2_auth.py @@ -1324,15 +1324,21 @@ def _resolve_redirect_after_auth(request: Request, redirect: Optional[str]) -> s return "/" redirect = redirect.strip() - if redirect.startswith("/"): - return redirect - + if not redirect or "\\" in redirect or any(ord(char) < 32 for char in redirect): + logger.warning("Unsafe redirect ignored: %s", redirect) + return "/" parsed = urllib.parse.urlparse(redirect) if not parsed.scheme and not parsed.netloc: + if redirect.startswith("/") and not redirect.startswith("//"): + return redirect return f"/{redirect.lstrip('/')}" current = urllib.parse.urlparse(str(request.url)) - if parsed.scheme == current.scheme and parsed.netloc == current.netloc: + if ( + parsed.scheme in {"http", "https"} + and parsed.scheme == current.scheme + and parsed.netloc == current.netloc + ): return redirect logger.warning("Unsafe redirect ignored: %s", redirect) diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index bc6a0ec15..5ffa6394f 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -36,7 +36,7 @@ from pathlib import Path from time import monotonic from typing import Any, Literal -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse from uuid import uuid4 import click @@ -111,6 +111,9 @@ r"Pipeline triggered successfully,\s*run ID:\s*(?P\S+)" ) _RUNTIME_DESCRIPTION_MAX_BYTES = 255 +_LOCAL_ADK_USER_PATH_RE = re.compile( + r"^/(?:harness/)?apps/[^/]+/users/(?P[^/]+)/sessions(?:/|$)" +) def _byteplus_vefaas_application_name_suggestion(name: str) -> str: @@ -1072,7 +1075,7 @@ def _run_frontend_server( # Agent introspection for the UI's agent picker (name, model, tools). Reuses # ADK's AgentLoader, which caches each loaded `root_agent`. from fastapi import HTTPException, Query, Request - from fastapi.responses import Response + from fastapi.responses import JSONResponse, Response from google.adk.cli.utils.agent_loader import AgentLoader import httpx @@ -1107,13 +1110,6 @@ def _run_frontend_server( _agent_loader = AgentLoader(agents_dir) media_service = MediaService(create_media_storage()) - mount_media_routes(app, media_service) - - # Generated-agent debug is intentionally feature-complete in both local and - # remote Studio deployments: the backend receives AgentDraft JSON, generates - # the same project content as "Generate project", writes it to a temp dir, - # and starts a runner for the debug session. - generated_agent_test_run_allows_local_resources = True generated_agent_test_run_ttl = max(60, generated_agent_test_run_ttl) access_policy = StudioAccessPolicy.from_csv( @@ -1267,6 +1263,43 @@ def _require_agent_management(request: Request) -> StudioPrincipal | None: ) return principal + def _require_local_user_scope(request: Request, user_id: str) -> None: + principal = _current_principal(request) + if access_policy.enabled and principal is None: + raise HTTPException(status_code=401, detail="Studio identity is required") + if principal is None or access_policy.role_for(principal) == StudioRole.ADMIN: + return + if user_id.strip().casefold() not in principal.identifiers: + raise HTTPException( + status_code=403, + detail="Access to another Studio user's local data is not allowed", + ) + + mount_media_routes(app, media_service, authorize=_require_local_user_scope) + + @app.middleware("http") + async def _enforce_local_adk_user_scope(request: Request, call_next): + user_id = "" + match = _LOCAL_ADK_USER_PATH_RE.match(request.url.path) + if match: + user_id = unquote(match.group("user_id")) + elif request.url.path in {"/run", "/run_sse"}: + try: + payload = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError): + payload = {} + if isinstance(payload, dict): + user_id = str(payload.get("user_id") or payload.get("userId") or "") + if user_id: + try: + _require_local_user_scope(request, user_id) + except HTTPException as error: + return JSONResponse( + status_code=error.status_code, + content={"detail": error.detail}, + ) + return await call_next(request) + def _skill_creator_owner(request: Request) -> str: principal = _require_agent_management(request) return principal.owner_id if principal else "local" @@ -2438,9 +2471,6 @@ async def _generate_project_and_draft_from_request( draft = _draft_for_debug_run(draft) validate_debug_policy( draft, - allow_local_runtime_resources=( - generated_agent_test_run_allows_local_resources - ), ) draft = await resolve_debug_mcp_endpoints(draft) else: @@ -6286,11 +6316,13 @@ async def _evaluation_post( @app.get("/web/a2a-spaces") async def _web_list_a2a_spaces( + http_request: Request, region: str = "", page_size: int = Query(default=100, ge=1, le=100), project: str | None = None, ): """List all AgentKit A2A Spaces visible to server credentials.""" + _require_agent_management(http_request) region = _coerce_cloud_region(region) try: _resolve_ve_credentials() @@ -6567,10 +6599,12 @@ def _list_vikingdb_vector_collections( @app.get("/web/viking-knowledgebases") async def _web_list_viking_knowledgebases( + http_request: Request, region: str = "", project: str = "", ): """List VikingDB KnowledgeBase collections visible to server creds.""" + _require_agent_management(http_request) from volcengine.viking_knowledgebase import VikingKnowledgeBaseService region = _coerce_cloud_region(region) @@ -6724,6 +6758,7 @@ def _skills_client(region: str): @app.get("/web/skill-spaces") async def _web_list_skill_spaces( + http_request: Request, region: str = "all", page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), @@ -6735,6 +6770,7 @@ async def _web_list_skill_spaces( mode it resolves to the BytePlus Studio region configured for this server (currently ap-southeast-1). """ + _require_agent_management(http_request) from agentkit.sdk.skills.types import ListSkillSpacesRequest aggregate_regions = region in {"all", "", "*"} @@ -6793,6 +6829,7 @@ async def _web_list_skill_spaces( @app.get("/web/skill-spaces/{space_id}/skills") async def _web_list_skills_in_space( + http_request: Request, space_id: str, region: str = "", page: int = Query(default=1, ge=1), @@ -6801,6 +6838,7 @@ async def _web_list_skills_in_space( ): """List skills in one SkillSpace (relation view: id/name/description/ version/status per skill).""" + _require_agent_management(http_request) from agentkit.sdk.skills.types import ListSkillsBySkillSpaceRequest del project # SkillSpace ID is already globally scoped by AgentKit. @@ -6848,12 +6886,14 @@ async def _web_list_skills_in_space( @app.get("/web/skill-spaces/{space_id}/skills/{skill_id}") async def _web_get_skill_detail( + http_request: Request, space_id: str, skill_id: str, version: str | None = None, region: str = "", ): """Fetch a specific skill version's SKILL.md content plus package files.""" + _require_agent_management(http_request) from agentkit.sdk.skills.types import GetSkillVersionRequest region = _coerce_cloud_region(region) @@ -7787,6 +7827,7 @@ def frontend_deploy( auth_method="none", enable_mcp_session=False, keep_failed_deploy=keep_failed_deploy, + disable_cors=True, ) url = (app.vefaas_endpoint or "").rstrip("/") redirect_uri = f"{url}/oauth2/callback" diff --git a/veadk/cli/generated_agent_security.py b/veadk/cli/generated_agent_security.py index 133f02989..7cbe866af 100644 --- a/veadk/cli/generated_agent_security.py +++ b/veadk/cli/generated_agent_security.py @@ -21,11 +21,13 @@ from urllib.parse import urlparse from veadk.cli.generated_agent_catalog import ( + BYTEPLUS_MODELARK_BASE_URL, EXPORTER_BY_ID, KB_BY_ID, LTM_BY_ID, STM_BY_ID, TOOL_BY_ID, + VOLCENGINE_MODELARK_BASE_URL, ) from veadk.cli.generated_agent_codegen import AgentDraft @@ -57,6 +59,11 @@ class DebugPolicyError(ValueError): ipaddress.ip_address("169.254.169.254"), } +_TRUSTED_MODEL_API_BASES = { + BYTEPLUS_MODELARK_BASE_URL.rstrip("/"), + VOLCENGINE_MODELARK_BASE_URL.rstrip("/"), +} + def validate_project_policy(draft: AgentDraft) -> None: total = _validate_node( @@ -64,6 +71,7 @@ def validate_project_policy(draft: AgentDraft) -> None: depth=0, allow_local_runtime_resources=True, allow_stdio_mcp=True, + managed_debug_credentials=False, ) if total > MAX_SUBAGENTS + 1: raise DebugPolicyError(f"Too many agents: {total}") @@ -79,6 +87,7 @@ def validate_debug_policy( depth=0, allow_local_runtime_resources=allow_local_runtime_resources, allow_stdio_mcp=False, + managed_debug_credentials=True, ) if total > MAX_SUBAGENTS + 1: raise DebugPolicyError(f"Too many agents: {total}") @@ -90,6 +99,7 @@ def _validate_node( depth: int, allow_local_runtime_resources: bool, allow_stdio_mcp: bool, + managed_debug_credentials: bool, ) -> int: if depth > MAX_DEPTH: raise DebugPolicyError(f"Agent tree is too deep (>{MAX_DEPTH})") @@ -100,13 +110,20 @@ def _validate_node( _check_len("description", draft.description, MAX_DESCRIPTION_LEN) _check_len("instruction", draft.instruction, MAX_INSTRUCTION_LEN) + if managed_debug_credentials and draft.modelApiBase.strip(): + validate_debug_model_api_base(draft.modelApiBase) + if draft.agentType == "loop" and not (1 <= draft.maxIterations <= MAX_ITERATIONS): raise DebugPolicyError(f"maxIterations must be between 1 and {MAX_ITERATIONS}") if draft.agentType == "a2a": if not registry_backed_remote and not draft.a2aUrl.strip(): raise DebugPolicyError("A2A URL is required") if not registry_backed_remote and not allow_local_runtime_resources: - validate_url_not_private(draft.a2aUrl, field_name="a2aUrl") + validate_url_not_private( + draft.a2aUrl, + field_name="a2aUrl", + require_https=True, + ) if draft.a2aRegistry.enabled and not draft.a2aRegistry.registrySpaceId.strip(): raise DebugPolicyError("A2A registry space id is required") @@ -144,7 +161,11 @@ def _validate_node( if tool.transport == "stdio" and not allow_stdio_mcp: raise DebugPolicyError("MCP stdio transport is disabled for debug runs") if tool.transport == "http" and not allow_local_runtime_resources: - validate_url_not_private(tool.url, field_name="mcpTools.url") + validate_url_not_private( + tool.url, + field_name="mcpTools.url", + require_https=True, + ) for arg in tool.args: _check_len("MCP arg", arg, MAX_MCP_ARG_LEN) @@ -155,6 +176,7 @@ def _validate_node( depth=depth + 1, allow_local_runtime_resources=allow_local_runtime_resources, allow_stdio_mcp=allow_stdio_mcp, + managed_debug_credentials=managed_debug_credentials, ) return total @@ -164,6 +186,7 @@ def validate_url_not_private( *, field_name: str, resolve_dns: bool = True, + require_https: bool = False, ) -> None: raw = (raw_url or "").strip() if not raw: @@ -171,6 +194,10 @@ def validate_url_not_private( parsed = urlparse(raw) if parsed.scheme not in {"http", "https"}: raise DebugPolicyError(f"{field_name} must use http or https") + if require_https and parsed.scheme != "https": + raise DebugPolicyError(f"{field_name} must use https") + if parsed.username or parsed.password: + raise DebugPolicyError(f"{field_name} must not include user info") host = (parsed.hostname or "").strip().lower() if not host: raise DebugPolicyError(f"{field_name} must include a hostname") @@ -205,6 +232,25 @@ def validate_url_not_private( raise DebugPolicyError(f"{field_name} resolved to an invalid IP") from exc +def validate_debug_model_api_base(raw_url: str) -> None: + """Allow managed debug credentials only on built-in ModelArk endpoints.""" + raw = raw_url.strip() + parsed = urlparse(raw) + if ( + parsed.scheme != "https" + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise DebugPolicyError("modelApiBase must use a trusted ModelArk endpoint") + normalized = f"https://{parsed.netloc}{parsed.path}".rstrip("/") + if normalized not in _TRUSTED_MODEL_API_BASES: + raise DebugPolicyError( + "Custom modelApiBase is disabled for managed debug credentials" + ) + + def _default_port(scheme: str) -> int: return 443 if scheme == "https" else 80 diff --git a/veadk/cloud/cloud_agent_engine.py b/veadk/cloud/cloud_agent_engine.py index 6934e1260..1f7faaf85 100644 --- a/veadk/cloud/cloud_agent_engine.py +++ b/veadk/cloud/cloud_agent_engine.py @@ -246,6 +246,7 @@ def deploy( local_test: bool = False, enable_mcp_session: bool = True, keep_failed_deploy: bool = False, + disable_cors: bool = False, ) -> CloudApp: """Deploys a local agent project to Volcengine FaaS, creating necessary resources. @@ -262,6 +263,7 @@ def deploy( identity_user_pool_name (str, optional): Custom user pool name. Defaults to timestamped. identity_client_name (str, optional): Custom client name. Defaults to timestamped. local_test (bool): Perform FastAPI server test before deploy. Defaults to False. + disable_cors (bool): Disable APIG-managed CORS on the application route. Returns: CloudApp: Deployed application with endpoint, name, and ID. @@ -330,6 +332,8 @@ def deploy( veapig_gateway_id, _, veapig_route_id = ( self._vefaas_service.get_application_route(app_id=app_id) ) + if disable_cors: + self._veapig_service.disable_route_cors(veapig_route_id) if auth_method == "oauth2": # Resolve the Identity user pool: reuse an existing one by UID diff --git a/veadk/integrations/ve_apig/ve_apig.py b/veadk/integrations/ve_apig/ve_apig.py index bdde704fa..98856f029 100644 --- a/veadk/integrations/ve_apig/ve_apig.py +++ b/veadk/integrations/ve_apig/ve_apig.py @@ -16,7 +16,14 @@ import volcenginesdkcore from volcenginesdkapig import APIGApi -from volcenginesdkapig20221112 import APIG20221112Api, UpstreamListForCreateRouteInput +from volcenginesdkapig20221112 import ( + APIG20221112Api, + AdvancedSettingForUpdateRouteInput, + CorsPolicySettingForUpdateRouteInput, + GetRouteRequest, + UpdateRouteRequest, + UpstreamListForCreateRouteInput, +) from veadk.utils.cloud_provider import ( DEFAULT_CLOUD_PROVIDER, @@ -152,6 +159,38 @@ def create_gateway_service(self, gateway_id: str, service_name: str) -> str: result = thread.get() return result.to_dict()["id"] + def disable_route_cors(self, route_id: str) -> None: + """Disable APIG-managed CORS so same-origin Studio remains authoritative.""" + route = ( + self.apig_20221112_client.get_route( + GetRouteRequest(id=route_id), async_req=True + ) + .get() + .route + ) + advanced_setting = route.advanced_setting + request = UpdateRouteRequest( + id=route_id, + name=route.name, + enable=route.enable, + fallback_setting=route.fallback_setting, + match_rule=route.match_rule, + priority=route.priority, + upstream_list=route.upstream_list, + advanced_setting=AdvancedSettingForUpdateRouteInput( + cors_policy_setting=CorsPolicySettingForUpdateRouteInput( + enable=False, + allow_credentials=False, + ), + header_operations=advanced_setting.header_operations, + mirror_policies=advanced_setting.mirror_policies, + retry_policy_setting=advanced_setting.retry_policy_setting, + timeout_setting=advanced_setting.timeout_setting, + url_rewrite_setting=advanced_setting.url_rewrite_setting, + ), + ) + self.apig_20221112_client.update_route(request, async_req=True).get() + def create_vefaas_upstream( self, function_id: str, gateway_id: str, upstream_name: str ): diff --git a/veadk/multimodal/api.py b/veadk/multimodal/api.py index 3474219df..54d83cae4 100644 --- a/veadk/multimodal/api.py +++ b/veadk/multimodal/api.py @@ -16,6 +16,7 @@ from __future__ import annotations import os +from collections.abc import Callable from pathlib import Path import tempfile @@ -23,6 +24,7 @@ from fastapi import File from fastapi import Form from fastapi import HTTPException +from fastapi import Request from fastapi import UploadFile from fastapi.responses import FileResponse from fastapi.responses import RedirectResponse @@ -33,9 +35,17 @@ from .service import SUPPORTED_MIME_TYPES -def mount_media_routes(app: FastAPI, service: MediaService) -> None: +def mount_media_routes( + app: FastAPI, + service: MediaService, + authorize: Callable[[Request, str], None] | None = None, +) -> None: """Mount the multimodal upload, delivery, and cleanup endpoints.""" + def _authorize(request: Request, user_id: str) -> None: + if authorize is not None: + authorize(request, user_id) + @app.get("/web/media/capabilities") async def media_capabilities() -> dict[str, object]: return { @@ -46,11 +56,13 @@ async def media_capabilities() -> dict[str, object]: @app.post("/web/media") async def upload_media( + request: Request, app_name: str = Form(...), user_id: str = Form(...), session_id: str = Form(...), file: UploadFile = File(...), ) -> dict[str, object]: + _authorize(request, user_id) suffix = Path(file.filename or "attachment").suffix temp_path: Path | None = None try: @@ -84,8 +96,9 @@ async def upload_media( @app.get("/web/media/{app_name}/{user_id}/{session_id}/{media_id}") async def get_media_metadata( - app_name: str, user_id: str, session_id: str, media_id: str + request: Request, app_name: str, user_id: str, session_id: str, media_id: str ) -> dict[str, object]: + _authorize(request, user_id) try: record = await service.get_record( _media_ref(app_name, user_id, session_id, media_id) @@ -96,8 +109,9 @@ async def get_media_metadata( @app.get("/web/media/{app_name}/{user_id}/{session_id}/{media_id}/content") async def get_media_content( - app_name: str, user_id: str, session_id: str, media_id: str + request: Request, app_name: str, user_id: str, session_id: str, media_id: str ) -> Response: + _authorize(request, user_id) ref = _media_ref(app_name, user_id, session_id, media_id) try: record = await service.get_record(ref) @@ -120,8 +134,9 @@ async def get_media_content( @app.delete("/web/media/{app_name}/{user_id}/{session_id}/{media_id}") @app.post("/web/media/{app_name}/{user_id}/{session_id}/{media_id}/delete") async def delete_media( - app_name: str, user_id: str, session_id: str, media_id: str + request: Request, app_name: str, user_id: str, session_id: str, media_id: str ) -> None: + _authorize(request, user_id) await service.storage.delete( _media_ref(app_name, user_id, session_id, media_id) ) @@ -129,8 +144,9 @@ async def delete_media( @app.delete("/web/media/{app_name}/{user_id}/{session_id}") @app.post("/web/media/{app_name}/{user_id}/{session_id}/delete") async def delete_session_media( - app_name: str, user_id: str, session_id: str + request: Request, app_name: str, user_id: str, session_id: str ) -> None: + _authorize(request, user_id) await service.storage.delete_session(app_name, user_id, session_id)