Skip to content

Commit c7693fc

Browse files
perf: Multiple Domain Validation for Embedded Systems #388
1 parent a5ba142 commit c7693fc

File tree

10 files changed

+86
-27
lines changed

10 files changed

+86
-27
lines changed

backend/apps/system/api/assistant.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from common.core.deps import SessionDep, Trans
1818
from common.core.security import create_access_token
1919
from common.core.sqlbot_cache import clear_cache
20-
from common.utils.utils import get_origin_from_referer
20+
from common.utils.utils import get_origin_from_referer, origin_match_domain
2121

2222
router = APIRouter(tags=["system/assistant"], prefix="/system/assistant")
2323

@@ -30,13 +30,15 @@ async def info(request: Request, response: Response, session: SessionDep, trans:
3030
if not db_model:
3131
raise RuntimeError(f"assistant application not exist")
3232
db_model = AssistantModel.model_validate(db_model)
33-
response.headers["Access-Control-Allow-Origin"] = db_model.domain
33+
3434
origin = request.headers.get("origin") or get_origin_from_referer(request)
3535
if not origin:
3636
raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or ''))
3737
origin = origin.rstrip('/')
38-
if origin != db_model.domain:
38+
if not origin_match_domain(origin, db_model.domain):
3939
raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or ''))
40+
41+
response.headers["Access-Control-Allow-Origin"] = origin
4042
return db_model
4143

4244

@@ -48,13 +50,14 @@ async def getApp(request: Request, response: Response, session: SessionDep, tran
4850
if not db_model:
4951
raise RuntimeError(f"assistant application not exist")
5052
db_model = AssistantModel.model_validate(db_model)
51-
response.headers["Access-Control-Allow-Origin"] = db_model.domain
5253
origin = request.headers.get("origin") or get_origin_from_referer(request)
5354
if not origin:
5455
raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or ''))
5556
origin = origin.rstrip('/')
56-
if origin != db_model.domain:
57+
if not origin_match_domain(origin, db_model.domain):
5758
raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or ''))
59+
60+
response.headers["Access-Control-Allow-Origin"] = origin
5861
return db_model
5962

6063

backend/apps/system/crud/assistant.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,22 @@ class AssistantOutDs:
9999
assistant: AssistantHeader
100100
ds_list: Optional[list[AssistantOutDsSchema]] = None
101101
certificate: Optional[str] = None
102+
request_origin: Optional[str] = None
102103

103104
def __init__(self, assistant: AssistantHeader):
104105
self.assistant = assistant
105106
self.ds_list = None
106107
self.certificate = assistant.certificate
108+
self.request_origin = assistant.request_origin
107109
self.get_ds_from_api()
108110

109111
# @cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_DS, keyExpression="current_user.id")
110112
def get_ds_from_api(self):
111113
config: dict[any] = json.loads(self.assistant.configuration)
112114
endpoint: str = config['endpoint']
115+
endpoint = self.get_complete_endpoint(endpoint=endpoint)
116+
if not endpoint:
117+
raise Exception(f"Failed to get datasource list from {config['endpoint']}, error: [Assistant domain or endpoint miss]")
113118
certificateList: list[any] = json.loads(self.certificate)
114119
header = {}
115120
cookies = {}
@@ -137,6 +142,17 @@ def get_ds_from_api(self):
137142
else:
138143
raise Exception(f"Failed to get datasource list from {endpoint}, status code: {res.status_code}")
139144

145+
def get_complete_endpoint(self, endpoint: str) -> str | None:
146+
if endpoint.startswith("http://") or endpoint.startswith("https://"):
147+
return endpoint
148+
domain_text = self.assistant.domain
149+
if not domain_text:
150+
return None
151+
if ',' in domain_text:
152+
return self.request_origin.strip('/') if self.request_origin else domain_text.split(',')[0].strip('/') + endpoint
153+
else:
154+
return f"{domain_text}{endpoint}"
155+
140156
def get_simple_ds_list(self):
141157
if self.ds_list:
142158
return [{'id': ds.id, 'name': ds.name, 'description': ds.comment} for ds in self.ds_list]

