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. 潜在安全风险
当前实现存在多个安全隐患:
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)
})
})
总结与最佳实践
安全加固成果
通过本次修复,我们实现了:
- 全面的参数校验:覆盖所有输入参数的合法性验证
- 安全边界防护:防止SSRF、路径遍历等常见攻击
- 功能完整性:确保API在各种边界条件下的稳定运行
- 错误处理优化:提供清晰明确的错误信息
持续安全建议
版本兼容性
本次修复保持向后兼容,所有合法的现有调用都不会受到影响,同时增强了安全性和稳定性。
建议所有LLOneBot用户及时更新到包含此修复的版本,以确保机器人系统的安全运行。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



