diff --git a/lib/client/src/api/cached-promise.js b/lib/client/src/api/cached-promise.js index b213a6517..5ee419768 100644 --- a/lib/client/src/api/cached-promise.js +++ b/lib/client/src/api/cached-promise.js @@ -9,6 +9,8 @@ * specific language governing permissions and limitations under the License. */ +import { isSafePropertyKey } from '../../../shared/security/property-injection-guard' + export default class CachedPromise { constructor () { this.cache = {} @@ -37,7 +39,12 @@ export default class CachedPromise { * @return {Promise} promise 对象 */ set (id, promise) { - Object.assign(this.cache, { [id]: promise }) + // 验证id是否安全,防止属性注入攻击 + if (!isSafePropertyKey(id)) { + console.warn(`[Security] Rejected unsafe cache key: ${id}`) + return + } + this.cache[id] = promise } /** diff --git a/lib/client/src/api/index.js b/lib/client/src/api/index.js index a8f59f738..a28faf300 100644 --- a/lib/client/src/api/index.js +++ b/lib/client/src/api/index.js @@ -29,6 +29,8 @@ axios.interceptors.response.use( // 接口请求成功 case 0: return data + case 200: + return data // 需要去权限中心申请权限 case 403: if (data.data.permissionType === 'page') { diff --git a/lib/client/src/api/pureAxios.js b/lib/client/src/api/pureAxios.js index b05defaf0..16aa45456 100644 --- a/lib/client/src/api/pureAxios.js +++ b/lib/client/src/api/pureAxios.js @@ -105,7 +105,7 @@ async function getPromise (method, url, data, userConfig = {}) { try { const response = await axiosRequest Object.assign(config, response.config || {}) - if (Object.prototype.hasOwnProperty.call(response, 'code') && response.code !== 0) { + if (Object.prototype.hasOwnProperty.call(response, 'code') && response.code !== 0 && response.code !== 200) { reject(response) } else { handleResponse({ config, response, resolve, reject }) diff --git a/lib/client/src/common/util.js b/lib/client/src/common/util.js index 3b62a71de..825db23bf 100644 --- a/lib/client/src/common/util.js +++ b/lib/client/src/common/util.js @@ -10,7 +10,7 @@ */ import { messageSuccess } from '@/common/bkmagic' -import DOMPurify from 'dompurify' +import { filterXss } from '@blueking/xss-filter' import Vue from 'vue' import LC from '@/element-materials/core' import store from '@/store' @@ -34,11 +34,15 @@ export function downloadFile (source, filename = 'lesscode.txt') { const downloadEl = document.createElement('a') const blob = new Blob([source]) downloadEl.download = filename - downloadEl.href = URL.createObjectURL(blob) + const url = URL.createObjectURL(blob) + downloadEl.href = url downloadEl.style.display = 'none' document.body.appendChild(downloadEl) downloadEl.click() document.body.removeChild(downloadEl) + setTimeout(() => { + URL.revokeObjectURL(url) + }, 100) } /** @@ -1013,5 +1017,5 @@ export const isJSON = (str) => { export const filterImgSrc = (src) => { src?.replaceAll('console', '')?.replaceAll('logout', '') - return DOMPurify.sanitize(src) + return filterXss(src) } diff --git a/lib/client/src/components/code-viewer/index.vue b/lib/client/src/components/code-viewer/index.vue index 75b473d64..8c589a690 100644 --- a/lib/client/src/components/code-viewer/index.vue +++ b/lib/client/src/components/code-viewer/index.vue @@ -46,6 +46,7 @@ import screenfull from 'screenfull' import monaco from '@/components/monaco' import { mapGetters } from 'vuex' + import { downloadFile } from '@/common/util' export default { components: { @@ -108,14 +109,7 @@ this.$emit('show-edit-data') }, handleDownloadFile () { - const downlondEl = document.createElement('a') - const blob = new Blob([this.code]) - downlondEl.download = this.filename - downlondEl.href = URL.createObjectURL(blob) - downlondEl.style.display = 'none' - document.body.appendChild(downlondEl) - downlondEl.click() - document.body.removeChild(downlondEl) + downloadFile(this.code, this.filename) }, handleScreenfull () { const el = document.querySelector(`.${this.$style['code-viewer']}`) diff --git a/lib/client/src/components/methods/forms/form-items/market.vue b/lib/client/src/components/methods/forms/form-items/market.vue index 83aab422d..1acb22dc7 100644 --- a/lib/client/src/components/methods/forms/form-items/market.vue +++ b/lib/client/src/components/methods/forms/form-items/market.vue @@ -31,6 +31,7 @@ import { transformTipsWidth } from '@/common/util' import mixins from './form-item-mixins' import { mapActions } from 'vuex' + import { filterXss } from '@blueking/xss-filter' export default { mixins: [mixins], @@ -59,7 +60,10 @@ ...tips } } - return tipObj + return { + ...tipObj, + content: filterXss(tipObj.content) + } }, getMarketFuncs (isExpand) { diff --git a/lib/client/src/components/patch/widget-bk-vision/index.vue b/lib/client/src/components/patch/widget-bk-vision/index.vue index 6662e8265..540792d90 100644 --- a/lib/client/src/components/patch/widget-bk-vision/index.vue +++ b/lib/client/src/components/patch/widget-bk-vision/index.vue @@ -19,41 +19,21 @@ }, waterMark: { type: String - }, - isFullScroll: { - type: Boolean, - default: true - }, - isShowTools: { - type: Boolean, - default: true - }, - isShowRefresh: { - type: Boolean, - default: true - }, - isShowTimeRange: { - type: Boolean, - default: true } }, data () { return { - // visionApp: {}, + visionApp: {}, renderId: '', apiPrefix: '/api/bkvision/', - cdnPrefix: 'https://staticfile.qq.com/bkvision/p8e3a7f52d95c45d795cb6f90955f2800/latest/' + cdnPrefix: 'https://staticfile.qq.com/bkvision/pbb9b207ba200407982a9bd3d3f2895d4/latest/' } }, computed: { dataInfo () { return { - apiPrefix: '/api/bkvision/' - // waterMark: { content: this.watchMark || 'bk-lesscode' }, - // isFullScroll: this.isFullScroll, - // isShowTools: this.isShowTools, - // isShowRefresh: this.isShowRefresh, - // isShowTimeRange: this.isShowTimeRange + apiPrefix: '/api/bkvision/', + waterMark: { content: this.watchMark || 'bk-lesscode' }, } } @@ -64,15 +44,6 @@ }, waterMark () { this.debounceRender() - }, - isShowTools () { - this.debounceRender() - }, - isShowRefresh () { - this.debounceRender() - }, - isShowTimeRange () { - this.debounceRender() } }, created () { @@ -91,11 +62,9 @@ methods: { async loadSdk () { const link = document.createElement('link') - link.href = 'https://staticfile.qq.com/bkvision/p8e3a7f52d95c45d795cb6f90955f2800/3c3de519287048dcb4c5a03d47ebf33f/main.css' + link.href = 'https://staticfile.qq.com/bkvision/pbb9b207ba200407982a9bd3d3f2895d4/3c3de519287048dcb4c5a03d47ebf33f/main.css' link.rel = 'stylesheet' document.body.append(link) - await this.loadScript('chunk-vendors.js') - await this.loadScript('chunk-bk-magic-vue.js') await this.loadScript('main.js') this.initPanel() }, @@ -112,8 +81,18 @@ }) }, async initPanel () { + console.log('init panel') if (window.BkVisionSDK) { - this.visionApp = this.uid && window.BkVisionSDK.init(`#dashboard-${this.renderId}`, this.uid, this.dataInfo) + try { + if (this.visionApp && Object.keys(this.visionApp).length) { + this.visionApp?.unmount() + } + } catch (error) { + console.error(error?.message || error, 'unmount bk-vision error') + } + + this.visionApp = this.uid && await window.BkVisionSDK.init(`#dashboard-${this.renderId}`, this.uid, this.dataInfo) + console.log(this.visionApp, 'after init') } else { console.error('sdk 加载异常') } diff --git a/lib/client/src/components/render/pc/render-component.js b/lib/client/src/components/render/pc/render-component.js index 473b1f8a5..5eea49a35 100644 --- a/lib/client/src/components/render/pc/render-component.js +++ b/lib/client/src/components/render/pc/render-component.js @@ -16,6 +16,7 @@ import WidgetFormItem from './widget/form/form-item' import WidgetMdEditor from './widget/md-editor/md-editor' import WidgetBkTable from './widget/table/table' import WidgetVanPicker from './widget/van-picker' +import widgetBkVision from './widget/bk-vision/bk-vision' import WidgetFormContainer from './widget/form-container' import WidgetDataManageContainer from './widget/data-manage-container/form-data-manage/edit/index' import WidgetFlowManageContainer from './widget/flow-manage-container/edit/index' @@ -51,6 +52,7 @@ export default { WidgetMdEditor, WidgetBkTable, WidgetVanPicker, + widgetBkVision, WidgetFormContainer, WidgetDataManageContainer, WidgetFlowManageContainer, diff --git a/lib/client/src/components/render/pc/widget/bk-vision/bk-vision.js b/lib/client/src/components/render/pc/widget/bk-vision/bk-vision.js new file mode 100644 index 000000000..c3a1810ba --- /dev/null +++ b/lib/client/src/components/render/pc/widget/bk-vision/bk-vision.js @@ -0,0 +1,138 @@ +/** + * Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community Edition) available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +import { h } from 'bk-lesscode-render' +import LC from '@/element-materials/core' +import { uuid, debounce } from 'shared/util' + +export default { + name: 'widget-bk-vision', + inheritAttrs: false, + props: { + componentData: { + type: Object + }, + uid: { + type: String + } + }, + data () { + return { + visionApp: {}, + renderId: '', + apiPrefix: '/api/bkvision/', + cdnPrefix: 'https://staticfile.qq.com/bkvision/pbb9b207ba200407982a9bd3d3f2895d4/latest/' + } + }, + computed: { + compUid () { + return this.uid || this.componentData?.prop?.uid + }, + dataInfo () { + return { + apiPrefix: '/api/bkvision/' + // waterMark: { content: this.componentData?.watchMark || 'bk-lesscode' } + } + } + }, + watch: { + compUid (val) { + console.log('compUid change', val) + this.debounceInit() + } + }, + created () { + this.renderId = uuid(6) + LC.addEventListener('update', this.updateCallback) + }, + mounted () { + this.debounceInit = debounce(this.initPanel) + if (!window.BkVisionSDK) { + console.log('load sdk') + this.loadSdk() + } else { + console.log('bkvision sdk exist') + this.initPanel() + } + }, + beforeDestroy () { + LC.removeEventListener('update', this.updateCallback) + }, + methods: { + updateCallback ({ target }) { + if (target.componentId === this.componentData?.componentId) { + this.$forceUpdate() + this.debounceInit() + } + }, + async loadSdk () { + const link = document.createElement('link') + link.href = 'https://staticfile.qq.com/bkvision/pbb9b207ba200407982a9bd3d3f2895d4/3c3de519287048dcb4c5a03d47ebf33f/main.css' + link.rel = 'stylesheet' + document.body.append(link) + await this.loadScript('main.js') + this.initPanel() + }, + loadScript (file) { + return new Promise((resolve, reject) => { + const url = this.cdnPrefix + file + const script = document.createElement('script') + script.src = url + document.body.append(script) + script.onload = () => { + resolve() + } + }) + }, + async initPanel () { + const compUid = this.compUid + console.log('init panel', compUid) + if (window.BkVisionSDK) { + try { + if (this.visionApp && Object.keys(this.visionApp).length) { + this.visionApp?.unmount() + } + } catch (error) { + console.error(error?.message || error, 'unmount bk-vision error') + } + + this.visionApp = compUid && await window.BkVisionSDK.init(`.dashboard-${this.renderId}`, compUid, this.dataInfo) + console.log(this.visionApp, 'after init', compUid) + } else { + console.error('sdk 加载异常') + } + } + }, + render (render) { + h.init(render) + + const self = this + + return h({ + component: 'div', + class: 'lesscode-bk-vision-container', + children: [ + !self.compUid ? h({ + component: 'bk-exception', + class: 'exception-wrap-item exception-part exception-gray', + props: { + type: '404', + scene: 'part' + } + }) + : h({ + component: 'div', + class: `dashboard-${self.renderId}` + }) + ] + }) + } +} diff --git a/lib/client/src/element-materials/core/extends/merge-render-events.js b/lib/client/src/element-materials/core/extends/merge-render-events.js index efd746517..6d8b7b175 100644 --- a/lib/client/src/element-materials/core/extends/merge-render-events.js +++ b/lib/client/src/element-materials/core/extends/merge-render-events.js @@ -1,4 +1,4 @@ -import _ from 'lodash' +import { safeObjectMerge } from '../../../../../shared/security/property-injection-guard' /** * @desc 设置节点的 renderEvents(增量添加) @@ -13,13 +13,8 @@ export default function (node, events) { if (!isObject(events)) { throw new Error(window.i18n.t('设置 mergeRenderEvents 值只支持 Object')) } - const customizer = (a, b) => { - if (isObject(a) && isObject(b)) { - return _.mergeWith(a, b, customizer) - } else { - return b - } - } - node.renderEvents = _.mergeWith(node.renderEvents, events, customizer) + + // 使用安全的对象合并方式,防止属性注入攻击 + node.renderEvents = safeObjectMerge(node.renderEvents || {}, events, { deep: true }) return true } diff --git a/lib/client/src/element-materials/core/extends/set-prop.js b/lib/client/src/element-materials/core/extends/set-prop.js index 9d9a7422c..bf97dad69 100644 --- a/lib/client/src/element-materials/core/extends/set-prop.js +++ b/lib/client/src/element-materials/core/extends/set-prop.js @@ -1,4 +1,6 @@ import _ from 'lodash' +import { safeForEachProperty, isSafePropertyKey } from '../../../../../shared/security/property-injection-guard' + /** * @desc 设置节点 renderProps 上指定 prop 的值 * @param { Node } node @@ -12,27 +14,33 @@ import _ from 'lodash' export default function (node, params1, params2) { let propData = {} if (Object.prototype.toString.call(params1) === '[object String]') { + // 验证属性名是否安全 + if (!isSafePropertyKey(params1)) { + console.warn(`[Security] Rejected unsafe property name: ${params1}`) + return false + } propData[params1] = params2 } else { propData = params1 } const materialProps = node.material.props - Object.keys(propData).forEach(propName => { + // 使用安全的方式遍历属性,防止原型链污染 + safeForEachProperty(propData, (propName, propValue) => { if (materialProps.hasOwnProperty(propName)) { const propConfigType = materialProps[propName].type - node.renderProps[propName] = _.cloneDeep(propData[propName]) + node.renderProps[propName] = _.cloneDeep(propValue) // 没传 valueType, - if (!propData[propName].valueType) { + if (!propValue.valueType) { // 从组件配置中自动推导出默认 valueType (如果是数组默认取第一个) const valueType = Array.isArray(propConfigType) ? propConfigType[0] : propConfigType node.renderProps[propName].valueType = valueType } - if (!propData[propName].payload) { + if (!propValue.payload) { node.renderProps[propName].payload = {} } } diff --git a/lib/client/src/element-materials/core/helper/merge-data-to-node.js b/lib/client/src/element-materials/core/helper/merge-data-to-node.js index 096423aa8..38a06c8dd 100644 --- a/lib/client/src/element-materials/core/helper/merge-data-to-node.js +++ b/lib/client/src/element-materials/core/helper/merge-data-to-node.js @@ -1,23 +1,29 @@ +import { safeObjectMerge, safeForEachProperty } from '../../../../../shared/security/property-injection-guard' + export default function (data, node) { if (data.renderStyles) { - node.renderStyles = data.renderStyles + // 安全地合并样式对象,防止属性注入攻击 + node.renderStyles = safeObjectMerge(node.renderStyles || {}, data.renderStyles, { deep: true }) } if (data.renderProps) { // prop 通过 merge 的方式加载 // 兼容组件 prop 扩展的场景 - Object.keys(data.renderProps).forEach(key => { - node.renderProps[key] = data.renderProps[key] + // 使用安全的方式遍历和设置属性,防止原型链污染 + safeForEachProperty(data.renderProps, (key, value) => { + node.renderProps[key] = value }) } if (data.renderDirectives) { node.renderDirectives = data.renderDirectives } if (data.renderEvents) { - node.renderEvents = data.renderEvents + // 安全地合并事件对象 + node.renderEvents = safeObjectMerge(node.renderEvents || {}, data.renderEvents, { deep: true }) } if (data.renderAlign) { - Object.keys(data.renderAlign).forEach(key => { - node.renderAlign[key] = data.renderAlign[key] + // 安全地合并对齐属性 + safeForEachProperty(data.renderAlign, (key, value) => { + node.renderAlign[key] = value }) } if (data.renderPerms) { diff --git a/lib/client/src/element-materials/materials/vue2/bk/bk-vision/index.js b/lib/client/src/element-materials/materials/vue2/bk/bk-vision/index.js index cb3e921b4..82151e3ac 100644 --- a/lib/client/src/element-materials/materials/vue2/bk/bk-vision/index.js +++ b/lib/client/src/element-materials/materials/vue2/bk/bk-vision/index.js @@ -13,7 +13,7 @@ export default { name: 'bkvision', type: 'widget-bk-vision', display: 'none', - displayName: '蓝鲸图表', + displayName: 'BKVision仪表盘', icon: 'bk-drag-histogram', group: 'ECharts', order: 1, diff --git a/lib/client/src/element-materials/materials/vue2/bk/paragraph/index.js b/lib/client/src/element-materials/materials/vue2/bk/paragraph/index.js index ab7082c1f..37f03b613 100644 --- a/lib/client/src/element-materials/materials/vue2/bk/paragraph/index.js +++ b/lib/client/src/element-materials/materials/vue2/bk/paragraph/index.js @@ -28,15 +28,15 @@ export default { verticalAlign: 'middle' }, directives: [ - { - type: 'v-html', - prop: 'slots', - format: 'variable', - valueTypeInclude: ['string'], - tips () { - return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' - } - } + // { + // type: 'v-html', + // prop: 'slots', + // format: 'variable', + // valueTypeInclude: ['string'], + // tips () { + // return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' + // } + // } ], groups: [ { label: 'hover时提示', value: 'tooltip' }, diff --git a/lib/client/src/element-materials/materials/vue2/vant/paragraph/index.js b/lib/client/src/element-materials/materials/vue2/vant/paragraph/index.js index 633c62bb7..f2c071a59 100644 --- a/lib/client/src/element-materials/materials/vue2/vant/paragraph/index.js +++ b/lib/client/src/element-materials/materials/vue2/vant/paragraph/index.js @@ -27,15 +27,15 @@ export default { wordBreak: 'break-all' }, directives: [ - { - type: 'v-html', - prop: 'slots', - format: 'variable', - valueTypeInclude: ['string'], - tips () { - return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' - } - } + // { + // type: 'v-html', + // prop: 'slots', + // format: 'variable', + // valueTypeInclude: ['string'], + // tips () { + // return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' + // } + // } ], groups: [ { label: 'hover时提示', value: 'tooltip' }, diff --git a/lib/client/src/element-materials/materials/vue3/bk/bk-vision/index.js b/lib/client/src/element-materials/materials/vue3/bk/bk-vision/index.js new file mode 100644 index 000000000..95ae77bc1 --- /dev/null +++ b/lib/client/src/element-materials/materials/vue3/bk/bk-vision/index.js @@ -0,0 +1,47 @@ +/** + * Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community Edition) available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +export default { + name: 'bkvision', + type: 'widget-bk-vision', + display: 'none', + displayName: 'BKVision仪表盘', + icon: 'bk-drag-histogram', + group: 'ECharts', + order: 1, + events: [], + styles: [ + { + name: 'size', + include: ['width', 'height'] + } + ], + renderStyles: { + width: '100%', + height: '100%', + minHeight: '100px' + }, + props: { + uid: { + type: 'request-select', + payload: { + url: '/bkvision/shareList', + key: 'uid', + value: 'name', + slotText: '新增仪表盘分享', + slotLink: process.env.BK_VISION_WEB_URL + '/#/space', + isGroup: true, + groupChildren: 'share' + }, + tips: '图表平台嵌入管理中的仪表盘分享id, 请先到图表平台配置,步骤:
1、选择想要分享的空间或新建空间, 进入空间后新建仪表盘
2、点击上方嵌入管理、新增嵌入
3、新增时选择想要分享的仪表盘、蓝鲸应用ID选择“当前应用”,并填入“visual-layout”(运维开发平台的应用ID)
4、下一步嵌入模式选择“JS-SDK嵌入”, 提交后返回列表页开启发布分享
5、回到LessCode平台选择所需的分享' + } + } +} diff --git a/lib/client/src/element-materials/materials/vue3/bk/index.js b/lib/client/src/element-materials/materials/vue3/bk/index.js index a25d4fbde..7c4798ef2 100644 --- a/lib/client/src/element-materials/materials/vue3/bk/index.js +++ b/lib/client/src/element-materials/materials/vue3/bk/index.js @@ -72,6 +72,7 @@ import chartsPie from './charts-pie' import echartsLine from './echarts-line' import echartsBar from './echarts-bar' import echartsPie from './echarts-pie' +import bkVision from './bk-vision' import bkChartsLine from './bk-charts-line' import bkChartsBar from './bk-charts-bar' import bkChartsPie from './bk-charts-pie' @@ -144,6 +145,7 @@ const bkComponents = Object.seal([ echartsLine, echartsBar, echartsPie, + bkVision, bkChartsLine, bkChartsBar, bkChartsPie, diff --git a/lib/client/src/element-materials/materials/vue3/bk/paragraph/index.js b/lib/client/src/element-materials/materials/vue3/bk/paragraph/index.js index 372997c6b..369be1811 100644 --- a/lib/client/src/element-materials/materials/vue3/bk/paragraph/index.js +++ b/lib/client/src/element-materials/materials/vue3/bk/paragraph/index.js @@ -28,15 +28,15 @@ export default { verticalAlign: 'middle' }, directives: [ - { - type: 'v-html', - prop: 'slots', - format: 'variable', - valueTypeInclude: ['string'], - tips () { - return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' - } - } + // { + // type: 'v-html', + // prop: 'slots', + // format: 'variable', + // valueTypeInclude: ['string'], + // tips () { + // return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' + // } + // } ], groups: [ { label: 'hover时提示', value: 'tooltip' }, diff --git a/lib/client/src/element-materials/materials/vue3/vant/paragraph/index.js b/lib/client/src/element-materials/materials/vue3/vant/paragraph/index.js index 50e0dd6c8..1677ace7a 100644 --- a/lib/client/src/element-materials/materials/vue3/vant/paragraph/index.js +++ b/lib/client/src/element-materials/materials/vue3/vant/paragraph/index.js @@ -27,15 +27,15 @@ export default { wordBreak: 'break-all' }, directives: [ - { - type: 'v-html', - prop: 'slots', - format: 'variable', - valueTypeInclude: ['string'], - tips () { - return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' - } - } + // { + // type: 'v-html', + // prop: 'slots', + // format: 'variable', + // valueTypeInclude: ['string'], + // tips () { + // return '内容直接作为普通 HTML 插入,Vue 模板语法不会被解析' + // } + // } ], groups: [ { label: 'hover时提示', value: 'tooltip' }, diff --git a/lib/client/src/element-materials/modifier/component/props/components/render-prop.vue b/lib/client/src/element-materials/modifier/component/props/components/render-prop.vue index fde9fccd0..f7990018c 100644 --- a/lib/client/src/element-materials/modifier/component/props/components/render-prop.vue +++ b/lib/client/src/element-materials/modifier/component/props/components/render-prop.vue @@ -23,7 +23,7 @@ + v-bk-xss-html="displayName"> @@ -110,7 +110,7 @@ import _ from 'lodash' import { mapActions } from 'vuex' import LC from '@/element-materials/core' - import DOMPurify from 'dompurify' + import { filterXss } from '@blueking/xss-filter' import { camelCase, camelCaseTransformMerge } from 'change-case' import { transformTipsWidth } from '@/common/util' import safeStringify from '@/common/json-safe-stringify' @@ -375,7 +375,7 @@ name = `${this.describe.displayName}(${this.name})` } } - return DOMPurify.sanitize(name) + return name }, /** * @desc 不支持的变量切换类型(variable、expression) @@ -392,7 +392,7 @@ return { placements: ['left-start'], maxWidth: 300, - content: DOMPurify.sanitize(this.tipsContent) + content: filterXss(this.tipsContent) } }, tipsContent () { diff --git a/lib/client/src/element-materials/modifier/component/styles/strategy/custom-style.vue b/lib/client/src/element-materials/modifier/component/styles/strategy/custom-style.vue index 91ce13549..74dd2f7a1 100644 --- a/lib/client/src/element-materials/modifier/component/styles/strategy/custom-style.vue +++ b/lib/client/src/element-materials/modifier/component/styles/strategy/custom-style.vue @@ -67,6 +67,7 @@ import StyleItem from '../layout/item' import Monaco from '@/components/monaco' import { camelCase } from 'change-case' + import { isSafePropertyKey } from '../../../../../../../shared/security/property-injection-guard' export default { components: { @@ -143,7 +144,11 @@ if (item) { const itemArr = item.split(':') if (itemArr.length === 2 && itemArr[0].trim() && itemArr[1].trim()) { - Object.assign(customMap, { [itemArr[0].trim()]: itemArr[1].trim() }) + const cssProperty = itemArr[0].trim() + // 验证CSS属性名是否安全,防止属性注入攻击 + if (isSafePropertyKey(cssProperty)) { + customMap[cssProperty] = itemArr[1].trim() + } } } }) diff --git a/lib/client/src/locales/lang/en.json b/lib/client/src/locales/lang/en.json index f59a6c2d0..1c231e65f 100644 --- a/lib/client/src/locales/lang/en.json +++ b/lib/client/src/locales/lang/en.json @@ -2493,7 +2493,7 @@ "边框宽度": "Border Width", "边框颜色": "Border Color", "饼图": "Pie Chart", - "蓝鲸图表": "BKVision", + "BKVision仪表盘": "BKVision", "鼠标Hover时,图块偏移的距离": "When the mouse hovers, the distance of the block offset", "数据点Hover时的背景色": "The background color of data point hover", "数据点的边框颜色": "The border color of the data point", diff --git a/lib/client/src/locales/lang/zh-cn.json b/lib/client/src/locales/lang/zh-cn.json index e34506239..3fed75259 100644 --- a/lib/client/src/locales/lang/zh-cn.json +++ b/lib/client/src/locales/lang/zh-cn.json @@ -2404,7 +2404,7 @@ "鼠标Hover时,图块偏移的距离": "鼠标Hover时,图块偏移的距离", "边框宽度": "边框宽度", "边框颜色": "边框颜色", - "蓝鲸图表": "蓝鲸图表", + "BKVision仪表盘": "BKVision仪表盘", "流程": "流程", "饼图": "饼图", "数据点Hover时的背景色": "数据点Hover时的背景色", diff --git a/lib/client/src/main.js b/lib/client/src/main.js index 8e16f7434..a65cdae1d 100644 --- a/lib/client/src/main.js +++ b/lib/client/src/main.js @@ -44,6 +44,7 @@ import pureAxios from '@/api/pureAxios.js' import MgContentLoader from '@/components/loader' import './directives' +import { BkXssFilterDirective } from '@blueking/xss-filter' import { IAM_ACTION } from 'shared/constant' import './bk-icon/style.css' @@ -55,6 +56,7 @@ Vue.prototype.$td = targetData Vue.prototype.$IAM_ACTION = IAM_ACTION Vue.use(mavonEditor) +Vue.use(BkXssFilterDirective) Vue.component('VueDraggable', VueDraggable) Vue.component('app-exception', Exception) diff --git a/lib/client/src/preview/component.js b/lib/client/src/preview/component.js index b97e1a704..69828b2c8 100644 --- a/lib/client/src/preview/component.js +++ b/lib/client/src/preview/component.js @@ -23,6 +23,7 @@ import widgetTableColumn from '@/components/render/pc/widget/table/table-column' import widgetVanPicker from '@/components/render/pc/widget/van-picker' import bkCharts from '@/components/render/pc/widget/bk-charts/bk-charts' import chart from '@/components/render/pc/widget/chart/chart' +import widgetBkVision from '@/components/render/pc/widget/bk-vision/bk-vision' import WidgetFormContainer from '@/form-engine/renderer/index' import WidgetDataManageContainer from '@/components/render/pc/widget/data-manage-container/form-data-manage/preview' import WidgetFlowManageContainer from '@/components/render/pc/widget/flow-manage-container/preview' @@ -83,6 +84,7 @@ const registerSysComponents = () => { registerComponent('chart', chart) // 新echarts配置 registerComponent('echarts', chart) + registerComponent('widget-bk-vision', widgetBkVision) registerComponent('widget-van-picker', widgetVanPicker) registerComponent('widget-form-container', WidgetFormContainer) registerComponent('widget-data-manage-container', WidgetDataManageContainer) diff --git a/lib/client/src/preview/overlay.js b/lib/client/src/preview/overlay.js index d0ce42b34..ddf019135 100644 --- a/lib/client/src/preview/overlay.js +++ b/lib/client/src/preview/overlay.js @@ -11,14 +11,14 @@ import Vue from 'vue' import Img403 from '@/images/403.png' import ApplyPage from '@/components/apply-permission/apply-page.vue' -import DOMPurify from 'dompurify' +import { filterXss } from '@blueking/xss-filter' export const handleError = (err = {}) => { console.error(err) const response = err.response || {} const data = response.data || {} let message = data.message || err.message || window.i18n.t('无法连接到后端服务,请稍候再试。') - message = DOMPurify.sanitize(message) + message = filterXss(message) const divStyle = ` text-align: center; width: 400px; diff --git a/lib/client/src/views/project/function-manage/children/list.vue b/lib/client/src/views/project/function-manage/children/list.vue index 6b0fb9cb3..37534b1ac 100644 --- a/lib/client/src/views/project/function-manage/children/list.vue +++ b/lib/client/src/views/project/function-manage/children/list.vue @@ -168,7 +168,7 @@ computedFunctionList () { const searchReg = new RegExp(this.searchFunStr, 'i') - return this.functionList.filter((func) => searchReg.test(func.funcName)) + return this.functionList.filter((func) => searchReg.test(func.funcName) || searchReg.test(func.funcCode)) }, emptyType () { if (this.searchFunStr.length > 0) { diff --git a/lib/client/src/views/project/page-manage/children/page-menu-item.vue b/lib/client/src/views/project/page-manage/children/page-menu-item.vue index b6cc9a7a5..9c5129546 100644 --- a/lib/client/src/views/project/page-manage/children/page-menu-item.vue +++ b/lib/client/src/views/project/page-manage/children/page-menu-item.vue @@ -30,6 +30,7 @@ - diff --git a/lib/server/project-template/vue3/project-init-code/lib/server/controller/bkvision.js b/lib/server/project-template/vue3/project-init-code/lib/server/controller/bkvision.js index 53a420f9d..a67aeefbb 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/server/controller/bkvision.js +++ b/lib/server/project-template/vue3/project-init-code/lib/server/controller/bkvision.js @@ -42,7 +42,7 @@ export default class bkVisionController { ctx.body = res.data } - @All('/api/v1/(variable|datasource)/*') + @All('/api/v1/(variable|datasource|dataset)/*') async proxyPostApi ( @Ctx() ctx, @Ctx({ name: 'captures' }) captures, diff --git a/lib/server/project-template/vue3/project-init-code/lib/server/controller/mock-data.js b/lib/server/project-template/vue3/project-init-code/lib/server/controller/mock-data.js index be682c291..c60c5bfb9 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/server/controller/mock-data.js +++ b/lib/server/project-template/vue3/project-init-code/lib/server/controller/mock-data.js @@ -6,6 +6,7 @@ import { Ctx, OutputJson } from '../decorator' +import { validateUrl } from '../util/validate-url' const METHODS_WITH_DATA = ['post', 'put', 'patch'] @@ -15,7 +16,6 @@ export default class DataController { @Post('/getApiData') async getApiData (@Ctx() ctx) { const { - url, type = 'get', withToken = 0, apiData = {}, @@ -29,14 +29,32 @@ export default class DataController { hostUrl = ctx.origin + process.env.BKPAAS_SUB_PATH } + let url = ctx.request.body?.url + url = url?.replaceAll('@', '') + // /开头的是相对路径 + if (url.startsWith('/')) { + url = hostUrl + url + } + + const checkUrl = await validateUrl(url, ctx.origin, process.env.BK_API_DOMAIN_WHITE_LIST) + + if (checkUrl?.result !== true) { + ctx.throwError({ + message: checkUrl?.message || '请求异常' + }) + } + // 发送请求的参数 const httpConf = { ...axiosConfig, - url: /^http(s)?\:\/\//.test(url) ? url : hostUrl + url, + url, method: type, headers: { ...axiosConfig?.headers - } + }, + // 不允许重定向 + maxRedirects: 0, + timeout: 5000 } // 请求携带的数据 diff --git a/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js b/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js index b4454e432..c2f2a1aa3 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js +++ b/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js @@ -241,7 +241,7 @@ export function getDataService (name = 'default', customEntityMap) { */ count (tableFileName, query = { deleteFlag: 0 }) { const repository = getRepositoryByName(tableFileName) - return repository.count(transformQuery(query)) + return repository.count({ where: transformQuery(query) }) }, /** * 获取数据详情 @@ -251,7 +251,7 @@ export function getDataService (name = 'default', customEntityMap) { */ findOne (tableFileName, query = {}) { const repository = getRepositoryByName(tableFileName) - return repository.findOne(transformQuery(query)) || {} + return repository.findOne({ where: transformQuery(query) }) || {} }, /** @@ -279,7 +279,7 @@ export function getDataService (name = 'default', customEntityMap) { * @returns 新增结果 */ async bulkAdd (tableFileName, dataList) { - dataList.forEach(item =>{ + dataList.forEach(item => { item.id = null }) const repository = getRepositoryByName(tableFileName) @@ -303,7 +303,7 @@ export function getDataService (name = 'default', customEntityMap) { const repository = getRepositoryByName(tableFileName) try { const { id, ...rest } = data - const { affected = 0 } = await repository.update(transformQuery(where), transformData(rest, repository.metadata.columns)) + const { affected = 0 } = await repository.update({ where: transformQuery(where) }, transformData(rest, repository.metadata.columns)) return affected } catch (error) { throw new Error(error.sqlMessage || error) @@ -327,7 +327,7 @@ export function getDataService (name = 'default', customEntityMap) { Object.assign(editData, data) try { - const { affected = 0 } = await repository.update(data.id, transformData(editData, repository.metadata.columns)) + const { affected = 0 } = await repository.update({ where: { id: data.id } }, transformData(editData, repository.metadata.columns)) return affected } catch (error) { throw new Error(error.sqlMessage || error) @@ -356,7 +356,7 @@ export function getDataService (name = 'default', customEntityMap) { try { const transformList = transformData(editDataList, repository.metadata.columns) - const updateResult = await Promise.all(transformList.map((data) => repository.update(data.id, data))) + const updateResult = await Promise.all(transformList.map((data) => repository.update({ where: { id: data.id } }, data))) return updateResult.reduce((acc, { affected }) => { acc += affected return acc @@ -384,7 +384,9 @@ export function getDataService (name = 'default', customEntityMap) { throw new Error(global.i18n.t('删除 {{n}} 表数据的时候,需要使用 unique 或者 id 字段保证删除数据唯一', { n: tableFileName })) } try { - const { affected = 0 } = await repository.delete(data) + // TypeORM 0.3+ 需要将条件放在 { where: ... } 中 + const deleteCondition = typeof data === 'object' ? { where: data } : { where: { id: data } } + const { affected = 0 } = await repository.delete(deleteCondition) return affected } catch (error) { throw new Error(error.sqlMessage || error) @@ -401,7 +403,7 @@ export function getDataService (name = 'default', customEntityMap) { if ([undefined, null].includes(ids)) throw new Error(`批量删除 ${tableFileName} 表数据的时候,需要传入 ids 参数`) const repository = getRepositoryByName(tableFileName) try { - const { affected = 0 } = await repository.delete(ids) + const { affected = 0 } = await repository.delete({ where: { id: In(ids) } }) return affected } catch (error) { throw new Error(error.sqlMessage || error) diff --git a/lib/server/project-template/vue3/project-init-code/lib/server/util/validate-url.js b/lib/server/project-template/vue3/project-init-code/lib/server/util/validate-url.js new file mode 100644 index 000000000..9ba917b94 --- /dev/null +++ b/lib/server/project-template/vue3/project-init-code/lib/server/util/validate-url.js @@ -0,0 +1,124 @@ +const dns = require('dns').promises +const ipaddr = require('ipaddr.js') + +function isPrivateIP (ip) { + if (ip === '127.0.0.1' && process.env.NODE_ENV === 'development') { + return false + } + let addr + try { + addr = ipaddr.parse(ip) + } catch (e) { + return true + } + + if (addr.kind() === 'ipv4') { + const range = addr.range() + return ( + range === 'private' + || range === 'loopback' + || range === 'linkLocal' + || range === 'broadcast' + || range === 'carrierGradeNat' + || range === 'unspecified' + ) + } else if (addr.kind() === 'ipv6') { + return ( + addr.isLoopback() + || addr.range() === 'uniqueLocal' + || addr.range() === 'linkLocal' + ) + } + return false +} + +function checkPort (port) { + port = parseInt(port) + // 禁止端口 + const forbiddenPort = [21, 22, 23, 25, 69, 135, 137, 138, 139, + 161, 162, 389, 465, 514, 587, 636, 873, 1099, 2181, 2375, + 2376, 27017, 3306, 3389, 36000, 4848, 50070, 50075, 5432, + 56000, 5900, 5901, 6379, 7001, 7002, 9200, 9300, 10050, 10051, + 10250, 10255, 11211] + return forbiddenPort.includes(port) +} + +// 获取当前环境的主域名 +function getMainDomain (origin) { + try { + const url = new URL(origin) + const hostnameParts = url?.hostname?.split('.') + const partsLength = hostnameParts?.length + + // 如果域名部分少于2个,返回null + if (partsLength < 2) { + return null + } + + // 提取主域名 + const mainDomain = hostnameParts.slice(partsLength - 2).join('.') + return `.${mainDomain}` + } catch (error) { + console.error('Invalid URL:', error) + return null + } +} + +export const validateUrl = async (userInputUrl, originalUrl, whiteList = []) => { + let parsedUrl = '' + try { + parsedUrl = new URL(userInputUrl) + } catch (err) { + return { + result: false, + message: 'url异常' + } + } + + // 只允许 http 和 https 协议 + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return { + result: false, + message: '只允许http跟https协议' + } + } + + // 域名必须在白名单 + const domain = getMainDomain(originalUrl) + if (!domain) { + return { + result: false, + message: '无法解析需要访问的域名' + } + } + const targetDomain = getMainDomain(userInputUrl) + if (!parsedUrl.hostname?.endsWith(domain) && !whiteList?.includes(targetDomain)) { + return { + result: false, + message: '只允许访问跟当前环境同域的域名' + } + } + + const port = parsedUrl.port + if (port && checkPort(port)) { + return { + result: false, + message: '禁止使用此端口' + } + } + // 解析域名对应的 IP 地址,防止 DNS 解析绕过 + const addresses = await dns.lookup(parsedUrl.hostname, { all: true }) + for (const addr of addresses) { + if (isPrivateIP(addr.address)) { + return { + result: false, + message: '禁止访问私有ip' + } + } + } + + return { + result: true, + message: '' + } +} diff --git a/lib/server/project-template/vue3/project-init-code/package.json b/lib/server/project-template/vue3/project-init-code/package.json index cd994d7ea..82a726eb7 100644 --- a/lib/server/project-template/vue3/project-init-code/package.json +++ b/lib/server/project-template/vue3/project-init-code/package.json @@ -108,7 +108,7 @@ "acorn": "~7.2.0", "ansi_up": "^5.0.0", "app-root-path": "~3.0.0", - "axios": "~1.6.0", + "axios": "~1.12.0", "babel-eslint": "~10.0.3", "babel-plugin-parameter-decorator": "^1.0.16", "better-npm-run": "~0.1.1", @@ -154,7 +154,7 @@ "node-request-context": "~1.0.5", "nodemon": "~1.19.3", "ora": "~4.0.2", - "path-to-regexp": "^6.1.0", + "path-to-regexp": "^8.3.0", "postcss": "~8.4.31", "postcss-import": "^15.0.0", "postcss-mixins": "^9.0.4", @@ -169,7 +169,7 @@ "shx": "~0.3.2", "swig": "~1.4.2", "mavon-editor": "^2.9.0", - "typeorm": "^0.2.25", + "typeorm": "~0.3.27", "typescript": "^4.4.3", "vue": "~3.2.41", "vue-echarts": "^5.0.0-beta.0", diff --git a/lib/server/router/component.js b/lib/server/router/component.js index 53b1963dc..91e2356f8 100644 --- a/lib/server/router/component.js +++ b/lib/server/router/component.js @@ -36,13 +36,13 @@ const router = new Router({ }) // 只校验projectId -router.use(['/list', '/export', '/upload'], async (ctx, next) => { +router.use(['/list', '/export', '/upload', '/useing'], async (ctx, next) => { const projectId = ctx.query.belongProjectId await handleProjectPerm(ctx, next, projectId) }) // 只校验projectId -router.use(['/using', '/create', '/version-detail'], async (ctx, next) => { +router.use(['/create', '/version-detail'], async (ctx, next) => { await handleProjectPerm(ctx, next) }) diff --git a/lib/server/router/open-api.js b/lib/server/router/open-api.js index c2ef90541..7d07cd844 100644 --- a/lib/server/router/open-api.js +++ b/lib/server/router/open-api.js @@ -57,7 +57,7 @@ router.use('*', async (ctx, next) => { app: {}, user } - const userData = await getRepository(User).findOne({ username: user.username }) + const userData = await getRepository(User).findOne({ where: { username: user.username } }) let userId = userData?.id if (!userId) { userId = await addUser({ diff --git a/lib/server/router/page.js b/lib/server/router/page.js index 8aa79f35a..96ca1b5ea 100644 --- a/lib/server/router/page.js +++ b/lib/server/router/page.js @@ -58,7 +58,7 @@ router.use(['/create'], async (ctx, next) => { } }) -const hasPageIdRoutes = ['/update', '/copy', '/delete', '/detail', '/updatePageActive', '/occupyPage'] +const hasPageIdRoutes = ['/update', '/copy', '/delete', '/detail', '/updatePageActive', '/occupyPage', '/releasePage'] router.use(hasPageIdRoutes, async (ctx, next) => { const tableName = 'PROJECT_PAGE' const resourceKey = 'pageId' diff --git a/lib/server/service/business/open-api.js b/lib/server/service/business/open-api.js index 676467b24..355d05bb5 100644 --- a/lib/server/service/business/open-api.js +++ b/lib/server/service/business/open-api.js @@ -102,7 +102,7 @@ export const generateApiGateway = async () => { if (transformVersionToNum(version) < transformVersionToNum(openApiJson.info.version)) { try { const formData = new FormData() - formData.append('file', fs.createReadStream(path.resolve(__dirname, './system-conf/apigw-docs.zip'))) + formData.append('file', fs.createReadStream(path.resolve(__dirname, '../../system-conf/apigw-docs.zip'))) // 更新资源文档 await execApiGateWay({ apiName: 'bk-apigateway', diff --git a/lib/server/service/business/project-version.js b/lib/server/service/business/project-version.js index fd5e9b7cc..a993c3861 100644 --- a/lib/server/service/business/project-version.js +++ b/lib/server/service/business/project-version.js @@ -81,7 +81,7 @@ const create = async (data) => { } const update = async (id, data) => { - return getRepository(ProjectVersion).update(id, data) + return getRepository(ProjectVersion).update({ id }, data) } const getList = async (projectId, fields = []) => { diff --git a/lib/server/service/common/data-service.js b/lib/server/service/common/data-service.js index b49687436..03e8d10ab 100644 --- a/lib/server/service/common/data-service.js +++ b/lib/server/service/common/data-service.js @@ -253,7 +253,7 @@ export function getDataService (name = 'default', customEntityMap) { */ async has (tableFileName, query = { deleteFlag: 0 }) { const repository = getRepositoryByName(tableFileName) - const count = await repository.count(transformQuery(query)) + const count = await repository.count({ where: transformQuery(query) }) return count > 0 }, @@ -265,7 +265,7 @@ export function getDataService (name = 'default', customEntityMap) { */ count (tableFileName, query = { deleteFlag: 0 }) { const repository = getRepositoryByName(tableFileName) - return repository.count(transformQuery(query)) + return repository.count({ where: transformQuery(query) }) }, /** @@ -314,7 +314,7 @@ export function getDataService (name = 'default', customEntityMap) { */ findOne (tableFileName, query = { deleteFlag: 0 }) { const repository = getRepositoryByName(tableFileName) - return repository.findOne(transformQuery(query)) || {} + return repository.findOne({ where: transformQuery(query) }) || {} }, /** * 添加 @@ -341,7 +341,7 @@ export function getDataService (name = 'default', customEntityMap) { * @returns 新增结果 */ async bulkAdd (tableFileName, dataList) { - dataList.forEach(item =>{ + dataList.forEach(item => { item.id = null }) const repository = getRepositoryByName(tableFileName) @@ -365,7 +365,7 @@ export function getDataService (name = 'default', customEntityMap) { const repository = getRepositoryByName(tableFileName) try { const { id, ...rest } = data - const { affected = 0 } = await repository.update(transformQuery(where), transformData(rest, repository.metadata.columns)) + const { affected = 0 } = await repository.update({ where: transformQuery(where) }, transformData(rest, repository.metadata.columns)) return affected } catch (error) { throw new Error(error.sqlMessage || error) @@ -392,7 +392,7 @@ export function getDataService (name = 'default', customEntityMap) { } } try { - const { affected = 0 } = await repository.update(data.id, transformData(editData, repository.metadata.columns)) + const { affected = 0 } = await repository.update({ where: { id: data.id } }, transformData(editData, repository.metadata.columns)) return affected } catch (error) { throw new Error(error.sqlMessage || error) @@ -425,7 +425,7 @@ export function getDataService (name = 'default', customEntityMap) { try { const transformList = transformData(editDataList, repository.metadata.columns) - const updateResult = await Promise.all(transformList.map((data) => repository.update(data.id, data))) + const updateResult = await Promise.all(transformList.map((data) => repository.update({ where: { id: data.id } }, data))) return updateResult.reduce((acc, { affected }) => { acc += affected return acc @@ -453,7 +453,9 @@ export function getDataService (name = 'default', customEntityMap) { throw new Error(global.i18n.t('删除 {{n}} 表数据的时候,需要使用 unique 或者 id 字段保证删除数据唯一', { n: tableFileName })) } try { - const { affected = 0 } = await repository.delete(data) + // TypeORM 0.3+ 需要将条件放在 { where: ... } 中 + const deleteCondition = typeof data === 'object' ? { where: data } : { where: { id: data } } + const { affected = 0 } = await repository.delete(deleteCondition) return affected } catch (error) { throw new Error(error.sqlMessage || error) @@ -470,7 +472,16 @@ export function getDataService (name = 'default', customEntityMap) { if (isEmpty(datas)) throw new Error(global.i18n.t('删除 {{n}} 表数据的时候,不能传入空数据', { n: tableFileName })) const repository = getRepositoryByName(tableFileName) try { - const { affected = 0 } = await repository.delete(datas) + // TypeORM 0.3+ 需要将条件放在 { where: ... } 中 + // 如果 datas 是数组,需要判断是 ID 数组还是对象数组 + const deleteCondition = Array.isArray(datas) && typeof datas[0] !== 'object' + ? { where: { id: In(datas) } } + : Array.isArray(datas) && typeof datas[0] === 'object' + ? { where: datas[0] } // 对象数组,取第一个对象作为条件(通常批量删除使用相同条件) + : typeof datas === 'object' + ? { where: datas } + : { where: { id: datas } } + const { affected = 0 } = await repository.delete(deleteCondition) return affected } catch (error) { throw new Error(error.sqlMessage || error) @@ -568,6 +579,7 @@ export const TABLE_FILE_NAME = { FLOW_TASK: 'flow-task', API: 'api', API_CATEGORY: 'api-category', + API_DOMAINS: 'api-domains', FILE: 'file', FUNC_API: 'func-api', QUERY_RECORD: 'query-record', diff --git a/lib/server/utils/validate-url.js b/lib/server/utils/validate-url.js new file mode 100644 index 000000000..9ba917b94 --- /dev/null +++ b/lib/server/utils/validate-url.js @@ -0,0 +1,124 @@ +const dns = require('dns').promises +const ipaddr = require('ipaddr.js') + +function isPrivateIP (ip) { + if (ip === '127.0.0.1' && process.env.NODE_ENV === 'development') { + return false + } + let addr + try { + addr = ipaddr.parse(ip) + } catch (e) { + return true + } + + if (addr.kind() === 'ipv4') { + const range = addr.range() + return ( + range === 'private' + || range === 'loopback' + || range === 'linkLocal' + || range === 'broadcast' + || range === 'carrierGradeNat' + || range === 'unspecified' + ) + } else if (addr.kind() === 'ipv6') { + return ( + addr.isLoopback() + || addr.range() === 'uniqueLocal' + || addr.range() === 'linkLocal' + ) + } + return false +} + +function checkPort (port) { + port = parseInt(port) + // 禁止端口 + const forbiddenPort = [21, 22, 23, 25, 69, 135, 137, 138, 139, + 161, 162, 389, 465, 514, 587, 636, 873, 1099, 2181, 2375, + 2376, 27017, 3306, 3389, 36000, 4848, 50070, 50075, 5432, + 56000, 5900, 5901, 6379, 7001, 7002, 9200, 9300, 10050, 10051, + 10250, 10255, 11211] + return forbiddenPort.includes(port) +} + +// 获取当前环境的主域名 +function getMainDomain (origin) { + try { + const url = new URL(origin) + const hostnameParts = url?.hostname?.split('.') + const partsLength = hostnameParts?.length + + // 如果域名部分少于2个,返回null + if (partsLength < 2) { + return null + } + + // 提取主域名 + const mainDomain = hostnameParts.slice(partsLength - 2).join('.') + return `.${mainDomain}` + } catch (error) { + console.error('Invalid URL:', error) + return null + } +} + +export const validateUrl = async (userInputUrl, originalUrl, whiteList = []) => { + let parsedUrl = '' + try { + parsedUrl = new URL(userInputUrl) + } catch (err) { + return { + result: false, + message: 'url异常' + } + } + + // 只允许 http 和 https 协议 + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return { + result: false, + message: '只允许http跟https协议' + } + } + + // 域名必须在白名单 + const domain = getMainDomain(originalUrl) + if (!domain) { + return { + result: false, + message: '无法解析需要访问的域名' + } + } + const targetDomain = getMainDomain(userInputUrl) + if (!parsedUrl.hostname?.endsWith(domain) && !whiteList?.includes(targetDomain)) { + return { + result: false, + message: '只允许访问跟当前环境同域的域名' + } + } + + const port = parsedUrl.port + if (port && checkPort(port)) { + return { + result: false, + message: '禁止使用此端口' + } + } + // 解析域名对应的 IP 地址,防止 DNS 解析绕过 + const addresses = await dns.lookup(parsedUrl.hostname, { all: true }) + for (const addr of addresses) { + if (isPrivateIP(addr.address)) { + return { + result: false, + message: '禁止访问私有ip' + } + } + } + + return { + result: true, + message: '' + } +} diff --git a/lib/shared/constant.js b/lib/shared/constant.js index 6fc7c7d9c..dc73a548e 100644 --- a/lib/shared/constant.js +++ b/lib/shared/constant.js @@ -9,7 +9,6 @@ * specific language governing permissions and limitations under the License. */ -export const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'] /** * 数据类型 */ diff --git a/lib/shared/page-code/common/modelMethods.js b/lib/shared/page-code/common/modelMethods.js index 80b378e61..86cc794e0 100644 --- a/lib/shared/page-code/common/modelMethods.js +++ b/lib/shared/page-code/common/modelMethods.js @@ -2,6 +2,7 @@ import { getValue, getMethodByCode, getValueType } from './utils' import { uuid, sharedI18n, toPascal, capitalizeFirstChar } from '../../util' import { EVENT_ACTION_TYPE } from '../../function/constant' import { camelCase, camelCaseTransformMerge } from 'change-case' +import { safeSetProperty } from '../../security/property-injection-guard' /** * @desc 追加样式,将页面每个元素的样式追加到css中 @@ -29,13 +30,10 @@ export function appendCss (code, cssStr) { * @param { String } value 需要添加的变量value */ export function dataTemplate (code, key, value) { - const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'] - // 只允许字母数字和下划线,且不允许空字符串 - const SAFE_KEY_REGEX = /^[a-zA-Z0-9_-]+$/ - if (typeof key !== 'string' || FORBIDDEN_KEYS.includes(key) || !SAFE_KEY_REGEX?.test(key)) { - throw new Error('Invalid or forbidden key') + // 使用统一的安全工具函数来设置属性,防止原型链污染 + if (!safeSetProperty(code.dataObj, key, value)) { + throw new Error(`Invalid or forbidden key: ${key}`) } - Object.assign(code.dataObj, { [key]: value }) } /** diff --git a/lib/shared/page-code/common/utils.js b/lib/shared/page-code/common/utils.js index 4ce2e07dd..95ba80bd9 100644 --- a/lib/shared/page-code/common/utils.js +++ b/lib/shared/page-code/common/utils.js @@ -3,6 +3,8 @@ import { unitFilter } from '../../util' import safeStringify from '../../../client/src/common/json-safe-stringify' +import { isSafePropertyKey } from '../../security/property-injection-guard' + /** * @desc 判断输入值的类型 * @param { String } val 需要判读的值 @@ -60,8 +62,8 @@ export function getMethodByCode (methodCode, funcGroups = []) { export function getValue (val) { let value = val const type = getValueType(val) - const logVal = val?.toString()?.replace(/[\n\r]/g, '').slice(0, 50) - console.log('typefromval:', type, logVal) + // const logVal = val?.toString()?.replace(/[\n\r]/g, '').slice(0, 50) + // console.log('typefromval:', type, logVal) switch (type) { case 'string': case 'undefined': @@ -101,14 +103,22 @@ export function transformToString (val) { */ export function handleRenderStyles (renderStyles = {}) { const styles = {} - if (renderStyles && typeof renderStyles === 'object' && Object.keys(renderStyles).length > 0) { - if (renderStyles['customStyle']) { - Object.assign(renderStyles, renderStyles['customStyle']) - delete renderStyles['customStyle'] + // 创建副本,避免原对象被污染 + const tempRenderStyles = { ...renderStyles } + if (tempRenderStyles && typeof tempRenderStyles === 'object' && Object.keys(tempRenderStyles).length > 0) { + if (tempRenderStyles['customStyle']) { + // 只合并安全的 customStyle key + Object.keys(tempRenderStyles['customStyle']).forEach(key => { + if (isSafePropertyKey(key)) { + tempRenderStyles[key] = tempRenderStyles['customStyle'][key] + } + }) + delete tempRenderStyles['customStyle'] } - for (const key in renderStyles) { - if (renderStyles[key]) { - Object.assign(styles, { [paramCase(key)]: unitFilter(renderStyles[key]) }) + for (const key in tempRenderStyles) { + if (!isSafePropertyKey(key)) continue + if (tempRenderStyles[key]) { + Object.assign(styles, { [paramCase(key)]: unitFilter(tempRenderStyles[key]) }) } } } diff --git a/lib/shared/page-code/template/page/layout/index.js b/lib/shared/page-code/template/page/layout/index.js index f12123414..a3c1bfeef 100644 --- a/lib/shared/page-code/template/page/layout/index.js +++ b/lib/shared/page-code/template/page/layout/index.js @@ -32,11 +32,11 @@ export default function generateLayout (code, items = [], inFreeLayout = false) // 多列布局 if (item.type === 'render-grid') { /* eslint-disable no-unused-vars, indent */ - getItemStyles(code, item.componentId, item.renderStyles, item.renderProps, { inFreeLayout }) + getItemStyles(code, item.componentId, item.renderStyles, item.renderProps, { inFreeLayout, cssNamePrefix: `.bk-layout-row-${code.uniqueKey}` }) templateCode += ` ${item.renderSlots && item.renderSlots.default && item.renderSlots.default.map(col => { - getItemStyles(code, col.componentId, col.renderStyles, col.renderProps, {}) + getItemStyles(code, col.componentId, col.renderStyles, col.renderProps, { cssNamePrefix: `.bk-layout-col-${code.uniqueKey}` }) const colAlignStr = getAlignStr(col.renderAlign || {}, inFreeLayout) getInsertCommonStyle(code, colAlignStr) const { @@ -56,7 +56,7 @@ export default function generateLayout (code, items = [], inFreeLayout = false) ` // 自由布局 } else if (['free-layout', 'h5-page'].includes(item.type)) { - getItemStyles(code, item.componentId, item.renderStyles, item.renderProps, { componentType: item.type }) + getItemStyles(code, item.componentId, item.renderStyles, item.renderProps, { componentType: item.type, cssNamePrefix: `.bk-free-layout-${code.uniqueKey}` }) templateCode += `
diff --git a/lib/shared/security/property-injection-guard.js b/lib/shared/security/property-injection-guard.js new file mode 100644 index 000000000..f3aa1f8dc --- /dev/null +++ b/lib/shared/security/property-injection-guard.js @@ -0,0 +1,215 @@ +/** + * @desc 防止Remote property injection攻击的安全工具函数 + * @author LessCode Security Team + */ + +// 禁止的属性名,这些属性可能被用于原型链污染攻击 +const FORBIDDEN_KEYS = [ + '__proto__', + 'constructor', + 'prototype', + 'hasOwnProperty', + 'isPrototypeOf', + 'propertyIsEnumerable', + 'toLocaleString', + 'toString', + 'valueOf' +] + +// 安全的属性名正则表达式,只允许字母、数字、下划线和连字符 +const SAFE_KEY_REGEX = /^[a-zA-Z0-9_-]+$/ + +/** + * @desc 检查属性名是否安全 + * @param {string} key 属性名 + * @returns {boolean} 是否安全 + */ +export function isSafePropertyKey (key) { + if (typeof key !== 'string') { + return false + } + + // 检查是否在禁止列表中 + if (FORBIDDEN_KEYS.includes(key)) { + return false + } + + // 检查是否符合安全命名规范 + if (!SAFE_KEY_REGEX.test(key)) { + return false + } + + return true +} + +/** + * @desc 安全地合并对象属性,防止原型链污染 + * @param {Object} target 目标对象 + * @param {Object} source 源对象 + * @param {Object} options 选项 + * @param {boolean} options.deep 是否深度合并 + * @param {boolean} options.strict 是否严格模式(拒绝所有不安全的属性) + * @returns {Object} 合并后的安全对象 + */ +export function safeObjectMerge (target = {}, source = {}, options = {}) { + const { deep = false, strict = true } = options + + // 创建没有原型链的安全对象 + const safeTarget = Object.create(null) + + // 先复制目标对象的属性 + if (target && typeof target === 'object') { + for (const key in target) { + if (Object.prototype.hasOwnProperty.call(target, key) && isSafePropertyKey(key)) { + safeTarget[key] = deep ? deepClone(target[key]) : target[key] + } + } + } + + // 再合并源对象的属性 + if (source && typeof source === 'object') { + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + if (isSafePropertyKey(key)) { + if (deep && typeof source[key] === 'object' && source[key] !== null) { + safeTarget[key] = safeObjectMerge(safeTarget[key] || {}, source[key], { deep: true, strict }) + } else { + safeTarget[key] = source[key] + } + } else if (strict) { + // 严格模式下,记录不安全的属性但拒绝合并 + console.warn(`[Security] Rejected unsafe property key: ${key}`) + } + } + } + } + + return safeTarget +} + +/** + * @desc 安全地设置对象属性 + * @param {Object} target 目标对象 + * @param {string} key 属性名 + * @param {any} value 属性值 + * @param {boolean} strict 是否严格模式 + * @returns {boolean} 是否设置成功 + */ +export function safeSetProperty (target, key, value, strict = true) { + if (!target || typeof target !== 'object') { + return false + } + + if (!isSafePropertyKey(key)) { + if (strict) { + console.warn(`[Security] Rejected unsafe property key: ${key}`) + return false + } + return false + } + + target[key] = value + return true +} + +/** + * @desc 安全地遍历对象属性 + * @param {Object} obj 要遍历的对象 + * @param {Function} callback 回调函数 + * @param {boolean} strict 是否严格模式 + */ +export function safeForEachProperty (obj, callback, strict = true) { + if (!obj || typeof obj !== 'object') { + return + } + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + if (isSafePropertyKey(key)) { + callback(key, obj[key]) + } else if (strict) { + console.warn(`[Security] Skipped unsafe property key: ${key}`) + } + } + } +} + +/** + * @desc 深度克隆对象,防止原型链污染 + * @param {any} obj 要克隆的对象 + * @returns {any} 克隆后的对象 + */ +export function deepClone (obj) { + if (obj === null || typeof obj !== 'object') { + return obj + } + + if (obj instanceof Date) { + return new Date(obj.getTime()) + } + + if (obj instanceof Array) { + return obj.map(item => deepClone(item)) + } + + if (typeof obj === 'object') { + const cloned = Object.create(null) + safeForEachProperty(obj, (key, value) => { + cloned[key] = deepClone(value) + }, false) // 深度克隆时不严格,但会过滤不安全的key + return cloned + } + + return obj +} + +/** + * @desc 验证并清理对象,移除不安全的属性 + * @param {Object} obj 要清理的对象 + * @param {boolean} deep 是否深度清理 + * @returns {Object} 清理后的安全对象 + */ +export function sanitizeObject (obj, deep = false) { + if (!obj || typeof obj !== 'object') { + return obj + } + + const sanitized = Object.create(null) + + safeForEachProperty(obj, (key, value) => { + if (deep && typeof value === 'object' && value !== null) { + sanitized[key] = sanitizeObject(value, true) + } else { + sanitized[key] = value + } + }, false) // 清理时不严格,但会过滤不安全的key + + return sanitized +} + +/** + * @desc 检查对象是否包含不安全的属性 + * @param {Object} obj 要检查的对象 + * @param {boolean} deep 是否深度检查 + * @returns {Array} 不安全属性的列表 + */ +export function findUnsafeProperties (obj, deep = false) { + const unsafeProperties = [] + + if (!obj || typeof obj !== 'object') { + return unsafeProperties + } + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + if (!isSafePropertyKey(key)) { + unsafeProperties.push(key) + } else if (deep && typeof obj[key] === 'object' && obj[key] !== null) { + const nestedUnsafe = findUnsafeProperties(obj[key], true) + unsafeProperties.push(...nestedUnsafe.map(nestedKey => `${key}.${nestedKey}`)) + } + } + } + + return unsafeProperties +} diff --git a/lib/shared/util.js b/lib/shared/util.js index c446691d7..434e0763a 100644 --- a/lib/shared/util.js +++ b/lib/shared/util.js @@ -168,13 +168,19 @@ export function throttle (fn, delay = 200) { * @returns */ export function unitFilter (value) { - if (value?.endsWith('rpx') && value.length < 50) { - const match = /(\d+)rpx$/.exec(value) - if (match) { - const sizeNumber = (parseInt(match[1], 10) / 750 * 20).toFixed(2) - const result = sizeNumber + 'rem' - return result + if (!value) return '' + try { + value = value?.toString() + if (value?.endsWith('rpx') && value.length < 50) { + const match = /(\d+)rpx$/.exec(value) + if (match) { + const sizeNumber = (parseInt(match[1], 10) / 750 * 20).toFixed(2) + const result = sizeNumber + 'rem' + return result + } } + } catch (error) { + console.log(error, 'unitfilter err') } return value } @@ -434,3 +440,35 @@ export const getLesscodeVarCode = (value) => { } return '' } + +// 过滤文件路径中危险字符 +export function filterFilePath (inputPath, options = {}) { + // 默认把危险字符替换成_ + const { replacement = '_' } = options + + if (typeof inputPath !== 'string') { + throw new TypeError('inputPath 必须是字符串') + } + + // 拆分路径,过滤空和 '.',禁止 '..' + const parts = inputPath.split('/').filter(part => part && part !== '.') + + if (parts.includes('..')) { + throw new Error('路径中不允许包含父目录引用 ".."') + } + + // 定义危险字符正则(控制字符 + 常见危险符号) + // 这里排除了 '/' 因为已经拆分了 + const dangerousCharsPattern = '[\\u0000-\\u001F\\\\*?"\'<>|&;$(){}\\[\\]~!#%+=^:, ]' + const dangerousCharsRegex = new RegExp(dangerousCharsPattern, 'g') + + const safeParts = parts.map(part => { + const replaced = part.replace(dangerousCharsRegex, replacement) + if (!replaced) { + throw new Error(`路径段 "${part}" 过滤后为空,可能全部是非法字符`) + } + return replaced + }) + + return safeParts.join('/') +} diff --git a/package.json b/package.json index 60a84d37f..8d5677336 100644 --- a/package.json +++ b/package.json @@ -85,11 +85,12 @@ "@blueking/cli-service-webpack": "0.0.0-beta.81", "@blueking/crypto-js-sdk": "^0.0.5", "@blueking/login-modal": "^1.0.1", - "@blueking/notice-component-vue2": "^2.0.5", + "@blueking/notice-component-vue2": "^2.0.6", "@blueking/user-selector": "^1.0.7", "@blueking/platform-config": "1.0.5", "@blueking/chrome-tips": "0.0.3-beta.3", "@blueking/bkui-library": "0.0.0-beta.4", + "@blueking/xss-filter": "^0.0.10", "@icon-cool/bk-icon-vue-drag-vis": "^0.2.12", "@toast-ui/editor": "3.1.8", "@toast-ui/vue-editor": "3.1.8", @@ -102,9 +103,8 @@ "ansi_up": "^5.0.0", "app-root-path": "~3.0.0", "async-validator": "~1.8.1", - "aws-sdk": "^2.736.0", "bk-lesscode-render": "1.0.0-beta.4", - "axios": "~1.6.0", + "axios": "~1.12.0", "babel-plugin-lodash": "^3.3.4", "babel-plugin-parameter-decorator": "^1.0.16", "better-npm-run": "~0.1.1", @@ -125,7 +125,7 @@ "db-migrate": "^0.11.12", "db-migrate-mysql": "^2.1.2", "dexie": "^3.2.3", - "dompurify": "^3.0.6", + "dompurify": "^3.2.6", "echarts": "~5.4.0", "element-ui": "~2.15.1", "enhanced-resolve": "^5.10.0", @@ -185,7 +185,7 @@ "nodemon": "~1.19.3", "node-fetch": "^2.6.13", "npm": "6.14.15", - "path-to-regexp": "^6.1.0", + "path-to-regexp": "^8.3.0", "pinyin": "^2.11.2", "postcss": "~8.4.31", "postcss-import": "^15.0.0", @@ -208,7 +208,7 @@ "swiper-element-animation": "^1.1.0", "transliteration": "~2.1.8", "tslib": "^2.0.3", - "typeorm": "^0.2.25", + "typeorm": "~0.3.27", "typescript": "^4.4.3", "unzipper": "~0.10.11", "vant": "^2.12.39",