backend/apps/system/middleware/auth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from common.core.config import settings
1717
from common.core.schemas import TokenPayload
1818
from common.utils.locale import I18n
19-
from common.utils.utils import SQLBotLogUtil
19+
from common.utils.utils import SQLBotLogUtil, get_origin_from_referer
2020
from common.utils.whitelist import whiteUtils
2121
from fastapi.security.utils import get_authorization_scheme_param
2222
from common.core.deps import get_i18n
@@ -40,6 +40,9 @@ async def dispatch(self, request, call_next):
4040
if validator[0]:
4141
request.state.current_user = validator[1]
4242
request.state.assistant = validator[2]
43+
origin = request.headers.get("origin") or get_origin_from_referer(request)
44+
if origin and validator[2]:
45+
request.state.assistant.request_origin = origin
4346
return await call_next(request)
4447
message = trans('i18n_permission.authenticate_invalid', msg = validator[1])
4548
return JSONResponse(message, status_code=401, headers={"Access-Control-Allow-Origin": "*"})

backend/apps/system/schemas/system_schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ class AssistantHeader(AssistantDTO):
116116
unique: Optional[str] = None
117117
certificate: Optional[str] = None
118118
online: bool = False
119+
request_origin: Optional[str] = None
119120

120121

121122
class AssistantValidator(BaseModel):

backend/common/utils/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,12 @@ def get_origin_from_referer(request: Request):
263263
SQLBotLogUtil.error(f"解析 Referer 出错: {e}")
264264
return referer
265265

266+
def origin_match_domain(origin: str, domain: str) -> bool:
267+
if not origin or not domain:
268+
return False
269+
origin_text = origin.rstrip('/')
270+
domain_list = domain.replace(" ", "").split(',')
271+
return origin_text in [d.rstrip('/') for d in domain_list]
266272

267273
def equals_ignore_case(str1: str, *args: str) -> bool:
268274
if str1 is None:

frontend/src/i18n/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,9 @@
573573
"creating_advanced_applications": "Creating Advanced Applications",
574574
"configure_interface": "Configure interface",
575575
"interface_url": "Interface URL",
576-
"format_is_incorrect": "format is incorrect",
576+
"format_is_incorrect": "format is incorrect{msg}",
577+
"domain_format_incorrect": ",start with http/https, no trailing slash (/), multiple domains separated by half-width commas (,)",
578+
"interface_url_incorrect": ",enter a relative path starting with /",
577579
"aes_enable": "Enable AES encryption",
578580
"aes_enable_tips": "The fields (host, user, password, dataBase, schema) are all encrypted using the AES-CBC-PKCS5Padding encryption method",
579581
"bit": "bit",

frontend/src/i18n/ko-KR.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,9 @@
572572
"private": "비공개",
573573
"configure_interface": "인터페이스 설정",
574574
"interface_url": "인터페이스 URL",
575-
"format_is_incorrect": "형식이 올바르지 않습니다",
575+
"format_is_incorrect": "형식이 올바르지 않습니다{msg}",
576+
"domain_format_incorrect": ", http/https로 시작, 슬래시(/)로 끝나지 않음, 여러 도메인은 반각 쉼표(,)로 구분",
577+
"interface_url_incorrect": ", 상대 경로를 입력해주세요. /로 시작합니다",
576578
"aes_enable": "AES 암호화 활성화",
577579
"aes_enable_tips": "암호화 필드 (host, user, password, dataBase, schema)는 모두 AES-CBC-PKCS5Padding 암호화 방식을 사용합니다",
578580
"bit": "비트",

frontend/src/i18n/zh-CN.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,9 @@
572572
"private": "私有",
573573
"configure_interface": "配置接口",
574574
"interface_url": "接口 URL",
575-
"format_is_incorrect": "格式不对",
575+
"format_is_incorrect": "格式不对{msg}",
576+
"domain_format_incorrect": ",http或https开头,不能以 / 结尾,多个域名以逗号(半角)分隔",
577+
"interface_url_incorrect": ",请填写相对路径,以/开头",
576578
"aes_enable": "开启 AES 加密",
577579
"aes_enable_tips": "加密字段 (host, user, password, dataBase, schema) 均采用 AES-CBC-PKCS5Padding 加密方式",
578580
"bit": "",

