Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

G2 Spec 的序列化,便于 SSR 的时候纯 JSON #6542

Open
1 task done
hustcc opened this issue Dec 6, 2024 · 13 comments
Open
1 task done

G2 Spec 的序列化,便于 SSR 的时候纯 JSON #6542

hustcc opened this issue Dec 6, 2024 · 13 comments
Labels
AntV OSCP feature 💡 A new feature request or an enhancement proposal

Comments

@hustcc
Copy link
Member

hustcc commented Dec 6, 2024

AntV Open Source Contribution Plan(可选)

  • 我同意将这个 Issue 参与 OSCP 计划

Issue 类型

中级任务

任务介绍

我们在做 G2 SSR 的时候,会只用一个 json 配置描述图表,然后这个 json 会由用户输入并存储到数据库中。

但是 G2 Spec 有一些配置时使用回调函数实现,没有办法序列,所以需要有一个方案:用一个规范的字符串去描述简单函数。

{
  encode: {
    x: (d) => d.type
  }
}

改成使用(仅做示意,不干扰方案设计):

{
  "encode": {
    "x": "{d.type}"
  }
}

原则:

  1. 仅仅支持简单的函数回调,不用完全覆盖所有的函数逻辑
  2. 安全性

参考说明

可以参考 echarts、highcharts 等如何处理。

@BQXBQX
Copy link
Contributor

BQXBQX commented Dec 6, 2024

认领

@BQXBQX
Copy link
Contributor

BQXBQX commented Feb 8, 2025

实现方案

目标

将 G2 的 Spec 配置中的回调函数(如 (d) => d.type)转换为可序列化的字符串(如 "{d.type}"),并在服务端渲染(SSR)时安全地还原为函数。


方案设计

1. 字符串语法规范
  • 格式:使用 {} 包裹表达式,例如 "{d.type}"
  • 语义:字符串内的内容为 JavaScript 表达式,作用域中仅包含变量 d(数据项)。
2. 序列化与反序列化
  • 序列化:将回调函数替换为字符串(如 {d.type})。
  • 反序列化:解析字符串,生成函数 (d) => <表达式>,并确保表达式安全性。
3. 安全校验
  • 限制表达式内容
    • 支持简单运算符(如 +-*/、三元运算符)。
    • 禁止函数调用、newthis、全局变量等危险操作。
    • 仅支持特定白名单内部的变量的属性,比如访问变量 d 的属性(如 d.type)。
  • 实现方式:通过 AST 静态分析 校验表达式合法性(使用 acorn 解析并遍历 AST)。同时,检查是否存在可能导致原型链污染、递归与无限循环的结构。

安全性与限制

  • 白名单校验:通过 AST 分析,仅允许安全表达式。
  • 作用域隔离:生成的函数仅能访问特定参数,比如 d,无法调用外部方法或访问全局变量。
  • 错误处理:非法表达式直接抛出错误,避免执行危险代码。

测试用例

// 合法表达式
assertValid("{d.age}");
assertValid("{d.value + 10}");
assertValid("{d.status ? 'OK' : 'FAIL'}");

// 非法表达式
assertInvalid("{alert(1)}");          // 函数调用
assertInvalid("{window.location}");   // 全局变量
assertInvalid("{new Date()}");        // new 操作
assertInvalid("{d.__proto__.evilProp = 'injected'}"); // 原型链污染
assertInvalid("{d.a? d.a : d.a}"); // 可能的无限循环

options 拦截解析位置

Image

添加配置 serializable 选项来控制是否启用这个功能,因为不是所有场景都需要序列化

@hustcc
Copy link
Member Author

hustcc commented Feb 10, 2025

具体是实现是怎么做?

@BQXBQX
Copy link
Contributor

BQXBQX commented Feb 10, 2025

安全检验函数

function isSafeExpression(expr) {
  try {
    const ast = acorn.parseExpressionAt(expr, 0, { ecmaVersion: 'latest' });
    let isSafe = true;

    acorn.walk.simple(ast, {
      CallExpression() { isSafe = false; },   // 禁止函数调用
      NewExpression() { isSafe = false; },    // 禁止 new
      ThisExpression() { isSafe = false; },   // 禁止 this
      Identifier(node) {
        if (node.name !== 'd') isSafe = false; // 仅允许变量 d
      },
      MemberExpression(node) {
        // 确保对象链仅以比如说 d 开头(如 d.a.b)
        let obj = node.object;
        while (obj.type === 'MemberExpression') obj = obj.object;
        if (obj.type !== 'Identifier' || obj.name !== 'd') {
          isSafe = false;
        }
      },
    });

    return isSafe;
  } catch {
    return false; // 解析失败视为非法
  }
}

递归处理 Spec 配置

function parseSpec(spec) {
  const processed = Array.isArray(spec) ? [] : {};
  for (const key in spec) {
    const value = spec[key];
    if (typeof value === 'string' && /^{.*}$/.test(value)) {
      const expr = value.slice(1, -1).trim();
      processed[key] = createSafeFunction(expr);
    } else if (typeof value === 'object' && value !== null) {
      processed[key] = parseSpec(value); // 递归处理嵌套对象
    } else {
      processed[key] = value;
    }
  }
  return processed;
}

反序列化函数生成

