mirror of https://github.com/langgenius/dify.git
Enabled cross-subdomain console sessions by making the cookie domain configurable and aligning the frontend so it reads the shared CSRF cookie. (#27190)
This commit is contained in:
parent
543c5236e7
commit
ff32dff163
|
|
@ -156,6 +156,9 @@ SUPABASE_URL=your-server-url
|
|||
# CORS configuration
|
||||
WEB_API_CORS_ALLOW_ORIGINS=http://localhost:3000,*
|
||||
CONSOLE_CORS_ALLOW_ORIGINS=http://localhost:3000,*
|
||||
# Set COOKIE_DOMAIN when the console frontend and API are on different subdomains.
|
||||
# Provide the registrable domain (e.g. example.com); leading dots are optional.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Vector database configuration
|
||||
# Supported values are `weaviate`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `oceanbase`, `opengauss`, `tablestore`,`vastbase`,`tidb`,`tidb_on_qdrant`,`baidu`,`lindorm`,`huawei_cloud`,`upstash`, `matrixone`.
|
||||
|
|
|
|||
|
|
@ -337,6 +337,11 @@ class HttpConfig(BaseSettings):
|
|||
HTTP-related configurations for the application
|
||||
"""
|
||||
|
||||
COOKIE_DOMAIN: str = Field(
|
||||
description="Explicit cookie domain for console/service cookies when sharing across subdomains",
|
||||
default="",
|
||||
)
|
||||
|
||||
API_COMPRESSION_ENABLED: bool = Field(
|
||||
description="Enable or disable gzip compression for HTTP responses",
|
||||
default=False,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ from werkzeug.exceptions import HTTPException
|
|||
from werkzeug.http import HTTP_STATUS_CODES
|
||||
|
||||
from configs import dify_config
|
||||
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from libs.token import is_secure
|
||||
from libs.token import build_force_logout_cookie_headers
|
||||
|
||||
|
||||
def http_status_message(code):
|
||||
|
|
@ -73,15 +72,7 @@ def register_external_error_handlers(api: Api):
|
|||
error_code = getattr(e, "error_code", None)
|
||||
if error_code == "unauthorized_and_force_logout":
|
||||
# Add Set-Cookie headers to clear auth cookies
|
||||
|
||||
secure = is_secure()
|
||||
# response is not accessible, so we need to do it ugly
|
||||
common_part = "Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly"
|
||||
headers["Set-Cookie"] = [
|
||||
f'{COOKIE_NAME_ACCESS_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax',
|
||||
f'{COOKIE_NAME_CSRF_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax',
|
||||
f'{COOKIE_NAME_REFRESH_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax',
|
||||
]
|
||||
headers["Set-Cookie"] = build_force_logout_cookie_headers()
|
||||
return data, status_code, headers
|
||||
|
||||
_ = handle_http_exception
|
||||
|
|
|
|||
|
|
@ -30,8 +30,22 @@ def is_secure() -> bool:
|
|||
return dify_config.CONSOLE_WEB_URL.startswith("https") and dify_config.CONSOLE_API_URL.startswith("https")
|
||||
|
||||
|
||||
def _cookie_domain() -> str | None:
|
||||
"""
|
||||
Returns the normalized cookie domain.
|
||||
|
||||
Leading dots are stripped from the configured domain. Historically, a leading dot
|
||||
indicated that a cookie should be sent to all subdomains, but modern browsers treat
|
||||
'example.com' and '.example.com' identically. This normalization ensures consistent
|
||||
behavior and avoids confusion.
|
||||
"""
|
||||
domain = dify_config.COOKIE_DOMAIN.strip()
|
||||
domain = domain.removeprefix(".")
|
||||
return domain or None
|
||||
|
||||
|
||||
def _real_cookie_name(cookie_name: str) -> str:
|
||||
if is_secure():
|
||||
if is_secure() and _cookie_domain() is None:
|
||||
return "__Host-" + cookie_name
|
||||
else:
|
||||
return cookie_name
|
||||
|
|
@ -91,6 +105,7 @@ def set_access_token_to_cookie(request: Request, response: Response, token: str,
|
|||
_real_cookie_name(COOKIE_NAME_ACCESS_TOKEN),
|
||||
value=token,
|
||||
httponly=True,
|
||||
domain=_cookie_domain(),
|
||||
secure=is_secure(),
|
||||
samesite=samesite,
|
||||
max_age=int(dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 60),
|
||||
|
|
@ -103,6 +118,7 @@ def set_refresh_token_to_cookie(request: Request, response: Response, token: str
|
|||
_real_cookie_name(COOKIE_NAME_REFRESH_TOKEN),
|
||||
value=token,
|
||||
httponly=True,
|
||||
domain=_cookie_domain(),
|
||||
secure=is_secure(),
|
||||
samesite="Lax",
|
||||
max_age=int(60 * 60 * 24 * dify_config.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
|
|
@ -115,6 +131,7 @@ def set_csrf_token_to_cookie(request: Request, response: Response, token: str):
|
|||
_real_cookie_name(COOKIE_NAME_CSRF_TOKEN),
|
||||
value=token,
|
||||
httponly=False,
|
||||
domain=_cookie_domain(),
|
||||
secure=is_secure(),
|
||||
samesite="Lax",
|
||||
max_age=int(60 * dify_config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
|
|
@ -133,6 +150,7 @@ def _clear_cookie(
|
|||
"",
|
||||
expires=0,
|
||||
path="/",
|
||||
domain=_cookie_domain(),
|
||||
secure=is_secure(),
|
||||
httponly=http_only,
|
||||
samesite=samesite,
|
||||
|
|
@ -155,6 +173,19 @@ def clear_csrf_token_from_cookie(response: Response):
|
|||
_clear_cookie(response, COOKIE_NAME_CSRF_TOKEN, http_only=False)
|
||||
|
||||
|
||||
def build_force_logout_cookie_headers() -> list[str]:
|
||||
"""
|
||||
Generate Set-Cookie header values that clear all auth-related cookies.
|
||||
This mirrors the behavior of the standard cookie clearing helpers while
|
||||
allowing callers that do not have a Response instance to reuse the logic.
|
||||
"""
|
||||
response = Response()
|
||||
clear_access_token_from_cookie(response)
|
||||
clear_csrf_token_from_cookie(response)
|
||||
clear_refresh_token_from_cookie(response)
|
||||
return response.headers.getlist("Set-Cookie")
|
||||
|
||||
|
||||
def check_csrf_token(request: Request, user_id: str):
|
||||
# some apis are sent by beacon, so we need to bypass csrf token check
|
||||
# since these APIs are post, they are already protected by SameSite: Lax, so csrf is not required.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_WEBAPP_ACCESS_TOKEN
|
||||
from libs.token import extract_access_token, extract_webapp_access_token
|
||||
from libs import token
|
||||
from libs.token import extract_access_token, extract_webapp_access_token, set_csrf_token_to_cookie
|
||||
|
||||
|
||||
class MockRequest:
|
||||
|
|
@ -23,3 +28,35 @@ def test_extract_access_token():
|
|||
for request, expected_console, expected_webapp in test_cases:
|
||||
assert extract_access_token(request) == expected_console # pyright: ignore[reportArgumentType]
|
||||
assert extract_webapp_access_token(request) == expected_webapp # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
def test_real_cookie_name_uses_host_prefix_without_domain(monkeypatch):
|
||||
monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False)
|
||||
monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False)
|
||||
monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", "", raising=False)
|
||||
|
||||
assert token._real_cookie_name("csrf_token") == "__Host-csrf_token"
|
||||
|
||||
|
||||
def test_real_cookie_name_without_host_prefix_when_domain_present(monkeypatch):
|
||||
monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False)
|
||||
monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False)
|
||||
monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False)
|
||||
|
||||
assert token._real_cookie_name("csrf_token") == "csrf_token"
|
||||
|
||||
|
||||
def test_set_csrf_cookie_includes_domain_when_configured(monkeypatch):
|
||||
monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False)
|
||||
monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False)
|
||||
monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False)
|
||||
|
||||
response = Response()
|
||||
request = MagicMock()
|
||||
|
||||
set_csrf_token_to_cookie(request, response, "abc123")
|
||||
|
||||
cookies = response.headers.getlist("Set-Cookie")
|
||||
assert any("csrf_token=abc123" in c for c in cookies)
|
||||
assert any("Domain=example.com" in c for c in cookies)
|
||||
assert all("__Host-" not in c for c in cookies)
|
||||
|
|
|
|||
|
|
@ -348,6 +348,11 @@ WEB_API_CORS_ALLOW_ORIGINS=*
|
|||
# Specifies the allowed origins for cross-origin requests to the console API,
|
||||
# e.g. https://cloud.dify.ai or * for all origins.
|
||||
CONSOLE_CORS_ALLOW_ORIGINS=*
|
||||
# Set COOKIE_DOMAIN when the console frontend and API are on different subdomains.
|
||||
# Provide the registrable domain (e.g. example.com); leading dots are optional.
|
||||
COOKIE_DOMAIN=
|
||||
# The frontend reads NEXT_PUBLIC_COOKIE_DOMAIN to align cookie handling with the API.
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
|
||||
# ------------------------------
|
||||
# File Storage Configuration
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ services:
|
|||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
APP_API_URL: ${APP_API_URL:-}
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
|
||||
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
|
||||
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
|
||||
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ x-shared-env: &shared-api-worker-env
|
|||
CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1}
|
||||
WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*}
|
||||
CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
|
||||
STORAGE_TYPE: ${STORAGE_TYPE:-opendal}
|
||||
OPENDAL_SCHEME: ${OPENDAL_SCHEME:-fs}
|
||||
OPENDAL_FS_ROOT: ${OPENDAL_FS_ROOT:-storage}
|
||||
|
|
@ -691,6 +693,7 @@ services:
|
|||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
APP_API_URL: ${APP_API_URL:-}
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
|
||||
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
|
||||
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
|
||||
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ NEXT_PUBLIC_CSP_WHITELIST=
|
|||
# Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking
|
||||
NEXT_PUBLIC_ALLOW_EMBED=
|
||||
|
||||
# Shared cookie domain when console UI and API use different subdomains (e.g. example.com)
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
|
||||
# Allow rendering unsafe URLs which have "data:" scheme.
|
||||
NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,9 @@ export const getMaxToken = (modelId: string) => {
|
|||
|
||||
export const LOCALE_COOKIE_NAME = 'locale'
|
||||
|
||||
const COOKIE_DOMAIN = (process.env.NEXT_PUBLIC_COOKIE_DOMAIN || '').trim()
|
||||
export const CSRF_COOKIE_NAME = () => {
|
||||
if (COOKIE_DOMAIN) return 'csrf_token'
|
||||
const isSecure = API_PREFIX.startsWith('https://')
|
||||
return isSecure ? '__Host-csrf_token' : 'csrf_token'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue