fix(cors): configure allowed origins and request errors

Add shared CORS configuration for the web and model API servers, avoid wildcard credentials, and show safe frontend request error messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
alan.cl
2026-07-01 19:56:47 +08:00
parent 8b6d460ba6
commit 641704de40
15 changed files with 159 additions and 24 deletions

View File

@@ -9,6 +9,8 @@ encrypt_key = "your_secret_key"
[service.web]
host = "0.0.0.0"
port = 5670
# CORS allowed origins: '*' allows all; set comma-separated origins to restrict.
cors_allowed_origins = "${env:DBGPT_CORS_ALLOWED_ORIGINS:-*}"
[service.web.database]
type = "sqlite"

View File

@@ -8,6 +8,8 @@ encrypt_key = "your_secret_key"
[service.web]
host = "0.0.0.0"
port = 5670
# CORS allowed origins: '*' allows all; set comma-separated origins to restrict.
cors_allowed_origins = "${env:DBGPT_CORS_ALLOWED_ORIGINS:-*}"
[service.web.database]
type = "sqlite"

View File

@@ -8,6 +8,8 @@ encrypt_key = "your_secret_key"
[service.web]
host = "0.0.0.0"
port = 5670
# CORS allowed origins: '*' allows all; set comma-separated origins to restrict.
cors_allowed_origins = "${env:DBGPT_CORS_ALLOWED_ORIGINS:-*}"
[service.web.database]
type = "mysql"

View File

@@ -8,6 +8,8 @@ encrypt_key = "your_secret_key"
[service.web]
host = "0.0.0.0"
port = 5670
# CORS allowed origins: '*' allows all; set comma-separated origins to restrict.
cors_allowed_origins = "${env:DBGPT_CORS_ALLOWED_ORIGINS:-*}"
[service.web.agent_context]
# Agent context-window budget. Set max_context_tokens to 0 to auto-detect from

View File

@@ -8,6 +8,8 @@ encrypt_key = "your_secret_key"
[service.web]
host = "0.0.0.0"
port = 5670
# CORS allowed origins: '*' allows all; set comma-separated origins to restrict.
cors_allowed_origins = "${env:DBGPT_CORS_ALLOWED_ORIGINS:-*}"
[service.web.agent_context]
# Agent context-window budget. Set max_context_tokens to 0 to auto-detect from

View File

@@ -378,6 +378,17 @@ class ServiceWebParameters(BaseParameters):
default_factory=AgentContextParameters,
metadata={"help": _("Agent context-window management configuration")},
)
cors_allowed_origins: str = field(
default="*",
metadata={
"help": _(
"Comma-separated allowed CORS origins. Default '*' allows all "
"(any website can read API responses cross-origin). Set to "
"explicit origins to restrict, e.g. "
"http://localhost:3000,https://your-app.com."
)
},
)
@dataclass

View File

@@ -15,7 +15,7 @@ from dbgpt.configs.model_config import (
LOGDIR,
STATIC_MESSAGE_IMG_PATH,
)
from dbgpt.util.fastapi import create_app, replace_router
from dbgpt.util.fastapi import build_cors_config, create_app, replace_router
from dbgpt.util.i18n_utils import _, set_default_language
from dbgpt.util.parameter_utils import _get_dict_from_obj
from dbgpt.util.system_utils import get_system_info
@@ -263,10 +263,7 @@ def run_uvicorn(param: ServiceWebParameters):
# https://github.com/encode/starlette/issues/617
cors_app = CORSMiddleware(
app=app,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
**build_cors_config(param.cors_allowed_origins),
)
log_level = "info"
if param.log:

View File

@@ -50,7 +50,7 @@ from dbgpt.model.cluster.manager_base import WorkerManager, WorkerManagerFactory
from dbgpt.model.cluster.registry import ModelRegistry
from dbgpt.model.parameter import ModelAPIServerParameters, WorkerType
from dbgpt.util.chat_util import transform_to_sse
from dbgpt.util.fastapi import create_app
from dbgpt.util.fastapi import build_cors_config, create_app
from dbgpt.util.tracer import initialize_tracer, root_tracer, trace
from dbgpt.util.tracer.tracer_impl import TracerParameters
from dbgpt.util.utils import (
@@ -899,10 +899,7 @@ def initialize_apiserver(
# https://github.com/encode/starlette/issues/617
cors_app = CORSMiddleware(
app=app,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
**build_cors_config(apiserver_params.cors_allowed_origins),
)
log_level = "info"
if log_config:

View File

@@ -144,6 +144,16 @@ class ModelAPIServerParameters(BaseServerParameters):
ignore_stop_exceeds_error: Optional[bool] = field(
default=False, metadata={"help": _("Ignore exceeds stop words error")}
)
cors_allowed_origins: str = field(
default="*",
metadata={
"help": _(
"Comma-separated allowed CORS origins for the standalone model "
"API server. Default '*' allows all. Set explicit origins "
"(e.g. http://localhost:3000) to restrict."
)
},
)
@dataclass

View File

@@ -128,3 +128,38 @@ def replace_router(app: FastAPI, router: Optional[APIRouter] = None):
app.router = router
app.setup()
return app
def build_cors_config(allow_origins: Optional[str]) -> Dict[str, Any]:
"""Build CORS middleware kwargs from a comma-separated origins string.
Shared by the webserver (``dbgpt-app``) and the model apiserver
(``dbgpt-core``) so both honor the same ``cors_allowed_origins`` setting
without coupling to each other's parameter classes.
Args:
allow_origins (Optional[str]): Comma-separated allowed origins. ``"*"``
(or empty/None) allows all origins. Otherwise a literal origin list,
e.g. ``"http://localhost:3000,https://your-app.com"``.
Returns:
Dict[str, Any]: Keyword arguments for ``CORSMiddleware`` (without
``app``; the caller supplies ``app``).
Notes:
- ``"*"`` is returned with ``allow_credentials=False`` because W3C
CORS forbids ``Access-Control-Allow-Origin: *`` together with
``Access-Control-Allow-Credentials: true``. The previous hardcoded
``["*"]`` + ``allow_credentials=True`` was an invalid combination.
- An explicit origin list is returned with ``allow_credentials=True``
so cookie/credential-bearing cross-origin requests keep working.
"""
raw = (allow_origins or "*").strip()
origins = [o.strip() for o in raw.split(",") if o.strip()]
allow_all = not origins or "*" in origins
return {
"allow_origins": ["*"] if allow_all else origins,
"allow_credentials": not allow_all,
"allow_methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
"allow_headers": ["*"],
}

View File

@@ -0,0 +1,43 @@
"""Tests for ``dbgpt.util.fastapi`` helpers."""
from dbgpt.util.fastapi import build_cors_config
_DEFAULT_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
def test_wildcard_disables_credentials():
"""``"*"`` must return all origins with credentials disabled (W3C)."""
cfg = build_cors_config("*")
assert cfg["allow_origins"] == ["*"]
assert cfg["allow_credentials"] is False
assert cfg["allow_methods"] == _DEFAULT_METHODS
assert cfg["allow_headers"] == ["*"]
def test_empty_or_none_treated_as_wildcard():
"""Empty string or None should fall back to ``"*"`` (safe default)."""
assert build_cors_config("")["allow_origins"] == ["*"]
assert build_cors_config("")["allow_credentials"] is False
assert build_cors_config(None)["allow_origins"] == ["*"]
assert build_cors_config(None)["allow_credentials"] is False
def test_explicit_origins_enable_credentials_and_trim():
"""Explicit origin list enables credentials and trims whitespace/blanks."""
cfg = build_cors_config(" http://localhost:3000 , https://your-app.com , ,")
assert cfg["allow_origins"] == ["http://localhost:3000", "https://your-app.com"]
assert cfg["allow_credentials"] is True
def test_mixed_wildcard_treated_as_wildcard():
"""Any wildcard entry disables credentials to keep CORS headers valid."""
cfg = build_cors_config("*, https://your-app.com")
assert cfg["allow_origins"] == ["*"]
assert cfg["allow_credentials"] is False
def test_single_explicit_origin():
"""A single explicit origin still enables credentials."""
cfg = build_cors_config("https://your-app.com")
assert cfg["allow_origins"] == ["https://your-app.com"]
assert cfg["allow_credentials"] is True

View File

@@ -1,6 +1,6 @@
import { sendSpacePostRequest } from '@/utils/request';
import { useRequest } from 'ahooks';
import { ConfigProvider, FloatButton, Form, List, Popover, Select, Tooltip, message } from 'antd';
import { ConfigProvider, FloatButton, Form, List, Popover, Select, Tooltip } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -73,9 +73,6 @@ const PromptBot: React.FC<PromptBotProps> = ({ submit, chat_scene }) => {
},
{
refreshDeps: [current, chat_scene],
onError: err => {
message.error(err?.message);
},
manual: true,
},
);

View File

@@ -739,4 +739,9 @@ export const CommonEn = {
// ── FromTaskBanner ──
'scheduled.banner.autoGenerated': 'This conversation was auto-generated by a scheduled task',
'scheduled.banner.backToDetail': 'Back to task details',
// ── request.ts error tips ──
'request.error.network':
'Cannot connect to DB-GPT service: network unreachable (possibly CORS restriction or service not running). Please check cors_allowed_origins config and whether the service is up.',
'request.error.timeout': 'Request timed out, please try again later.',
'request.error.default': 'Request failed (HTTP {{status}}).',
} as const;

View File

@@ -738,4 +738,9 @@ export const CommonZh: Resources['translation'] = {
// ── FromTaskBanner ──
'scheduled.banner.autoGenerated': '此对话由定时任务自动生成',
'scheduled.banner.backToDetail': '返回任务详情',
// ── request.ts 错误提示 ──
'request.error.network':
'无法连接 DB-GPT 服务:网络不可达(可能是 CORS 跨域限制或服务未启动),请检查 cors_allowed_origins 配置及服务是否运行。',
'request.error.timeout': '请求超时,请稍后重试。',
'request.error.default': '请求失败HTTP {{status}})。',
} as const;

View File

@@ -1,3 +1,4 @@
import i18n from '@/app/i18n';
import { getUserId } from '@/utils';
import { message } from 'antd';
import { isPlainObject } from 'lodash';
@@ -22,6 +23,30 @@ const sanitizeBody = (obj: Record<string, any>): string => {
return JSON.stringify(resObj);
};
// 从 axios 错误中提取友好提示文案(走 i18n)。
// 网络层失败(err.response 不存在)按 err.code 细分:超时 / 其他网络错误。
// 浏览器对 CORS 拦截 / 连接拒绝 / 断网 都只给 ERR_NETWORK,JS 无法进一步区分,
// 只能给方向性提示。传给 message.error 的必须是 string/ReactNode,不能是原始
// axios 对象(antd 渲染对象会抛异常 → 触发 Next.js 错误页)。
const formatErrTip = (err: any): string => {
if (!err) return i18n.t('request.error.default', { status: '?' });
// 网络层失败:浏览器拦截或服务不可达,无 response
if (!err.response) {
if (err.code === 'ECONNABORTED') {
return i18n.t('request.error.timeout');
}
return i18n.t('request.error.network');
}
// HTTP 错误:优先用服务端返回的 err_msg / message
const data = err.response.data;
if (data) {
if (typeof data === 'string') return data;
if (typeof data.err_msg === 'string') return data.err_msg;
if (typeof data.message === 'string') return data.message;
}
return i18n.t('request.error.default', { status: err.response.status || '?' });
};
export const sendGetRequest = (url: string, qs?: { [key: string]: any }) => {
if (qs) {
const str = Object.keys(qs)
@@ -38,8 +63,8 @@ export const sendGetRequest = (url: string, qs?: { [key: string]: any }) => {
})
.then(res => res)
.catch(err => {
message.error(err);
Promise.reject(err);
message.error(formatErrTip(err));
return Promise.reject(err);
});
};
@@ -59,8 +84,8 @@ export const sendSpaceGetRequest = (url: string, qs?: { [key: string]: any }) =>
})
.then(res => res)
.catch(err => {
message.error(err);
Promise.reject(err);
message.error(formatErrTip(err));
return Promise.reject(err);
});
};
@@ -73,8 +98,8 @@ export const sendPostRequest = (url: string, body?: any) => {
})
.then(res => res)
.catch(err => {
message.error(err);
Promise.reject(err);
message.error(formatErrTip(err));
return Promise.reject(err);
});
};
@@ -85,8 +110,8 @@ export const sendSpacePostRequest = (url: string, body?: any) => {
})
.then(res => res)
.catch(err => {
message.error(err);
Promise.reject(err);
message.error(formatErrTip(err));
return Promise.reject(err);
});
};
@@ -95,7 +120,7 @@ export const sendSpaceUploadPostRequest = (url: string, body?: any) => {
.post<null, any>(url, body)
.then(res => res)
.catch(err => {
message.error(err);
Promise.reject(err);
message.error(formatErrTip(err));
return Promise.reject(err);
});
};