VYPR
Medium severityNVD Advisory· Published May 26, 2026

CVE-2026-42335

CVE-2026-42335

Description

MaxKB is an open-source AI assistant for enterprise. Prior to 2.8.1, MaxKB v2.8.0 and prior are vulnerable to a server-side request forgery (SSRF) bypass in the OSS file service URL fetch (chat/api/oss/get_url) endpoint. The vulnerability exists due to inconsistent URL parsing between the urlparse validation function and the requests HTTP client, allowing attackers to access internal network services. This vulnerability is fixed in 2.8.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

MaxKB v2.8.0 and prior have an SSRF bypass in the OSS URL fetch endpoint due to URL parsing inconsistency between urlparse and requests.

Vulnerability

MaxKB, an open-source AI-powered question-and-answer application, prior to version 2.8.1 is vulnerable to a server-side request forgery (SSRF) bypass in the OSS file service URL fetch endpoint at chat/api/oss/get_url. The vulnerability stems from inconsistent URL parsing between the urlparse validation function and the requests HTTP client. When processing malformed URLs containing backslashes (\) and @ characters, urlparse may interpret a public host (e.g., 1.1.1.1) while requests connects to an internal host (e.g., 127.0.0.1:6666). This discrepancy allows an attacker to bypass SSRF protections. All versions up to and including 2.8.0 are affected [1].

Exploitation

An attacker must be authenticated to the MaxKB application. The attack involves sending a crafted request to the chat/api/oss/get_url endpoint with a URL that uses a backslash (\) and @ character to exploit the parsing difference. For example, http://127.0.0.1:6666\@1.1.1.1 is validated by urlparse as pointing to 1.1.1.1 (allowed), but requests connects to 127.0.0.1:6666 (internal). No known additional user interaction is required beyond the initial request [1].

Impact

Successful exploitation enables the attacker to perform SSRF attacks, accessing internal network services that are otherwise not directly reachable. This could lead to information disclosure, potential lateral movement, or further compromise of internal resources. The attacker does not gain direct remote code execution or escalation but can probe and interact with internal systems [1].

Mitigation

The vulnerability is fixed in MaxKB version 2.8.1. Users should upgrade to this version or apply the official patch that unifies URL parsing logic between the validator and the HTTP client. No public workarounds have been disclosed for earlier versions [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
  • 1panel Dev/Maxkbinferred2 versions
    <=2.8.0+ 1 more
    • (no CPE)range: <=2.8.0
    • (no CPE)range: <2.8.1

Patches

2
c9043db14b64

feat: add SafeHTTPAdapter for secure HTTP requests and enhance URL validation

https://github.com/1Panel-dev/MaxKBwxg0103Apr 16, 2026Fixed in 2.8.1via llm-release-walk
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)
    
6d3dae38450d

feat: Supports tree selector (#5122)

https://github.com/1Panel-dev/MaxKBshaohuzhang1Apr 17, 2026Fixed in 2.8.1via release-tag
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

"Inconsistent URL parsing between urlparse validation and the requests HTTP client allows SSRF bypass via crafted URLs with backslashes, authentication-info, or fragment injection."

Attack vector

An attacker sends a crafted URL to the `chat/api/oss/get_url` endpoint that passes the `urlparse`-based validation but is interpreted differently by the `requests` library. For example, a URL like `http://127.0.0.1:6666\@1.1.1.1/` uses a backslash or authentication-info trick to make `urlparse` see an external host while `requests` resolves to an internal IP. This bypasses the original `is_private_ip` check and allows the server to make HTTP requests to internal network services such as cloud metadata endpoints or internal APIs [patch_id=2590863]. The attacker must have network access to the MaxKB instance and the ability to supply a URL parameter to the vulnerable endpoint.

Affected code

The vulnerability resides in `apps/oss/serializers/file.py` in the `get_url_content` function and the `validate_url` function. The original code used Python's `urlparse` for validation but then passed the raw URL directly to `requests.get()`, creating a parsing inconsistency that attackers could exploit. The patch introduces `SafeHTTPAdapter` and `validate_and_normalize_url` in the same file, and adds a permission check in `apps/oss/views/file.py`.

What the fix does

The patch introduces `SafeHTTPAdapter`, a custom HTTP adapter that resolves the hostname to IP addresses via `socket.getaddrinfo` before connecting and blocks private, loopback, reserved, link-local, and multicast IPs [patch_id=2590863]. It also replaces the simple `validate_url` with `validate_and_normalize_url`, which strips dangerous characters (backslash, null bytes, newlines), rejects suspicious authentication-info in the netloc, removes URL fragments, and re-constructs a clean URL via `urlunparse`. Additionally, a permission check was added in `apps/oss/views/file.py` to ensure the requesting user's `application_id` matches the target `application_id`.

Preconditions

  • networkAttacker must have network access to the MaxKB instance
  • inputAttacker must be able to supply a URL parameter to the chat/api/oss/get_url endpoint

Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.