LLOneBot项目中download_file参数校验问题的分析与修复

LLOneBot项目中download_file参数校验问题的分析与修复

问题背景

在LLOneBot项目中,download_file API作为go-cqhttp兼容功能的一部分,负责从URL或base64数据下载文件。然而,当前实现存在严重的参数校验缺失问题,可能导致安全漏洞和功能异常。

现有问题分析

1. 参数校验完全缺失

查看src/onebot11/action/go-cqhttp/DownloadFile.ts文件,发现GoCQHTTPDownloadFile类没有重写check方法:

export default class GoCQHTTPDownloadFile extends BaseAction<Payload, FileResponse> {
  actionName = ActionName.GoCQHTTP_DownloadFile
  
  // 缺少check方法重写,直接使用BaseAction的空校验
  protected async _handle(payload: Payload): Promise<FileResponse> {
    // 处理逻辑...
  }
}

2. 潜在安全风险

当前实现存在多个安全隐患:

mermaid

3. 功能性问题

  • URL格式验证缺失:未验证URL的有效性和协议限制
  • base64数据验证缺失:未验证base64字符串的合法性
  • 文件名安全验证缺失:未对文件名进行安全过滤
  • headers参数解析错误:headers解析逻辑存在bug

问题复现与影响

复现代码示例

// 恶意URL示例 - SSRF攻击
const maliciousPayload = {
  url: "http://internal-api:8080/admin",
  name: "../../etc/passwd"
}

// 非法base64示例
const invalidBase64Payload = {
  base64: "not-a-valid-base64-string",
  name: "test.txt"
}

// 路径遍历示例
const pathTraversalPayload = {
  url: "http://example.com/file",
  name: "../../../system/config.json"
}

影响范围

问题类型影响程度可能后果
SSRF攻击高危内部网络探测、服务攻击
路径遍历高危敏感文件读取、系统文件覆盖
任意文件写入中危磁盘空间耗尽、系统不稳定
无效参数处理低危功能异常、错误信息泄露

解决方案设计

1. 完整的参数校验方案

protected async check(payload: Payload): Promise<BaseCheckResult> {
  // 必需参数检查
  if (!payload.url && !payload.base64) {
    return {
      valid: false,
      message: '必须提供url或base64参数'
    }
  }

  // 互斥参数检查
  if (payload.url && payload.base64) {
    return {
      valid: false,
      message: 'url和base64参数不能同时使用'
    }
  }

  // URL参数校验
  if (payload.url) {
    const urlValidation = this.validateUrl(payload.url)
    if (!urlValidation.valid) {
      return urlValidation
    }
  }

  // base64参数校验
  if (payload.base64) {
    const base64Validation = this.validateBase64(payload.base64)
    if (!base64Validation.valid) {
      return base64Validation
    }
  }

  // 文件名安全校验
  if (payload.name) {
    const filenameValidation = this.validateFilename(payload.name)
    if (!filenameValidation.valid) {
      return filenameValidation
    }
  }

  return { valid: true }
}

2. 安全验证方法实现

private validateUrl(url: string): BaseCheckResult {
  try {
    const parsedUrl = new URL(url)
    
    // 协议限制
    const allowedProtocols = ['http:', 'https:']
    if (!allowedProtocols.includes(parsedUrl.protocol)) {
      return {
        valid: false,
        message: `不支持的协议: ${parsedUrl.protocol},仅支持http和https`
      }
    }

    // 内网地址检测
    const hostname = parsedUrl.hostname
    if (this.isPrivateIP(hostname)) {
      return {
        valid: false,
        message: '不允许访问内网地址'
      }
    }

    // URL长度限制
    if (url.length > 2048) {
      return {
        valid: false,
        message: 'URL长度超过限制'
      }
    }

    return { valid: true }
  } catch (e) {
    return {
      valid: false,
      message: '无效的URL格式'
    }
  }
}

private validateBase64(base64Str: string): BaseCheckResult {
  // Base64格式验证
  const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
  if (!base64Regex.test(base64Str)) {
    return {
      valid: false,
      message: '无效的base64格式'
    }
  }

  // 数据大小限制(10MB)
  const estimatedSize = (base64Str.length * 3) / 4
  if (estimatedSize > 10 * 1024 * 1024) {
    return {
      valid: false,
      message: 'base64数据大小超过10MB限制'
    }
  }

  return { valid: true }
}