function createSafeFunction(expr) {
  if (!isSafeExpression(expr)) {
    throw new Error(`Unsafe expression: ${expr}`);
  }
  // 返回一个类似于下面的生成后的函数
  return new Function('d', `return ${expr};`);
}

@hustcc
Copy link
Member Author

hustcc commented Feb 10, 2025

检验函数引入 acorn 会带来不小的包大小吧,而且安全风险感觉不一定能完全规避。

@BQXBQX
Copy link
Contributor

BQXBQX commented Feb 10, 2025

如果能确保 ssr 运行 render 函数是在一个完全隔离且安全的沙箱环境,可以不用做检验,检验的目的是为了生成安全不会影响上下文的 function。也或者可以不用 acorn,自己实现一个基础的简单检验方法来排除侵入代码,从而避免包体积增大的问题。🤔

@hustcc
Copy link
Member Author

hustcc commented Feb 10, 2025

我感觉要避免使用 new Function,而是自行实现表达式的识别,也可以省去检验。另外,我们不仅仅考虑 ssr,即便非 ssr 也可以使用这套 api 语法。

@BQXBQX
Copy link
Contributor

BQXBQX commented Feb 10, 2025

👍 明白了,就是基于 js 独立的实现一个模版语法的简单语法解释器,这个解释器包含一些方法语法塘,包括以下几个方面

  • 基础类型:支持字符串("value")、数字、布尔值、null。
  • 属性访问:d.prop、d["prop"]。
  • 运算:+、-、*、/、%、&&、||、!、===、!==。
  • 三元表达式:condition ? a : b。
  • 预定义函数:@funcName(args) 格式调用(如 @sum(d.values), @max(d.values))。(可以作为扩展后期提供)
{
  encode: {
    x: "{d.type}",
    y: "{d.value * @sum(d.values)}",
    color: "{d.status ? 'green' : 'red'}"
  }
}
// 下面是一个简单的使用 demo
import { parseExpression } from '@antv/expression-engine';

function processSpec(userSpec, data) {
  const processed = {};
  for (const key in userSpec) {
    const value = userSpec[key];
    if (typeof value === 'string' && value.startsWith('{')) {
      processed[key] = parseExpression(value.slice(1, -1), data);
    } else {
      processed[key] = value;
    }
  }
  return processed;
}

// 使用示例
const parsedSpec = processSpec(userSpec, {
  type: 'category',
  value: 10,
  values: [1, 2, 3],
  status: true
});

console.log(parsedSpec.encode.y); // 输出: 60 {10 * (1+2+3)}

解释库内部自行解释带有语法糖的模版语法,并且返回模版语法的结果。

@BQXBQX
Copy link
Contributor

BQXBQX commented Feb 10, 2025

我觉得可以新建一个库来提供这个模版语法解释功能,这样可以方便后期对语法糖扩展以提供更强的功能,而且同时也可以为其他的 graph lib 提供 ssr 方案

@interstellarmt interstellarmt added the feature 💡 A new feature request or an enhancement proposal label Feb 10, 2025
@hustcc
Copy link
Member Author

hustcc commented Feb 10, 2025

我觉得可以新建一个库来提供这个模版语法解释功能,这样可以方便后期对语法糖扩展以提供更强的功能,而且同时也可以为其他的 graph lib 提供 ssr 方案

是的,是一个单独的库,未来 G6 也会使用。

@BQXBQX
Copy link
Contributor

BQXBQX commented Feb 11, 2025

https://github.com/BQXBQX/graph-secure-eval

基础功能已在此仓库中实现,可以基于此再次封装后使用

// simple encapsulation
import { Tokenizer, Parser, Interpreter } from "@antv/graph-secure-eval";

const tokenizer = new Tokenizer();
const parser = new Parser();

function evaluate(input: string, context = {}, functions = {}) {
	const tokens = tokenizer.tokenize(input);
	const ast = parser.parse(tokens);
	const interpreter = new Interpreter(context, functions);
	return interpreter.evaluate(ast);
}
// simple demo
const context = {
	data: {
		values: [1, 2, 3],
		status: "active",
	},
};

const functions = {
	sum: (arr: number[]) => arr.reduce((a, b) => a + b, 0),
};

const input = '@sum(data.values) > 5 ? data["status"] : "inactive"';
console.log(evaluate(input, context, functions)); // "active";

@hustcc
Copy link
Member Author

hustcc commented Feb 11, 2025

几个小建议:

  1. 这个库的 api 看起来有些怪,是有什么参考吗?可以好好定位下这个 repo,设计下简洁的 api,然后取个好名字,应该还是有用户的
  2. 增加完备单测,通过单测、readme 文档,展示能力范围
  3. 性能做一些社区的对比,比如对比 new Function,eval,或者其他的模板库
  4. 构建之后包大小

@BQXBQX
Copy link
Contributor

BQXBQX commented Feb 11, 2025

几个点:

  1. 这个库的 api 看起来有些怪,是有什么参考吗?可以好好定位下这个 repo,设计下简洁的 api,然后取个好名字,应该还是有用户的
  2. 增加完备单侧
  3. 性能做一些社区的对比,比如对比 new Function,eval,或者其他的模板库
  4. 构建之后包大小

嗯,好的,我再打磨打磨,感谢意见🙏🏻

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
AntV OSCP feature 💡 A new feature request or an enhancement proposal
Projects
Development

No branches or pull requests

3 participants