frontend/src/views/system/embedded/Page.vue

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,20 @@ const validateUrl = (_: any, value: any, callback: any) => {
209209
)
210210
} else {
211211
// var Expression = /(https?:\/\/)?([\da-z\.-]+)\.([a-z]{2,6})(:\d{1,5})?([\/\w\.-]*)*\/?(#[\S]+)?/ // eslint-disable-line
212-
var Expression = /^https?:\/\/[^\s/?#]+(:\d+)?/i
213-
var objExp = new RegExp(Expression)
214-
if (objExp.test(value) && !value.endsWith('/')) {
215-
callback()
216-
} else {
217-
callback(t('embedded.format_is_incorrect'))
218-
}
212+
value
213+
.trim()
214+
.split(',')
215+
.forEach((tempVal: string) => {
216+
var Expression = /^https?:\/\/[^\s/?#]+(:\d+)?/i
217+
var objExp = new RegExp(Expression)
218+
if (objExp.test(tempVal) && !tempVal.endsWith('/')) {
219+
callback()
220+
} else {
221+
callback(
222+
t('embedded.format_is_incorrect', { msg: t('embedded.domain_format_incorrect') })
223+
)
224+
}
225+
})
219226
}
220227
}
221228
const rules = {

frontend/src/views/system/embedded/iframe.vue

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,16 @@ const handleBaseEmbedded = (row: any) => {
157157
const handleAdvancedEmbedded = (row: any) => {
158158
advancedApplication.value = true
159159
if (row) {
160-
Object.assign(urlForm, cloneDeep(JSON.parse(row.configuration)))
160+
const tempData = cloneDeep(JSON.parse(row.configuration))
161+
if (tempData?.endpoint.startsWith('http')) {
162+
row.domain
163+
.trim()
164+
.split(',')
165+
.forEach((domain: string) => {
166+
tempData.endpoint = tempData.endpoint.replace(domain, '')
167+
})
168+
}
169+
Object.assign(urlForm, tempData)
161170
}
162171
ruleConfigvVisible.value = true
163172
dialogTitle.value = row?.id
@@ -265,13 +274,20 @@ const validateUrl = (_: any, value: any, callback: any) => {
265274
)
266275
} else {
267276
// var Expression = /(https?:\/\/)?([\da-z\.-]+)\.([a-z]{2,6})(:\d{1,5})?([\/\w\.-]*)*\/?(#[\S]+)?/ // eslint-disable-line
268-
var Expression = /^https?:\/\/[^\s/?#]+(:\d+)?/i
269-
var objExp = new RegExp(Expression)
270-
if (objExp.test(value) && !value.endsWith('/')) {
271-
callback()
272-
} else {
273-
callback(t('embedded.format_is_incorrect'))
274-
}
277+
value
278+
.trim()
279+
.split(',')
280+
.forEach((tempVal: string) => {
281+
var Expression = /^https?:\/\/[^\s/?#]+(:\d+)?/i
282+
var objExp = new RegExp(Expression)
283+
if (objExp.test(tempVal) && !tempVal.endsWith('/')) {
284+
callback()
285+
} else {
286+
callback(
287+
t('embedded.format_is_incorrect', { msg: t('embedded.domain_format_incorrect') })
288+
)
289+
}
290+
})
275291
}
276292
}
277293
const rules = {
@@ -307,12 +323,13 @@ const validatePass = (_: any, value: any, callback: any) => {
307323
)
308324
} else {
309325
// var Expression = /(https?:\/\/)?([\da-z\.-]+)\.([a-z]{2,6})(:\d{1,5})?([\/\w\.-]*)*\/?(#[\S]+)?/ // eslint-disable-line
310-
var Expression = /^https?:\/\/[^\s/?#]+(:\d+)?/i
326+
// var Expression = /^https?:\/\/[^\s/?#]+(:\d+)?/i
327+
var Expression = /^\/([a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+(\?[a-zA-Z0-9_=&-]+)?$/
311328
var objExp = new RegExp(Expression)
312-
if (objExp.test(value) && value.startsWith(currentEmbedded.domain)) {
329+
if (objExp.test(value)) {
313330
callback()
314331
} else {
315-
callback(t('embedded.format_is_incorrect'))
332+
callback(t('embedded.format_is_incorrect', { msg: t('embedded.interface_url_incorrect') }))
316333
}
317334
}
318335
}

0 commit comments

Comments
 (0)