private validateFilename(filename: string): BaseCheckResult {
  // 路径遍历检测
  if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
    return {
      valid: false,
      message: '文件名包含非法字符'
    }
  }

  // 文件名长度限制
  if (filename.length > 255) {
    return {
      valid: false,
      message: '文件名长度超过255字符限制'
    }
  }

  // 保留文件名检测
  const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1', etc.]
  const upperName = filename.toUpperCase().split('.')[0]
  if (reservedNames.includes(upperName)) {
    return {
      valid: false,
      message: '文件名是系统保留名称'
    }
  }

  return { valid: true }
}

3. headers解析修复

getHeaders(headersIn?: string | string[]): Record<string, string> {
  const headers: Record<string, string> = {}
  
  if (typeof headersIn === 'string') {
    // 修复:正确的换行符分割
    headersIn = headersIn.split(/\r?\n/)
  }
  
  if (Array.isArray(headersIn)) {
    for (const headerItem of headersIn) {
      const separatorIndex = headerItem.indexOf(':')
      if (separatorIndex > 0) {
        const key = headerItem.substring(0, separatorIndex).trim()
        const value = headerItem.substring(separatorIndex + 1).trim()
        headers[key] = value
      }
    }
  }
  
  // 设置默认Content-Type
  if (!headers['Content-Type']) {
    headers['Content-Type'] = 'application/octet-stream'
  }
  
  return headers
}

完整修复代码

import BaseAction from '../BaseAction'
import { ActionName, BaseCheckResult } from '../types'
import fs from 'fs'
import { join as joinPath } from 'node:path'
import { calculateFileMD5, httpDownload, TEMP_DIR } from '../../../common/utils'
import { randomUUID } from 'node:crypto'

interface Payload {
  thread_count?: number
  url?: string
  base64?: string
  name?: string
  headers?: string | string[]
}

interface FileResponse {
  file: string
}

export default class GoCQHTTPDownloadFile extends BaseAction<Payload, FileResponse> {
  actionName = ActionName.GoCQHTTP_DownloadFile

  protected async check(payload: Payload): Promise<BaseCheckResult> {
    // 参数存在性检查
    if (!payload.url && !payload.base64) {
      return {
        valid: false,
        message: '必须提供url或base64参数'
      }
    }

    // 参数互斥检查
    if (payload.url && payload.base64) {
      return {
        valid: false,
        message: 'url和base64参数不能同时使用'
      }
    }

    // URL参数校验
    if (payload.url) {
      const urlValidation = this.validateUrl(payload.url)
      if (!urlValidation.valid) {
        return urlValidation
      }
    }

    // base64参数校验
    if (payload.base64) {
      const base64Validation = this.validateBase64(payload.base64)
      if (!base64Validation.valid) {
        return base64Validation
      }
    }

    // 文件名安全校验
    if (payload.name) {
      const filenameValidation = this.validateFilename(payload.name)
      if (!filenameValidation.valid) {
        return filenameValidation
      }
    }

    return { valid: true }
  }

  protected async _handle(payload: Payload): Promise<FileResponse> {
    const isRandomName = !payload.name
    let name = payload.name || randomUUID()
    const filePath = joinPath(TEMP_DIR, name)

    if (payload.base64) {
      const buffer = Buffer.from(payload.base64, 'base64')
      fs.writeFileSync(filePath, buffer)
    } else if (payload.url) {
      const headers = this.getHeaders(payload.headers)
      let buffer = await httpDownload({ url: payload.url, headers: headers })
      fs.writeFileSync(filePath, Buffer.from(buffer))
    }

    if (fs.existsSync(filePath)) {
      if (isRandomName) {
        const md5 = await calculateFileMD5(filePath)
        const newPath = joinPath(TEMP_DIR, md5)
        fs.renameSync(filePath, newPath)
        return { file: newPath }
      }
      return { file: filePath }
    } else {
      throw new Error('文件写入失败, 检查权限')
    }
  }

