CVE-2026-42337
Description
MaxKB is an open-source AI assistant for enterprise. MaxKB 2.8.0 and prior are vulnerable to a broken access control vulnerability in the OSS file service URL fetch API (chat/api/oss/get_url). The endpoint uses application_id from the URL path without validating ownership, allowing attackers to perform operations under other applications’ policies. This vulnerability is fixed in 2.8.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
MaxKB ≤2.8.0 OSS URL fetch API lacks application ownership validation, allowing authenticated cross-application privilege bypass.
Vulnerability
MaxKB versions 2.8.0 and prior contain a broken access control vulnerability in the OSS file service URL fetch API (chat/api/oss/get_url). The GetUrlView.get() method accepts an application_id from the URL path and passes it to get_url_content() without verifying that the authenticated token belongs to that application. This allows an attacker to perform operations under other applications' policies, breaking tenant isolation. [1]
Exploitation
An authenticated attacker can craft a request to the /chat/api/oss/get_url endpoint with an arbitrary application_id that does not belong to their own application. Since ownership is not validated, the endpoint loads the target application's policy and executes URL fetch operations in its context. No additional privileges or user interaction beyond authentication are required. [1]
Impact
Successful exploitation enables an attacker to access features and policies of other applications, bypassing application-level isolation. The attacker can perform operations such as URL fetching under another application's identity, potentially leading to unauthorized data access or actions within the scope of the target application's permissions. [1]
Mitigation
Update to MaxKB version 2.8.1, which enforces application ownership validation at the view layer and adds explicit authorization checks to ensure the token matches the application_id. The fix was released on an undisclosed date and is available in the official repository. [1]
AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2<=2.8.0+ 1 more
- (no CPE)range: <=2.8.0
- (no CPE)range: <=2.8.0
Patches
2c9043db14b64feat: add SafeHTTPAdapter for secure HTTP requests and enhance URL validation
3 files changed · +170 −18
apps/knowledge/api/file.py+17 −0 modified@@ -52,3 +52,20 @@ def get_parameters(): @staticmethod def get_response(): return DefaultResultSerializer + +class GetUrlContentAPI(APIMixin): + @staticmethod + def get_parameters(): + return [ + OpenApiParameter( + name="url", + description="文件url", + type=OpenApiTypes.STR, + location='query', + required=True, + ), + ] + + @staticmethod + def get_response(): + return DefaultResultSerializer \ No newline at end of file
apps/oss/serializers/file.py+147 −17 modified@@ -4,7 +4,7 @@ import re import socket import urllib -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse import requests import uuid_utils.compat as uuid @@ -166,6 +166,56 @@ def delete(self): return True +from requests.adapters import HTTPAdapter + + +class SafeHTTPAdapter(HTTPAdapter): + """ + 安全的 HTTP 适配器,防止 DNS 重绑定攻击 + 在建立连接前验证目标 IP 地址 + """ + + def send(self, request, **kwargs): + # 解析 URL 获取主机名 + parsed_url = urlparse(request.url) + host = parsed_url.hostname + + if host: + # 验证目标 IP 是否安全 + self._validate_host_ip(host) + + return super().send(request, **kwargs) + + def _validate_host_ip(self, host: str): + """验证主机解析的 IP 地址是否安全""" + try: + # 获取所有 IP 地址(包括 IPv4 和 IPv6) + addr_infos = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + + for addr_info in addr_infos: + ip = addr_info[4][0] + if self._is_unsafe_ip(ip): + raise AppApiException(500, _('Access to internal IP addresses is blocked')) + except AppApiException: + raise + except Exception as e: + raise AppApiException(500, _('Failed to resolve host: {error}').format(error=str(e))) + + def _is_unsafe_ip(self, ip: str) -> bool: + """检查 IP 地址是否属于不安全的范围""" + try: + ip_addr = ipaddress.ip_address(ip) + return ( + ip_addr.is_private or + ip_addr.is_loopback or + ip_addr.is_reserved or + ip_addr.is_link_local or + ip_addr.is_multicast + ) + except Exception: + return True + + def get_url_content(url, application_id: str): application = Application.objects.filter(id=application_id).first() if application is None: @@ -177,11 +227,21 @@ def get_url_content(url, application_id: str): file_limit = application.file_upload_setting.get('fileLimit') * 1024 * 1024 parsed = validate_url(url) - response = requests.get( - url, - timeout=3, - allow_redirects=False - ) + # 创建带有安全检查的 session + session = requests.Session() + safe_adapter = SafeHTTPAdapter() + session.mount('http://', safe_adapter) + session.mount('https://', safe_adapter) + + try: + response = session.get( + url, + timeout=3, + allow_redirects=False + ) + finally: + session.close() + final_host = urlparse(response.url).hostname if is_private_ip(final_host): raise ValueError("Blocked unsafe redirect to internal host") @@ -220,24 +280,94 @@ def is_private_ip(host: str) -> bool: return True -def validate_url(url: str): - """验证 URL 是否安全""" +def validate_and_normalize_url(url: str) -> str: + """ + 严格验证并规范化 URL,防止 URL 解析绕过攻击 + + 防御场景: + - http://127.0.0.1:6666\@1.1.1.1/ (反斜杠绕过) + - http://127.0.0.1:6666@1.1.1.1/ (认证信息混淆) + - http://1.1.1.1#@127.0.0.1:6666/ (片段注入) + """ if not url: raise ValueError("URL is required") + # 1. 拒绝包含危险字符的 URL + dangerous_patterns = [ + r'\\', # 反斜杠 + r'\s', # 空白字符 + r'%00', # 空字节 + r'%0a', # 换行符 + r'%0d', # 回车符 + ] + + url_lower = url.lower() + for pattern in dangerous_patterns: + if re.search(pattern, url_lower): + raise ValueError("URL contains dangerous characters") + + # 2. 解析 URL parsed = urlparse(url) - # 仅允许 http / https + # 3. 仅允许 http / https if parsed.scheme not in ("http", "https"): raise ValueError("Only http and https are allowed") - host = parsed.hostname - # 域名不能为空 - if not host: - raise ValueError("Invalid URL") + # 4. 提取主机名(从 netloc 中) + netloc = parsed.netloc + + # 5. 如果 netloc 中包含 @,说明有认证信息,需要特别处理 + if '@' in netloc: + # 分离认证信息和主机 + auth_part, host_part = netloc.rsplit('@', 1) + + # 检查认证部分是否包含危险的 IP 或端口信息 + # 攻击者可能在认证部分放置内网地址 + if ':' in auth_part or '.' in auth_part: + raise ValueError("Authentication part contains suspicious content") + + # 使用真实的主机部分 + actual_host = host_part.split(':')[0] if ':' in host_part else host_part + else: + # 没有认证信息,直接提取主机 + actual_host = parsed.hostname + + # 6. 验证主机名不为空 + if not actual_host: + raise ValueError("Invalid URL: missing hostname") + + # 7. 验证主机不是 IP 地址形式的内网地址 + # 这样可以防止直接在 URL 中使用内网 IP + try: + # 尝试解析为 IP 地址 + ip_addr = ipaddress.ip_address(actual_host) + if is_private_ip(actual_host): + raise ValueError("Access to internal IP addresses is blocked") + except ValueError as e: + # 如果不是 IP 地址(是域名),则继续检查 + if "internal IP" in str(e): + raise + # 对于域名,检查其解析结果 + if is_private_ip(actual_host): + raise ValueError("Access to internal IP addresses is blocked") - # 禁止访问内部、保留、环回、云 metadata - if is_private_ip(host): - raise ValueError("Access to internal IP addresses is blocked") + # 8. 重新构建干净的 URL,移除可能的认证信息 + clean_netloc = actual_host + if parsed.port: + clean_netloc = f"{actual_host}:{parsed.port}" - return parsed + clean_url = urlunparse(( + parsed.scheme, + clean_netloc, + parsed.path, + parsed.params, + parsed.query, + '' # 移除 fragment,防止片段注入 + )) + + return clean_url + + +def validate_url(url: str): + """验证 URL 是否安全(保留向后兼容)""" + return validate_and_normalize_url(url)
apps/oss/views/file.py+6 −1 modified@@ -5,9 +5,10 @@ from rest_framework.views import APIView from rest_framework.views import Request from common.auth import TokenAuth, AllTokenAuth +from common.constants.permission_constants import ChatAuth from common.log.log import log from common.result import result -from knowledge.api.file import FileUploadAPI, FileGetAPI +from knowledge.api.file import FileUploadAPI, FileGetAPI, GetUrlContentAPI from oss.serializers.file import FileSerializer, get_url_content @@ -73,11 +74,15 @@ class GetUrlView(APIView): @extend_schema( methods=['GET'], summary=_('Get url'), + parameters=GetUrlContentAPI.get_parameters(), description=_('Get url'), operation_id=_('Get url'), # type: ignore tags=[_('Chat')] # type: ignore ) def get(self, request: Request, application_id: str): + if isinstance(request.auth, ChatAuth) and request.auth.application_id and str( + request.auth.application_id) != application_id: + return result.error(_('No permission')) url = request.query_params.get('url') result_data = get_url_content(url, application_id) return result.success(result_data)
6d3dae38450dfeat: Supports tree selector (#5122)
8 files changed · +546 −2
ui/src/components/dynamics-form/constructor/data.ts+5 −1 modified@@ -59,9 +59,13 @@ const input_type_list = [ label: t('dynamicsForm.input_type_list.Model'), value: 'Model', }, - { + { label: t('dynamicsForm.input_type_list.Knowledge'), value: 'Knowledge', }, + { + label: t('dynamicsForm.input_type_list.TreeSelect'), + value: 'TreeSelect', + }, ] export { input_type_list }
ui/src/components/dynamics-form/constructor/items/TreeSelectConstructor.vue+495 −0 added@@ -0,0 +1,495 @@ +<template> + <el-form-item> + <template #label> + <div class="flex-between"> + <span> + {{ $t('dynamicsForm.TreeSelect.select') }} + <span class="color-danger">*</span> + </span> + <div class="flex"> + <el-checkbox v-model="formValue.multiple" label="允许多选" size="large" class="pr-8" /> + <el-button link type="primary" @click="openAddRootDialog"> + <AppIcon iconName="app-add-outlined" class="mr-4"></AppIcon> + {{ $t('common.add') }} + </el-button> + </div> + </div> + </template> + + <el-tree + :data="treeData" + node-key="id" + default-expand-all + :expand-on-click-node="false" + :props="treeProps" + class="option-tree" + > + <template #default="{ data }"> + <div class="tree-node"> + <div class="tree-node__main"> + <span class="tree-node__label">{{ data.label }}</span> + <span class="tree-node__colon">:</span> + <span class="tree-node__value">{{ data.value }}</span> + </div> + + <div class="tree-node__actions"> + <el-button link type="primary" @click.stop="openAddChildDialog(data)"> + <el-icon class="action-btn"> + <Plus /> + </el-icon> + </el-button> + + <el-button link type="primary" @click.stop="openEditDialog(data)"> + <el-icon class="action-btn"> <Edit /></el-icon> + </el-button> + + <el-button link type="danger" @click.stop="handleDelete(data)"> + <el-icon class="action-btn"> <Delete /></el-icon> + </el-button> + </div> + </div> + </template> + </el-tree> + </el-form-item> + + <el-form-item + class="defaultValueItem" + :required="formValue.required" + prop="default_value" + :label="$t('dynamicsForm.default.label')" + :rules=" + formValue.required + ? [ + { + required: true, + message: `${$t('dynamicsForm.default.label')}${$t('dynamicsForm.default.requiredMessage')}`, + }, + ] + : [] + " + > + <el-tree-select + v-model="formValue.default_value" + :data="treeData" + :multiple="formValue.multiple" + :render-after-expand="false" + style="width: 100%" + /> + </el-form-item> + <!-- 添加弹窗 --> + <el-dialog + v-model="addDialog.visible" + :title=" + addDialog.mode === 'root' + ? $t('dynamicsForm.TreeSelect.addDialog.addFirstOption') + : $t('dynamicsForm.TreeSelect.addDialog.addSubOptions') + " + width="520px" + destroy-on-close + > + <div class="dialog-body"> + <div v-for="(item, index) in addDialog.formList" :key="item.key" class="dialog-row"> + <el-input + v-model.trim="item.label" + :placeholder="$t('dynamicsForm.tag.placeholder')" + maxlength="50" + /> + <el-input + v-model.trim="item.value" + :placeholder="$t('dynamicsForm.Select.placeholder')" + maxlength="100" + /> + <el-button + link + type="danger" + :disabled="addDialog.formList.length === 1" + @click="removeAddRow(index)" + > + <el-icon class="action-btn"> <Delete /></el-icon> + </el-button> + </div> + + <el-button link type="primary" @click="appendAddRow"> + <AppIcon iconName="app-add-outlined" class="mr-4" /> + {{ $t('common.add') }} + </el-button> + </div> + + <template #footer> + <el-button @click="closeAddDialog">{{ $t('common.cancel') }}</el-button> + <el-button type="primary" @click="submitAdd">{{ $t('common.add') }}</el-button> + </template> + </el-dialog> + + <!-- 编辑弹窗 --> + <el-dialog v-model="editDialog.visible" :title="$t('common.edit')" width="520px" destroy-on-close> + <div class="dialog-body"> + <div class="dialog-row dialog-row--edit"> + <el-input + v-model.trim="editDialog.form.label" + :placeholder="$t('dynamicsForm.tag.placeholder')" + maxlength="50" + /> + <el-input + v-model.trim="editDialog.form.value" + :placeholder="$t('dynamicsForm.Select.placeholder')" + maxlength="100" + /> + </div> + </div> + + <template #footer> + <el-button @click="closeEditDialog">{{ $t('common.cancel') }}</el-button> + <el-button type="primary" @click="submitEdit">{{ $t('common.save') }}</el-button> + </template> + </el-dialog> +</template> +<script setup lang="ts"> +import { computed, onMounted, watch, ref, reactive } from 'vue' +import { Edit, Plus, Delete } from '@element-plus/icons-vue' +import { t } from '@/locales/' + +import { ElMessage, ElMessageBox } from 'element-plus' +const props = defineProps<{ + modelValue: any +}>() +const emit = defineEmits(['update:modelValue']) +const formValue = computed({ + set: (item) => { + emit('update:modelValue', item) + }, + get: () => { + return props.modelValue + }, +}) + +const getData = () => { + return { + input_type: 'TreeSelect', + attrs: { multiple: formValue.value.multiple, data: treeData.value }, + default_value: formValue.value.default_value, + show_default_value: formValue.value.show_default_value, + } +} +const rander = (form_data: any) => { + const attrs = form_data.attrs || {} + formValue.value.multiple = attrs.multiple + treeData.value = attrs.data || [] + formValue.value.default_value = form_data.default_value + formValue.value.show_default_value = form_data.show_default_value +} + +defineExpose({ getData, rander }) +onMounted(() => { + formValue.value.default_value = '' + if (formValue.value.show_default_value === undefined) { + formValue.value.show_default_value = true + } +}) + +interface TreeNode { + id: string + label: string + value: string + children?: TreeNode[] +} + +interface AddFormItem { + key: string + label: string + value: string +} + +type AddMode = 'root' | 'child' + +const treeProps = { + children: 'children', + label: 'label', +} + +const treeData = ref<TreeNode[]>([]) + +const addDialog = reactive<{ + visible: boolean + mode: AddMode + parentNode: TreeNode | null + formList: AddFormItem[] +}>({ + visible: false, + mode: 'root', + parentNode: null, + formList: [], +}) + +const editDialog = reactive<{ + visible: boolean + targetNode: TreeNode | null + form: { + label: string + value: string + } +}>({ + visible: false, + targetNode: null, + form: { + label: '', + value: '', + }, +}) + +function createId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function createEmptyRow(): AddFormItem { + return { + key: createId(), + label: '', + value: '', + } +} + +/* -------------------- 添加 -------------------- */ + +function openAddRootDialog() { + addDialog.visible = true + addDialog.mode = 'root' + addDialog.parentNode = null + addDialog.formList = [createEmptyRow()] +} + +function openAddChildDialog(node: TreeNode) { + addDialog.visible = true + addDialog.mode = 'child' + addDialog.parentNode = node + addDialog.formList = [createEmptyRow()] +} + +function appendAddRow() { + addDialog.formList.push(createEmptyRow()) +} + +function removeAddRow(index: number) { + if (addDialog.formList.length === 1) return + addDialog.formList.splice(index, 1) +} + +function closeAddDialog() { + addDialog.visible = false + addDialog.mode = 'root' + addDialog.parentNode = null + addDialog.formList = [] +} + +function submitAdd() { + const validList = addDialog.formList + .map((item) => ({ + label: item.label.trim(), + value: item.value.trim(), + })) + .filter((item) => item.label && item.value) + + if (!validList.length) { + ElMessage.warning(t('dynamicsForm.TreeSelect.addDialog.require')) + return + } + + const newNodes: TreeNode[] = validList.map((item) => ({ + id: createId(), + label: item.label, + value: item.value, + })) + + if (addDialog.mode === 'root') { + treeData.value.push(...newNodes) + } else { + const parent = addDialog.parentNode + if (!parent) { + ElMessage.error(t('dynamicsForm.TreeSelect.addDialog.nodeNotFound')) + return + } + + if (!parent.children) { + parent.children = [] + } + parent.children.push(...newNodes) + } + + ElMessage.success(t('common.saveSuccess')) + closeAddDialog() +} + +/* -------------------- 编辑 -------------------- */ + +function openEditDialog(node: TreeNode) { + editDialog.visible = true + editDialog.targetNode = node + editDialog.form.label = node.label + editDialog.form.value = node.value +} + +function closeEditDialog() { + editDialog.visible = false + editDialog.targetNode = null + editDialog.form.label = '' + editDialog.form.value = '' +} + +function submitEdit() { + const label = editDialog.form.label.trim() + const value = editDialog.form.value.trim() + + if (!label || !value) { + ElMessage.warning(t('dynamicsForm.TreeSelect.addDialog.tagRequire')) + return + } + + if (!editDialog.targetNode) { + ElMessage.error(t('dynamicsForm.TreeSelect.addDialog.nodeNotFound')) + return + } + + editDialog.targetNode.label = label + editDialog.targetNode.value = value + + ElMessage.success(t('common.saveSuccess')) + closeEditDialog() +} + +/* -------------------- 删除 -------------------- */ + +function handleDelete(node: TreeNode) { + ElMessageBox.confirm(`${t('common.deleteConfirm')}「${node.label}」`, t('common.tip'), { + type: 'warning', + }) + .then(() => { + const removed = removeNodeById(treeData.value, node.id) + if (removed) { + ElMessage.success(t('common.deleteSuccess')) + } else { + ElMessage.error(t('common.deleteError')) + } + }) + .catch(() => {}) +} + +function removeNodeById(list: TreeNode[], targetId: string): boolean { + const index = list.findIndex((item) => item.id === targetId) + if (index !== -1) { + list.splice(index, 1) + return true + } + + for (const item of list) { + if (item.children?.length) { + const removed = removeNodeById(item.children, targetId) + if (removed) { + if (item.children.length === 0) { + delete item.children + } + return true + } + } + } + + return false +} +</script> +<style lang="scss" scoped> +.defaultValueItem { + position: relative; + .defaultValueCheckbox { + position: absolute; + right: 0; + top: -35px; + } +} +.dynamic-option-tree { + width: 100%; +} + +.tree-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.tree-header__title { + display: flex; + align-items: center; + gap: 4px; + color: var(--el-text-color-primary); + font-size: 14px; +} + +.required { + color: var(--el-color-danger); +} + +.tree-empty { + width: 100%; + padding: 24px 0; + border: 1px dashed var(--el-border-color); + border-radius: 8px; +} +.option-tree { + width: 100%; +} +.option-tree :deep(.el-tree-node__content) { + height: 18px; +} + +.tree-node { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-right: 8px; +} + +.tree-node__main { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.tree-node__label, +.tree-node__colon, +.tree-node__value { + font-size: 14px; + color: var(--el-text-color-primary); +} + +.tree-node__actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.2s ease; +} + +.tree-node:hover .tree-node__actions { + opacity: 1; +} + +.dialog-body { + padding-top: 8px; +} + +.dialog-row { + display: grid; + grid-template-columns: 1fr 1fr 40px; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.dialog-row--edit { + grid-template-columns: 1fr 1fr; +} +</style>
ui/src/components/dynamics-form/items/tree/TreeSelect.vue+5 −0 added@@ -0,0 +1,5 @@ +<template> + <el-tree-select v-bind="$attrs" /> +</template> +<script setup lang="ts"></script> +<style lang="scss"></style>
ui/src/locales/lang/en-US/dynamics-form.ts+12 −0 modified@@ -56,6 +56,18 @@ export default { placeholder: 'Please enter a description', }, }, + TreeSelect: { + label: 'Tree Select', + select: 'Option', + allowMultipleSelections: 'Allow Multiple Selections', + addDialog: { + addFirstOption: 'Add Root Option', + addSubOptions: 'Add Sub Option', + require: 'Please enter at least one complete item', + nodeNotFound: 'Parent node not found', + tagRequire: 'Label and value cannot be empty', + }, + }, DatePicker: { placeholder: 'Select Date', year: 'Year',
ui/src/locales/lang/zh-CN/common.ts+3 −1 modified@@ -8,6 +8,7 @@ export default { save: '保存', saveSuccess: '保存成功', delete: '删除', + deleteError: '删除失败', deleteSuccess: '删除成功', setting: '设置', settingSuccess: '设置成功', @@ -151,6 +152,7 @@ export default { subTitle: '查看执行记录', }, sourceType: '资源类型', - knowledgeImportTip: '导入创建知识库成功,文档数据未向量化,请先设置知识库的向量模型,并对文档进行向量化操作', + knowledgeImportTip: + '导入创建知识库成功,文档数据未向量化,请先设置知识库的向量模型,并对文档进行向量化操作', import: '导入', }
ui/src/locales/lang/zh-CN/dynamics-form.ts+13 −0 modified@@ -15,6 +15,7 @@ export default { MultiRow: '单行多选卡', Model: '模型', Knowledge: '知识库', + TreeSelect: '树形选择器', }, default: { label: '默认值', @@ -56,6 +57,18 @@ export default { placeholder: '请输入描述', }, }, + TreeSelect: { + label: '树形选择器', + select: '选项', + allowMultipleSelections: '允许多选', + addDialog: { + addFirstOption: '添加一级选项', + addSubOptions: '添加子选项', + require: '请至少填写一条完整数据', + nodeNotFound: '未找到父节点', + tagRequire: '标签和选项值不能为空', + }, + }, DatePicker: { placeholder: '选择日期', year: '年',
ui/src/locales/lang/zh-Hant/dynamics-form.ts+12 −0 modified@@ -56,6 +56,18 @@ export default { placeholder: '請輸入描述', }, }, + TreeSelect: { + label: '樹狀選擇器', + select: '選項', + allowMultipleSelections: '允許多選', + addDialog: { + addFirstOption: '新增第一層選項', + addSubOptions: '新增子選項', + require: '請至少填寫一筆完整資料', + nodeNotFound: '找不到父節點', + tagRequire: '標籤與選項值不可為空', + }, + }, DatePicker: { placeholder: '選擇日期', year: '年',
ui/src/workflow/nodes/base-node/component/UserFieldFormDialog.vue+1 −0 modified@@ -136,6 +136,7 @@ const inputTypeList = ref([ { label: t('dynamicsForm.input_type_list.MultiRow'), value: 'MultiRowConstructor' }, { label: t('dynamicsForm.input_type_list.Model'), value: 'ModelConstructor' }, { label: t('dynamicsForm.input_type_list.Knowledge'), value: 'KnowledgeConstructor' }, + { label: t('dynamicsForm.input_type_list.TreeSelect'), value: 'TreeSelectConstructor' }, ]) const dialogVisible = ref<boolean>(false)
Vulnerability mechanics
Root cause
"Missing authorization check: the endpoint uses application_id from the URL path without validating that the authenticated user owns that application."
Attack vector
An attacker with a valid ChatAuth token for one application can call `GET /chat/api/oss/get_url/{application_id}?url=...` with a different application's ID. The endpoint uses the supplied `application_id` to look up the target application's file upload settings (size limits, allowed types) but never checks that the requester's token is authorized for that application [patch_id=2590838]. This broken access control lets attackers fetch arbitrary URLs under another application's policy context, potentially bypassing file-size or type restrictions imposed by their own application.
Affected code
The vulnerability exists in the OSS file service URL fetch API endpoint `chat/api/oss/get_url`. The affected code is in `apps/oss/views/file.py` (the `GetUrlView.get` method) and `apps/oss/serializers/file.py` (the `get_url_content` function). The endpoint accepts an `application_id` from the URL path but does not verify that the authenticated user owns that application.
What the fix does
Patch [patch_id=2591028] adds an ownership check in `GetUrlView.get`: if the request carries a `ChatAuth` token with an `application_id`, the handler now compares it against the `application_id` from the URL path and returns an error if they do not match. Patch [patch_id=2590838] additionally hardens the URL-fetching logic by introducing `SafeHTTPAdapter` to block DNS rebinding attacks and by rewriting `validate_url` into `validate_and_normalize_url` to strip credential fragments and reject dangerous characters. Together these changes close both the authorization bypass and several SSRF vectors.
Preconditions
- authAttacker must possess a valid ChatAuth token (application-level authentication) for any application in the system.
- networkThe target endpoint must be network-accessible.
- inputAttacker supplies a different application_id in the URL path than the one their token is authorized for.
Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.