From 218ef6a447013227710939e5b3167b0067ebc629 Mon Sep 17 00:00:00 2001 From: GareArc Date: Sun, 26 Apr 2026 23:30:27 -0700 Subject: [PATCH] feat(api): CORS posture for /openapi/v1 (Phase A.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPENAPI_CORS_ALLOW_ORIGINS env var defaults to empty (same-origin only). Operators expand for third-party integrations via comma-separated list. Allowed headers: Authorization, Content-Type, X-CSRF-Token. Methods: GET POST PATCH DELETE OPTIONS. Max-Age 600s. supports_credentials=True so cookie-authed approve/deny work once Phase D moves them in. Disallowed origins receive a normal 200 OPTIONS response without the Access-Control-Allow-Origin header — flask-cors's standard behavior; browser blocks the cross-origin request from the disallowed origin. Plan: docs/superpowers/plans/2026-04-26-openapi-migration.md (in difyctl repo). --- api/configs/feature/__init__.py | 14 ++ api/extensions/ext_blueprints.py | 17 ++- .../controllers/openapi/test_cors.py | 127 ++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 api/tests/unit_tests/controllers/openapi/test_cors.py diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index dca44f2f32..41af96de50 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -499,6 +499,20 @@ class HttpConfig(BaseSettings): def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]: return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",") + inner_OPENAPI_CORS_ALLOW_ORIGINS: str = Field( + description=( + "Comma-separated allowlist for /openapi/v1/* CORS. " + "Default empty = same-origin only. Browser-cookie routes within " + "the group reject cross-origin OPTIONS regardless of this list." + ), + validation_alias=AliasChoices("OPENAPI_CORS_ALLOW_ORIGINS"), + default="", + ) + + @computed_field + def OPENAPI_CORS_ALLOW_ORIGINS(self) -> list[str]: + return [o for o in self.inner_OPENAPI_CORS_ALLOW_ORIGINS.split(",") if o] + HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field( ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10 ) diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 690ee1fccf..a3bcec15e0 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -8,6 +8,8 @@ AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN) EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE) EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id") +OPENAPI_HEADERS: tuple[str, ...] = ("Authorization", "Content-Type", HEADER_NAME_CSRF_TOKEN) +OPENAPI_MAX_AGE_SECONDS: int = 600 def _apply_cors_once(bp, /, **cors_kwargs): @@ -42,7 +44,20 @@ def init_app(app: DifyApp): ) app.register_blueprint(service_api_bp) - # User-scoped programmatic API. CORS configured in Phase A step 5. + # User-scoped programmatic API. Default empty allowlist = same-origin + # only; expand via OPENAPI_CORS_ALLOW_ORIGINS for third-party + # integrations. supports_credentials so cookie-authed approve/deny + # work; cross-origin OPTIONS without an allowed origin will fail + # the same as on the console blueprint. + _apply_cors_once( + openapi_bp, + resources={r"/*": {"origins": dify_config.OPENAPI_CORS_ALLOW_ORIGINS}}, + supports_credentials=True, + allow_headers=list(OPENAPI_HEADERS), + methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + expose_headers=list(EXPOSED_HEADERS), + max_age=OPENAPI_MAX_AGE_SECONDS, + ) app.register_blueprint(openapi_bp) _apply_cors_once( diff --git a/api/tests/unit_tests/controllers/openapi/test_cors.py b/api/tests/unit_tests/controllers/openapi/test_cors.py new file mode 100644 index 0000000000..e13c285657 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_cors.py @@ -0,0 +1,127 @@ +"""CORS posture for /openapi/v1/* — default empty allowlist (same-origin), +expandable via OPENAPI_CORS_ALLOW_ORIGINS. Cross-origin requests from +disallowed origins do not receive the Access-Control-Allow-Origin +header, which the browser then blocks. + +Tests use a fresh Blueprint + Flask-CORS per case because the production +blueprint is a module-level singleton and can't be reconfigured once +registered. +""" +import builtins + +import pytest +from flask import Blueprint, Flask +from flask.views import MethodView +from flask_cors import CORS +from flask_restx import Resource + +from configs import dify_config +from extensions.ext_blueprints import OPENAPI_HEADERS, OPENAPI_MAX_AGE_SECONDS +from libs.external_api import ExternalApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +def _make_app(allowed_origins: list[str], blueprint_name: str) -> Flask: + """Build a Flask app with a fresh openapi-style blueprint mirroring + production CORS settings, parameterised on the origin allowlist. + """ + bp = Blueprint(blueprint_name, __name__, url_prefix="/openapi/v1") + api = ExternalApi(bp, version="1.0", title="OpenAPI Test", description="") + + @api.route("/_health") + class _Health(Resource): + def get(self): + return {"ok": True} + + CORS( + bp, + resources={r"/*": {"origins": allowed_origins}}, + supports_credentials=True, + allow_headers=list(OPENAPI_HEADERS), + methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + expose_headers=["X-Version"], + max_age=OPENAPI_MAX_AGE_SECONDS, + ) + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(bp) + return app + + +def test_default_openapi_cors_allowlist_is_empty(): + """Default config admits no cross-origin until operator opts in.""" + assert dify_config.OPENAPI_CORS_ALLOW_ORIGINS == [] + + +def test_preflight_allowed_origin_returns_cors_headers(): + app = _make_app(["https://app.example.com"], "openapi_t1") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + + assert response.headers.get("Access-Control-Allow-Origin") == "https://app.example.com" + assert response.headers.get("Access-Control-Max-Age") == str(OPENAPI_MAX_AGE_SECONDS) + + +def test_preflight_disallowed_origin_omits_cors_headers(): + app = _make_app(["https://app.example.com"], "openapi_t2") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://attacker.example", + "Access-Control-Request-Method": "GET", + }, + ) + + # flask-cors omits Allow-Origin for disallowed origins; browser blocks. + assert "Access-Control-Allow-Origin" not in response.headers + + +def test_preflight_with_default_empty_allowlist_omits_cors_headers(): + app = _make_app([], "openapi_t3") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + + assert "Access-Control-Allow-Origin" not in response.headers + + +def test_same_origin_request_succeeds_without_origin_header(): + app = _make_app(["https://app.example.com"], "openapi_t4") + client = app.test_client() + # Browsers don't send Origin on same-origin GETs. + response = client.get("/openapi/v1/_health") + + assert response.status_code == 200 + assert response.get_json() == {"ok": True} + + +def test_authorization_header_is_in_allow_headers(): + """Bearer-authed routes need Authorization in the preflight response.""" + app = _make_app(["https://app.example.com"], "openapi_t5") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Authorization", + }, + ) + + allow_headers = response.headers.get("Access-Control-Allow-Headers", "").lower() + assert "authorization" in allow_headers