  private validateUrl(url: string): BaseCheckResult {
    try {
      const parsedUrl = new URL(url)
      
      // 协议限制
      const allowedProtocols = ['http:', 'https:']
      if (!allowedProtocols.includes(parsedUrl.protocol)) {
        return {
          valid: false,
          message: `不支持的协议: ${parsedUrl.protocol},仅支持http和https`
        }
      }

      // 内网地址检测
      if (this.isPrivateIP(parsedUrl.hostname)) {
        return {
          valid: false,
          message: '不允许访问内网地址'
        }
      }

      return { valid: true }
    } catch (e) {
      return {
        valid: false,
        message: '无效的URL格式'
      }
    }
  }

  private validateBase64(base64Str: string): BaseCheckResult {
    const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
    if (!base64Regex.test(base64Str)) {
      return {
        valid: false,
        message: '无效的base64格式'
      }
    }

    // 数据大小限制
    const estimatedSize = (base64Str.length * 3) / 4
    if (estimatedSize > 10 * 1024 * 1024) {
      return {
        valid: false,
        message: 'base64数据大小超过10MB限制'
      }
    }

    return { valid: true }
  }

  private validateFilename(filename: string): BaseCheckResult {
    if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
      return {
        valid: false,
        message: '文件名包含非法字符'
      }
    }

    if (filename.length > 255) {
      return {
        valid: false,
        message: '文件名长度超过255字符限制'
      }
    }

    return { valid: true }
  }

  private isPrivateIP(hostname: string): boolean {
    // 内网IP地址检测逻辑
    const ipRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|127\.|::1|fc00::|fe80::)/
    return ipRegex.test(hostname) || hostname === 'localhost'
  }

  getHeaders(headersIn?: string | string[]): Record<string, string> {
    const headers: Record<string, string> = {}
    
    if (typeof headersIn === 'string') {
      headersIn = headersIn.split(/\r?\n/)
    }
    
    if (Array.isArray(headersIn)) {
      for (const headerItem of headersIn) {
        const separatorIndex = headerItem.indexOf(':')
        if (separatorIndex > 0) {
          const key = headerItem.substring(0, separatorIndex).trim()
          const value = headerItem.substring(separatorIndex + 1).trim()
          headers[key] = value
        }
      }
    }
    
    if (!headers['Content-Type']) {
      headers['Content-Type'] = 'application/octet-stream'
    }
    
    return headers
  }
}

测试用例设计

单元测试用例

describe('GoCQHTTPDownloadFile参数校验', () => {
  const action = new GoCQHTTPDownloadFile()

  test('缺少必要参数应该报错', async () => {
    const result = await action.check({})
    expect(result.valid).toBe(false)
    expect(result.message).toContain('必须提供url或base64参数')
  })

  test('同时提供url和base64应该报错', async () => {
    const result = await action.check({
      url: 'http://example.com',
      base64: 'dGVzdA=='
    })
    expect(result.valid).toBe(false)
    expect(result.message).toContain('不能同时使用')
  })

  test('非法URL格式应该报错', async () => {
    const result = await action.check({
      url: 'invalid-url'
    })
    expect(result.valid).toBe(false)
  })

  test('内网URL应该被拒绝', async () => {
    const result = await action.check({
      url: 'http://192.168.1.1/admin'
    })
    expect(result.valid).toBe(false)
  })

  test('非法base64应该报错', async () => {
    const result = await action.check({
      base64: 'invalid-base64!@#'
    })
    expect(result.valid).toBe(false)
  })

  test('路径遍历文件名应该报错', async () => {
    const result = await action.check({
      url: 'http://example.com/file',
      name: '../../etc/passwd'
    })
    expect(result.valid).toBe(false)
  })
})

总结与最佳实践

安全加固成果

通过本次修复,我们实现了:

  1. 全面的参数校验:覆盖所有输入参数的合法性验证
  2. 安全边界防护:防止SSRF、路径遍历等常见攻击
  3. 功能完整性:确保API在各种边界条件下的稳定运行
  4. 错误处理优化:提供清晰明确的错误信息

持续安全建议

mermaid

版本兼容性

本次修复保持向后兼容,所有合法的现有调用都不会受到影响,同时增强了安全性和稳定性。

建议所有LLOneBot用户及时更新到包含此修复的版本,以确保机器人系统的安全运行。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值