Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<i v-if="template.templateType === 'MOBILE'" class="bk-drag-icon bk-drag-mobilephone"> </i>
<i v-else class="bk-drag-icon bk-drag-pc"> </i>
</span>
<div class="name" v-tooltips="template.templateName">{{template.templateName}}</div>
<div class="name" v-bk-overflow-tips="{ content: template.templateName }">{{template.templateName}}</div>
</div>
<div class="stat">{{ template.updateUser || template.createUser }}</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<script>
import { mapGetters } from 'vuex'
import templateMixin from './template-mixin'
import { sanitizeObject } from 'shared/security/property-injection-guard'
import { deepClone } from 'shared/security/property-injection-guard'

export default {
name: 'template-import-dialog',
Expand Down Expand Up @@ -130,7 +130,7 @@
methods: {
handleUploadSuccess (res) {
this.dialog.formData.jsonStr = res.responseData.data
this.templateJson = sanitizeObject(JSON.parse(res.responseData.data), true)
this.templateJson = deepClone(JSON.parse(res.responseData.data), true)
if (typeof this.templateJson.template !== 'object' || typeof this.templateJson.vars !== 'object' || typeof this.templateJson.functions !== 'object') {
this.$bkMessage({
theme: 'error',
Expand Down
4 changes: 2 additions & 2 deletions lib/client/src/views/system/components/project-form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

<script>
import LayoutThumbList from '@/components/project/layout-thumb-list'
import { sanitizeObject } from 'shared/security/property-injection-guard'
import { deepClone } from 'shared/security/property-injection-guard'

const defaultFormData = {
projectCode: '',
Expand Down Expand Up @@ -178,7 +178,7 @@
},
handleUploadSuccess (res) {
const dataStr = res.responseData?.data
this.importProjectData = sanitizeObject(JSON.parse(dataStr), true)
this.importProjectData = deepClone(JSON.parse(dataStr), true)
if (typeof this.importProjectData?.route !== 'object' || typeof this.importProjectData?.func !== 'object' || typeof this.importProjectData?.page !== 'object') {
this.$bkMessage({
theme: 'error',
Expand Down
134 changes: 123 additions & 11 deletions lib/server/controller/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,16 +250,18 @@ export const exportComps = async (ctx) => {
const sourcePath = path.join(STATIC_URL, componentDirName)
const targetPath = path.join(STATIC_URL, `${componentDirName}.zip`)

await fse.ensureDir(sourcePath)
await compressing.zip.compressDir(sourcePath, targetPath, { ignoreBase: true })
.then(async () => {
ctx.attachment(targetPath)
await send(ctx, targetPath)
fse.remove(sourcePath)
fse.remove(targetPath)
}).catch((err) => {
console.log(err)
})
try {
await fse.ensureDir(sourcePath)
await compressing.zip.compressDir(sourcePath, targetPath, { ignoreBase: true })
ctx.attachment(targetPath)
await send(ctx, targetPath)
} catch (err) {
console.log(err)
} finally {
// 确保清理临时文件
await fse.remove(sourcePath).catch(() => {})
await fse.remove(targetPath).catch(() => {})
}
}

export const updatePageComp = async (ctx) => {
Expand Down Expand Up @@ -669,11 +671,86 @@ async function safeUnzip (zipFilePath, destDir) {
const fs = require('fs')
const fse = require('fs-extra')

// ZIP 炸弹防护配置
const MAX_EXTRACTED_SIZE = 50 * 1024 * 1024 // 50MB - 解压后文件总大小限制
const MAX_FILES_COUNT = 1000 // 文件数量限制
const MAX_DEPTH = 10 // 目录嵌套深度限制
const MAX_COMPRESSION_RATIO = 100 // 最大压缩比(解压后/压缩前)

await fse.ensureDir(destDir)

const directory = await unzipper.Open.file(zipFilePath)

// 第一阶段:预检查所有文件(ZIP 炸弹防护)
let totalUncompressedSize = 0
let totalCompressedSize = 0
let fileCount = 0

for (const file of directory.files) {
// 统计文件数量
fileCount++
if (fileCount > MAX_FILES_COUNT) {
throw new Error(`ZIP 包中文件数量过多,最大允许 ${MAX_FILES_COUNT} 个文件`)
}

// 累计解压后大小
const uncompressedSize = file.uncompressedSize || 0
totalUncompressedSize += uncompressedSize
if (totalUncompressedSize > MAX_EXTRACTED_SIZE) {
throw new Error(`解压后文件总大小超过限制,最大允许 ${MAX_EXTRACTED_SIZE / 1024 / 1024}MB`)
}

// 累计压缩大小
const compressedSize = file.compressedSize || 0
totalCompressedSize += compressedSize

// 检查目录嵌套深度
const pathDepth = file.path.split('/').filter(p => p).length
if (pathDepth > MAX_DEPTH) {
throw new Error(`文件路径嵌套过深(${pathDepth}层),最大允许 ${MAX_DEPTH} 层`)
}
}

// 检查整体压缩比(防止压缩比异常高的 ZIP 炸弹)
if (totalCompressedSize > 0) {
const compressionRatio = totalUncompressedSize / totalCompressedSize
if (compressionRatio > MAX_COMPRESSION_RATIO) {
throw new Error(`检测到异常压缩比(${compressionRatio.toFixed(1)}:1),可能是 ZIP 炸弹攻击`)
}
}

// 第二阶段:执行实际解压
for (const file of directory.files) {
const filePath = filterFilePath(file.path)
const rawPath = file.path

// 符号链接检测:禁止 ZIP 包中包含符号链接(防止符号链接攻击)
if (file.type === 'SymbolicLink') {
throw new Error('ZIP 包中不允许包含符号链接')
}

// URL 编码检测:防止 %2e%2e 等编码绕过路径遍历防护
// 1. 直接禁止包含 % 字符的路径
if (rawPath.includes('%')) {
throw new Error('ZIP 包中的文件路径不允许包含 URL 编码字符')
}

// 2. 尝试解码并检测是否存在编码后的路径遍历
try {
const decodedPath = decodeURIComponent(rawPath)
// 如果解码后与原路径不同,且包含路径遍历符号
if (decodedPath !== rawPath && decodedPath.includes('..')) {
throw new Error('检测到 URL 编码的路径遍历攻击')
}
} catch (e) {
// 如果 decodeURIComponent 抛出异常(如非法编码),认为是恶意输入
if (e.message && e.message.includes('URI malformed')) {
throw new Error('ZIP 包中包含非法的 URL 编码')
}
// 重新抛出其他异常(包括我们自己抛出的安全检测异常)
throw e
}

const filePath = filterFilePath(rawPath)
if (!filePath) {
// 文件名清理后为空,跳过或抛错
throw new Error('文件名非法,无法解压')
Expand All @@ -688,8 +765,26 @@ async function safeUnzip (zipFilePath, destDir) {
await fse.ensureDir(absolutePath)
} else {
await fse.ensureDir(path.dirname(absolutePath))

// 实时监控写入大小(额外的运行时检查)
let writtenSize = 0
const maxFileSize = MAX_EXTRACTED_SIZE // 单个文件最大不超过总限制

const readStream = file.stream()
const writeStream = fs.createWriteStream(absolutePath)

// 监控写入的数据量
readStream.on('data', (chunk) => {
writtenSize += chunk.length
if (writtenSize > maxFileSize) {
readStream.destroy()
writeStream.destroy()
// 删除部分写入的文件
fs.unlink(absolutePath, () => {})
throw new Error(`单个文件解压后大小超过限制: ${file.path}`)
}
})

await new Promise((resolve, reject) => {
readStream.pipe(writeStream)
.on('finish', resolve)
Expand All @@ -714,9 +809,26 @@ export const upload = async (ctx) => {
}
const uploadComponent = ctx.request.files.upload_file

// 1. 文件大小限制检查(最大 5MB)
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
if (uploadComponent.size > MAX_FILE_SIZE) {
throw new Error('文件大小超过限制,最大支持5MB')
}

// 2. MIME 类型检查
if (!['application/x-zip', 'application/zip', 'application/x-zip-compressed'].includes(uploadComponent.type)) {
throw new Error(global.i18n.t('自定义组件上传只支持zip包'))
}

// 3. 文件魔术数字检查(防止 MIME 类型伪造)
const fileBuffer = fs.readFileSync(uploadComponent.path)
const fileSignature = fileBuffer.toString('hex', 0, 4)

// ZIP 文件的魔术数字:504B0304 (PK..) 或 504B0506 (空ZIP) 或 504B0708 (跨卷ZIP)
const validZipSignatures = ['504b0304', '504b0506', '504b0708']
if (!validZipSignatures.includes(fileSignature.toLowerCase())) {
throw new Error('文件格式不正确,只支持ZIP格式')
}
let exitComponent = null
if (id) {
exitComponent = await ComponentModel.getOne({
Expand Down
20 changes: 6 additions & 14 deletions lib/server/controller/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,11 @@
*/

import path from 'path'
import fs from 'fs'
import FileType from 'file-type'
import md5 from 'md5'
import { JSDOM } from 'jsdom'
import DOMPurify from 'dompurify'
import { LCDataService, TABLE_FILE_NAME } from '../service/common/data-service'
import fileService from '../utils/file-service/index'
import { validateNoStrictFile } from '../../shared/security/file-protection'
import {
Controller,
Get,
Expand Down Expand Up @@ -86,7 +84,7 @@ export default class FileController {

const fileKey = md5(`${userInfo.username}${projectId}${filePath}${Date.now()}`)

const noStrictExts = ['doc', 'xls', 'ppt', 'msi', 'csv', 'svg']
const noStrictExts = ['doc', 'xls', 'ppt', 'csv', 'svg']

let ext = path.extname(file.name).slice(1)
let mime = file.type
Expand All @@ -97,7 +95,7 @@ export default class FileController {
mime = typeResult.mime
}

if (!ALLOW_UPLOAD_TYPE.includes(ext)) {
if (!ALLOW_UPLOAD_TYPE.includes(ext)) {
throw new Error('不允许上传该格式类型文件')
}

Expand All @@ -108,15 +106,9 @@ export default class FileController {
}
file.name = file.name?.replace(/(\||;|&|\\|\$|>|<|`|!|\s+)/gi, '')

if (mime === 'image/svg+xml') {
const svgString = fs.readFileSync(filePath, 'utf-8')

// 过滤xss
const window = new JSDOM('').window
const purify = DOMPurify(window)
const cleanSvg = purify.sanitize(svgString)

fs.writeFileSync(filePath, cleanSvg)
// 对 noStrictExts 中的文件类型进行安全验证
if (noStrictExts.includes(ext)) {
await validateNoStrictFile(filePath, ext, mime)
}

try {
Expand Down
45 changes: 44 additions & 1 deletion lib/server/controller/page-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,48 @@ export const importTemplate = async (ctx) => {
try {
const params = ctx.request.body
params.belongProjectId = ctx.request.headers['x-project-id']

// 验证数据结构
if (!params.template || typeof params.template !== 'object') {
ctx.throwError({
message: global.i18n.t('模板数据格式不正确')
})
}

// 验证必需字段
const { template, vars = [], functions = [] } = params
if (!template.templateName || !template.content || !template.categoryId) {
ctx.throwError({
message: global.i18n.t('模板缺少必需字段')
})
}

// 验证字段类型
if (!Array.isArray(vars) || !Array.isArray(functions)) {
ctx.throwError({
message: global.i18n.t('变量和函数必须是数组格式')
})
}

// 验证content是否为合法JSON
try {
const contentObj = typeof template.content === 'string' ? JSON.parse(template.content) : template.content
if (typeof contentObj !== 'object') {
throw new Error('Invalid content format')
}
} catch (e) {
ctx.throwError({
message: global.i18n.t('模板内容格式不正确')
})
}

// 限制数组大小,防止DoS
if (vars.length > 100 || functions.length > 100) {
ctx.throwError({
message: global.i18n.t('导入的变量或函数数量超过限制')
})
}

// 模板名称已存在
const record = await PageTemplateModel.getOne({
templateName: params?.template?.templateName,
Expand All @@ -273,7 +315,8 @@ export const importTemplate = async (ctx) => {
message: global.i18n.t('模板名称已存在')
})
}

// 重置一遍这几个字段值
Object.assign(params.template, { fromPageCode: '', isOffcial: 0, offcialType: '', parentId: 0 })
await handleImportTemplate(params)
ctx.send({
code: 0,
Expand Down
Loading