feat(api): CORS posture for /openapi/v1 (Phase A.5)

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).
This commit is contained in:
GareArc 2026-04-26 23:30:27 -07:00
parent 501c0b8746
commit 218ef6a447
No known key found for this signature in database
3 changed files with 157 additions and 1 deletions

View File

@ -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
)

View File

@ -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(

View File

@ -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