UNPKG

@nocobase/flow-engine

Version:

A standalone flow engine for NocoBase, managing workflows, models, and actions.

189 lines (169 loc) 6.51 kB
/** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. * * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ /** * 统一的安全全局对象代理:window/document * - 默认仅允许常用的定时器、console、Math、Date、addEventListener(绑定原 window) * - document 仅允许 createElement/querySelector/querySelectorAll * - 不允许随意访问未声明的属性,最小权限原则 */ export function createSafeWindow(extra?: Record<string, any>) { // 解析相对 URL 使用脱敏 base(不含 query/hash),避免在解析时泄露敏感信息 const getSafeBaseHref = () => `${window.location.origin}${window.location.pathname}`; // 安全的 window.open 代理 const safeOpen = (url: string, target?: string, features?: string): Window | null => { // 仅允许 http/https 和 about:blank const isSafeUrl = (u: string): boolean => { try { const parsed = new URL(u, getSafeBaseHref()); // 使用脱敏 base const protocol = parsed.protocol.toLowerCase(); if (protocol === 'about:') return parsed.href === 'about:blank'; return protocol === 'http:' || protocol === 'https:'; } catch { return false; } }; if (!isSafeUrl(url)) { throw new Error('Unsafe URL: window.open only allows http/https/about:blank.'); } // 强制在新标签页打开,避免覆盖当前页或父窗口 const sanitizedTarget = '_blank'; // 合并并强制加上安全特性 const enforceFeatures = (f?: string): string => { const set = new Set<string>(); if (f) { f.split(',') .map((s) => s.trim()) .filter(Boolean) .forEach((part) => { const key = part.split('=')[0].trim().toLowerCase(); if (key !== 'noopener' && key !== 'noreferrer') set.add(part); }); } set.add('noopener'); set.add('noreferrer'); return Array.from(set).join(','); }; const sanitizedFeatures = enforceFeatures(features); // 调用原生 window.open const newWin = window.open.call(window, url, sanitizedTarget, sanitizedFeatures); // 双重保险:禁用 opener(部分浏览器/场景下 features 可能不生效) if (newWin && 'opener' in newWin) { try { (newWin as any).opener = null; } catch { // ignore } } return newWin; }; // 同源在当前页导航;跨域强制用新标签页 + noreferrer/noopener const guardedNavigate = (rawUrl: string, opts?: { replace?: boolean }) => { const parsed = new URL(rawUrl, getSafeBaseHref()); const protocol = parsed.protocol.toLowerCase(); const isAboutBlank = protocol === 'about:' && parsed.href === 'about:blank'; const isHttp = protocol === 'http:' || protocol === 'https:'; if (!isHttp && !isAboutBlank) { throw new Error('Unsafe URL: only http/https/about:blank are allowed.'); } if (isAboutBlank) { return opts?.replace ? window.location.replace('about:blank') : window.location.assign('about:blank'); } const sameOrigin = parsed.protocol === window.location.protocol && parsed.hostname === window.location.hostname && parsed.port === window.location.port; if (sameOrigin) { return opts?.replace ? window.location.replace(parsed.href) : window.location.assign(parsed.href); } const win = safeOpen(parsed.href); if (!win) throw new Error('Popup blocked: cross-origin navigation is opened in a new tab.'); }; // 只读/脱敏的 location 代理;支持安全的 href 赋值和 assign/replace const safeLocation = new Proxy( {}, { get(_t, prop: string) { switch (prop) { case 'origin': return window.location.origin; case 'protocol': return window.location.protocol; case 'host': return window.location.host; case 'hostname': return window.location.hostname; case 'port': return window.location.port; case 'pathname': return window.location.pathname; case 'assign': return (u: string) => guardedNavigate(u, { replace: false }); case 'replace': return (u: string) => guardedNavigate(u, { replace: true }); case 'reload': throw new Error('Access to location.reload is not allowed.'); case 'href': throw new Error('Reading location.href is not allowed.'); default: throw new Error(`Access to location property "${prop}" is not allowed.`); } }, set(_t, prop: string, value: any) { if (prop === 'href') { guardedNavigate(String(value), { replace: false }); return true; } throw new Error('Mutation on location is not allowed.'); }, }, ); const allowedGlobals: Record<string, any> = { // 需绑定到原始 window,避免严格模式下触发 Illegal invocation setTimeout: window.setTimeout.bind(window), clearTimeout: window.clearTimeout.bind(window), setInterval: window.setInterval.bind(window), clearInterval: window.clearInterval.bind(window), console, Math, Date, // 事件侦听仅绑定到真实 window,便于少量需要的全局监听 addEventListener: addEventListener.bind(window), // 安全的 window.open 代理 open: safeOpen, // 安全的 location 代理 location: safeLocation, ...(extra || {}), }; return new Proxy( {}, { get(_target, prop: string) { if (prop in allowedGlobals) return allowedGlobals[prop]; throw new Error(`Access to global property "${prop}" is not allowed.`); }, }, ); } export function createSafeDocument(extra?: Record<string, any>) { const allowed: Record<string, any> = { createElement: document.createElement.bind(document), querySelector: document.querySelector.bind(document), querySelectorAll: document.querySelectorAll.bind(document), ...(extra || {}), }; return new Proxy( {}, { get(_target, prop: string) { if (prop in allowed) return allowed[prop]; throw new Error(`Access to document property "${prop}" is not allowed.`); }, }, ); }