新增 tree、treemap、sunburst、graph、sankey、chord、funnel、gauge 和 pictorialBar 系列,并注册对应的 ECharts 按需模块。 完善层级与关系数据校验、节点和边数量限制、默认布局及错误占位规则,同时补充默认 Markdown 与 README 示例。 静态渲染全局关闭 ECharts 动画,修复 Treemap 标签冻结在透明动画帧的问题,并完成快速预览、精确 PDF、容器镜像和 147 项全项目测试验证。
444 lines
10 KiB
TypeScript
444 lines
10 KiB
TypeScript
import {
|
|
EChartsFenceError,
|
|
supportedEChartsSeriesTypes,
|
|
type JsonValue
|
|
} from "./protocol.js";
|
|
|
|
export interface EChartsSecurityLimits {
|
|
maxDepth: number;
|
|
maxNodes: number;
|
|
maxObjectKeys: number;
|
|
maxArrayLength: number;
|
|
maxStringLength: number;
|
|
maxSeries: number;
|
|
maxHierarchyNodes: number;
|
|
maxGraphNodes: number;
|
|
maxGraphEdges: number;
|
|
}
|
|
|
|
export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
|
|
maxDepth: 32,
|
|
maxNodes: 50_000,
|
|
maxObjectKeys: 1_000,
|
|
maxArrayLength: 20_000,
|
|
maxStringLength: 100_000,
|
|
maxSeries: 32,
|
|
maxHierarchyNodes: 5_000,
|
|
maxGraphNodes: 5_000,
|
|
maxGraphEdges: 10_000
|
|
};
|
|
|
|
const forbiddenPropertyNames = new Set([
|
|
"__proto__",
|
|
"prototype",
|
|
"constructor",
|
|
"renderItem",
|
|
"optionToContent",
|
|
"contentToOption",
|
|
"onclick",
|
|
"onClick",
|
|
"link",
|
|
"sublink"
|
|
]);
|
|
|
|
const allowedTopLevelOptionKeys = new Set([
|
|
"aria",
|
|
"animation",
|
|
"animationDelay",
|
|
"animationDelayUpdate",
|
|
"animationDuration",
|
|
"animationDurationUpdate",
|
|
"animationEasing",
|
|
"animationEasingUpdate",
|
|
"angleAxis",
|
|
"axisPointer",
|
|
"backgroundColor",
|
|
"calendar",
|
|
"color",
|
|
"darkMode",
|
|
"dataZoom",
|
|
"dataset",
|
|
"grid",
|
|
"legend",
|
|
"polar",
|
|
"radar",
|
|
"radiusAxis",
|
|
"series",
|
|
"textStyle",
|
|
"title",
|
|
"toolbox",
|
|
"tooltip",
|
|
"visualMap",
|
|
"xAxis",
|
|
"yAxis"
|
|
]);
|
|
|
|
const allowedToolboxFeatures = new Set([
|
|
"brush",
|
|
"dataZoom",
|
|
"magicType",
|
|
"restore",
|
|
"saveAsImage"
|
|
]);
|
|
|
|
const unsafeStringPattern =
|
|
/^(?:javascript|file|https?):|^\/\/|^data:|url\s*\(|image:\/\//iu;
|
|
|
|
interface TraversalState {
|
|
nodes: number;
|
|
}
|
|
|
|
function optionPath(path: readonly (string | number)[]) {
|
|
return path.length === 0 ? "option" : `option.${path.join(".")}`;
|
|
}
|
|
|
|
function assertSafeKey(key: string, path: readonly (string | number)[]) {
|
|
if (forbiddenPropertyNames.has(key)) {
|
|
throw new EChartsFenceError(
|
|
"UNSAFE_OPTION",
|
|
`${optionPath(path)} 包含禁止属性 ${key}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeValue(
|
|
value: unknown,
|
|
path: readonly (string | number)[],
|
|
depth: number,
|
|
state: TraversalState,
|
|
limits: EChartsSecurityLimits
|
|
): JsonValue {
|
|
state.nodes += 1;
|
|
if (state.nodes > limits.maxNodes) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`ECharts option 节点数量不能超过 ${limits.maxNodes}`
|
|
);
|
|
}
|
|
|
|
if (depth > limits.maxDepth) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`ECharts option 嵌套深度不能超过 ${limits.maxDepth}`
|
|
);
|
|
}
|
|
|
|
if (value === null || typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
if (!Number.isFinite(value)) {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
`${optionPath(path)} 必须是有限数值`
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
if (value.length > limits.maxStringLength) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`${optionPath(path)} 字符串长度不能超过 ${limits.maxStringLength}`
|
|
);
|
|
}
|
|
if (unsafeStringPattern.test(value.trim())) {
|
|
throw new EChartsFenceError(
|
|
"UNSAFE_OPTION",
|
|
`${optionPath(path)} 包含被禁止的外部资源或危险 URL`
|
|
);
|
|
}
|
|
if (
|
|
path.at(-1) === "formatter" &&
|
|
(value.includes("<") || value.includes(">"))
|
|
) {
|
|
throw new EChartsFenceError(
|
|
"UNSAFE_OPTION",
|
|
`${optionPath(path)} 只允许不含 HTML 的模板字符串`
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
if (value.length > limits.maxArrayLength) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`${optionPath(path)} 数组长度不能超过 ${limits.maxArrayLength}`
|
|
);
|
|
}
|
|
return value.map((item, index) =>
|
|
normalizeValue(item, [...path, index], depth + 1, state, limits)
|
|
);
|
|
}
|
|
|
|
if (typeof value !== "object") {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
`${optionPath(path)} 只能包含 JSON 可序列化值`
|
|
);
|
|
}
|
|
|
|
const prototype = Object.getPrototypeOf(value);
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
`${optionPath(path)} 必须是普通对象`
|
|
);
|
|
}
|
|
|
|
const entries = Object.entries(value);
|
|
if (entries.length > limits.maxObjectKeys) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`${optionPath(path)} 属性数量不能超过 ${limits.maxObjectKeys}`
|
|
);
|
|
}
|
|
|
|
const normalized: Record<string, JsonValue> = {};
|
|
for (const [key, child] of entries) {
|
|
assertSafeKey(key, [...path, key]);
|
|
normalized[key] = normalizeValue(
|
|
child,
|
|
[...path, key],
|
|
depth + 1,
|
|
state,
|
|
limits
|
|
);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function assertSupportedTopLevelKeys(option: Record<string, JsonValue>) {
|
|
for (const key of Object.keys(option)) {
|
|
if (!allowedTopLevelOptionKeys.has(key)) {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
`option.${key} 暂不在首版支持范围内`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertSupportedSeries(
|
|
option: Record<string, JsonValue>,
|
|
limits: EChartsSecurityLimits
|
|
) {
|
|
const series = option.series;
|
|
if (!Array.isArray(series) || series.length === 0) {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
"option.series 必须是非空数组"
|
|
);
|
|
}
|
|
if (series.length > limits.maxSeries) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`option.series 数量不能超过 ${limits.maxSeries}`
|
|
);
|
|
}
|
|
|
|
for (const [index, item] of series.entries()) {
|
|
if (
|
|
item === null ||
|
|
Array.isArray(item) ||
|
|
typeof item !== "object"
|
|
) {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
`option.series.${index} 必须是对象`
|
|
);
|
|
}
|
|
const type = item.type;
|
|
if (
|
|
typeof type !== "string" ||
|
|
!supportedEChartsSeriesTypes.some(
|
|
(supportedType) => supportedType === type
|
|
)
|
|
) {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
`option.series.${index}.type 暂不支持 ${String(type)}`
|
|
);
|
|
}
|
|
assertSeriesStructureLimits(item, index, type, limits);
|
|
}
|
|
}
|
|
|
|
function countHierarchyNodes(
|
|
value: JsonValue | undefined,
|
|
limit: number,
|
|
path: string
|
|
) {
|
|
if (!Array.isArray(value)) {
|
|
return;
|
|
}
|
|
let count = 0;
|
|
const pending = [...value];
|
|
while (pending.length > 0) {
|
|
const node = pending.pop();
|
|
count += 1;
|
|
if (count > limit) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`${path} 层级节点数量不能超过 ${limit}`
|
|
);
|
|
}
|
|
if (
|
|
node !== null &&
|
|
!Array.isArray(node) &&
|
|
typeof node === "object" &&
|
|
Array.isArray(node.children)
|
|
) {
|
|
pending.push(...node.children);
|
|
}
|
|
}
|
|
}
|
|
|
|
function arrayLength(value: JsonValue | undefined) {
|
|
return Array.isArray(value) ? value.length : 0;
|
|
}
|
|
|
|
function assertSeriesStructureLimits(
|
|
series: Record<string, JsonValue>,
|
|
seriesIndex: number,
|
|
type: string,
|
|
limits: EChartsSecurityLimits
|
|
) {
|
|
const path = `option.series.${seriesIndex}`;
|
|
if (
|
|
type === "tree" ||
|
|
type === "treemap" ||
|
|
type === "sunburst"
|
|
) {
|
|
countHierarchyNodes(
|
|
series.data,
|
|
limits.maxHierarchyNodes,
|
|
`${path}.data`
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
type !== "graph" &&
|
|
type !== "sankey" &&
|
|
type !== "chord"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const nodeCount =
|
|
arrayLength(series.data) + arrayLength(series.nodes);
|
|
if (nodeCount > limits.maxGraphNodes) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`${path} 节点数量不能超过 ${limits.maxGraphNodes}`
|
|
);
|
|
}
|
|
const edgeCount =
|
|
arrayLength(series.links) + arrayLength(series.edges);
|
|
if (edgeCount > limits.maxGraphEdges) {
|
|
throw new EChartsFenceError(
|
|
"LIMIT_EXCEEDED",
|
|
`${path} 边数量不能超过 ${limits.maxGraphEdges}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertSafeToolbox(option: Record<string, JsonValue>) {
|
|
if (option.toolbox === undefined) {
|
|
return;
|
|
}
|
|
const toolboxes = Array.isArray(option.toolbox)
|
|
? option.toolbox
|
|
: [option.toolbox];
|
|
|
|
for (const toolbox of toolboxes) {
|
|
if (
|
|
toolbox === null ||
|
|
Array.isArray(toolbox) ||
|
|
typeof toolbox !== "object"
|
|
) {
|
|
continue;
|
|
}
|
|
const feature = toolbox.feature;
|
|
if (
|
|
feature === undefined ||
|
|
feature === null ||
|
|
Array.isArray(feature) ||
|
|
typeof feature !== "object"
|
|
) {
|
|
continue;
|
|
}
|
|
for (const key of Object.keys(feature)) {
|
|
if (!allowedToolboxFeatures.has(key)) {
|
|
throw new EChartsFenceError(
|
|
"UNSAFE_OPTION",
|
|
`option.toolbox.feature.${key} 不允许使用`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertSafeTransforms(value: JsonValue, path: string[] = []) {
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item) => assertSafeTransforms(item, path));
|
|
return;
|
|
}
|
|
if (value === null || typeof value !== "object") {
|
|
return;
|
|
}
|
|
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childPath = [...path, key];
|
|
if (key === "transform") {
|
|
const transforms = Array.isArray(child) ? child : [child];
|
|
for (const transform of transforms) {
|
|
if (
|
|
transform === null ||
|
|
Array.isArray(transform) ||
|
|
typeof transform !== "object" ||
|
|
(transform.type !== "filter" && transform.type !== "sort")
|
|
) {
|
|
throw new EChartsFenceError(
|
|
"UNSAFE_OPTION",
|
|
`${optionPath(childPath)} 只允许内置 filter 或 sort`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
assertSafeTransforms(child, childPath);
|
|
}
|
|
}
|
|
|
|
export function normalizeAndValidateEChartsOption(
|
|
value: unknown,
|
|
limits: EChartsSecurityLimits = defaultEChartsSecurityLimits
|
|
): Record<string, JsonValue> {
|
|
const normalized = normalizeValue(
|
|
value,
|
|
[],
|
|
0,
|
|
{ nodes: 0 },
|
|
limits
|
|
);
|
|
if (
|
|
normalized === null ||
|
|
Array.isArray(normalized) ||
|
|
typeof normalized !== "object"
|
|
) {
|
|
throw new EChartsFenceError(
|
|
"INVALID_OPTION",
|
|
"option 必须是普通对象"
|
|
);
|
|
}
|
|
|
|
assertSupportedTopLevelKeys(normalized);
|
|
assertSupportedSeries(normalized, limits);
|
|
assertSafeToolbox(normalized);
|
|
assertSafeTransforms(normalized);
|
|
return normalized;
|
|
}
|