diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 9b3f496..16c5d09 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1303,7 +1303,7 @@ Playwright: - 只改文档,没有改代码。 -## [37] 2026-10-16 · 阶段 5 F3-ECharts 第一步:后端 option 原样渲染、五种图型、零外部请求 · commit <待提交> +## [37] 2026-10-16 · 阶段 5 F3-ECharts 第一步:后端 option 原样渲染、五种图型、零外部请求 · commit acaa8bf(PR #41) 当前阶段:**阶段 5 前端,F3 进行中**(第一步 ECharts 渲染完成;下一步 prepare 完整版 / 主题 / 开关 / 换图型 / 导出 / 注入实测)。方案见 phase5-frontend.md「F3-ECharts 第一步」(PR #40)。 @@ -1348,3 +1348,42 @@ Playwright: - 主题、显示开关、换图型、导出、注入实测、prepare 完整版(F3 后续)。 - chunk 拆分 / 懒加载 ECharts。 - 沙箱环境备注:Playwright 浏览器装到仓库外 `PLAYWRIGHT_BROWSERS_PATH=/workspace/work/ms-playwright`(home 不持久),系统依赖用 `playwright install-deps chromium`。 + + +## [38] 2026-10-17 · 阶段 5 F3 第二步:注入实测,tooltip 会解析 `}` · commit <待提交> + +当前阶段:**阶段 5 前端,F3 进行中**(第二步注入实测完成;下一步是第三步 prepare 骨架 + outerBounds + tooltip 的 `{}` 处理)。方案见 phase5-frontend.md「F3 第二步」(PR #42)。[37] 的 SHA 已回填 acaa8bf(PR #41)。 + +结论(详见 THREAT_MODEL §2.3):P1–P3 在所有面上都不执行,也不会变成元素。P4(`{a}{b}{c}`)在任何位置都**没有**被模板替换。但在 tooltip(richText)里,用户字符串中的第一个 `}` 会提前结束 ECharts 自己的 `{样式|名字}` 包装:`{a}{b}{c}` 实际画出来是 `{a` + `{b}{c}}`,`{x|富文本}` 是 `{x|富文本` + `}`,后半段换成了另一种字号。标题、轴名、类目轴标签、图例、饼图扇区标签都原样画出,不解析。所以第三步 prepare **必须**处理进 tooltip 的 `{` `}`。 + +后端 / 生成: +- `tests/fake_llm.py`:新增 `INJECT_PAYLOADS`(P1–P5,都短于 NAME_MAX)。`inject_csv()` 用 `csv.writer` 生成夹具 CSV:列名是 P4、P5、P2、P3,5 行,类目值按 `INJECT_ORDER`,系列值错开一位,P2 列全相等。新增场景 `inject-`:写代码的那一步,cartesian 和 scatter 各出两张图(宽表多 y,列名进图例;长表 `series=`,单元格值进图例),pie 出一张;结论里拼上 P1–P3,外加一个 `[点我](javascript:…)`。新增场景 `inject-error`:只调一次 `chart()`,x 传不存在的列名 P1。解析规则和 chart 一样:未知 kind 或带 @step 返回 400。 +- `scripts/gen_ui_types.py`:新增 `inject.json`(`{payloads, order, csv}`)。`chart_fixtures.json` 加了 3 个注入例(inject-bar、inject-bar-series、inject-pie),用的是同一份数据、同样的调用。`--check` 照旧按字节比对。 + +前端测试: +- `src/charts/inject.test.ts`:3 个注入夹具做 SSR SVG 渲染,用 DOMParser 按 XML 结构判定:载荷以文本节点完整出现;没有 img / script / iframe / object / embed / foreignObject / a;只有根 svg;没有 `on*` 属性和 `javascript:` 链接。第一版用正则查原串时红了:转义后的文本里本来就有 ` onerror=` 这几个字(`<img src=x onerror="…`),所以改成按结构判。 +- `e2e/f3-inject.spec.ts`(fake project,托管 dist + CSP):跑三轮(bar、pie、error)。断言包括:`fillText` / `strokeText` 包一层记录(原样转调);MutationObserver 从页面就绪后开始记新增元素和 `on*` / `javascript:` 属性;`page.on('dialog')`;5 处真机悬停(宽表的 P4、P5 类目,长表的系列行,饼图的 P4、P5 扇区),每处都要求绘制记录里出现被悬停的名字,并且悬停前后像素不同(截图前等连续两次逐字节相同,最多 30 次,不改产品的 animation);P4、P5 按位置的判定表用 `toEqual` 钉住;警告、结论、三轮的代码面板都按文本显示;`.echart` 的 textContent 为空,后代只有 div 和 canvas;零 CSP 违规、零外部请求。 + +偏离方案(及理由): +- **chart_error 拆成单独的 `inject-error` 场景**,没有在 `inject-` 里多调一次。原因:`app/core/result.py` 的 `chart_warnings` 只在**没有** option 图时才把 chart_error 放进 warnings(`if res.chart_errors and not has_option`),照方案做的话,载荷根本到不了界面。 +- **第二张图的标题是 `P4 P5`**(方案只写了 P1),这样标题这个位置上 P4、P5 也有结论。 +- **类目顺序是 `INJECT_ORDER = P4, P1, P5, P2, P3`**,不是 P1–P5。`.view` 最宽 1200px,载荷标签又长,ECharts 会自动隔一个画一个(实测只画第 0、2、4 个)。按 P1–P5 排的话,P4 的类目标签画不出来,那个位置就没有结论。悬停坐标用画出来的标签线性推算。 +- MutationObserver 从页面就绪后才开始记新增元素:第一次跑时 index.html 自己的 3 个 script 被记了进去。 +- 判定口径:只要画出来的不是完整原串,或者出现了 `{a` 之类的残片,就算"解析"。 + +补测(S 评审后,本地 probe,脚本不入库,第三步会转成 e2e 用例):直接加载 echarts 6.1.0 dist,renderMode 为 richText,真机悬停,记录 fillText(文本、坐标、字体)。 +- `x}{y|999`:bar 的表头行、系列名行和饼图扇区名都画成 `x`(14px)+ `999`(12px),`}{y|` 消失。轴标签和饼图标签原样画出。 +- 借样式:内部样式名是 `__EC_aUTo_`,起点为 `round(random×9)`(echarts `util/number.js` 的 `getRandomIdBase`),在 `tooltipMarkup.js` 的 `wrapRichTextStyle` 里递增。载荷 `x}{__EC_aUTo_K|999`,K 取 3–12,每个 K 悬停 20 次:只放在类目里时命中 18/200,类目和系列名都放时命中 36/200(每个 tooltip 有两次机会)。命中时 999 画成 `900 14px`,和真值同款。命中率按配置分开记:单次机会 9%(18/200),两次机会 18%(36/200)。 +- 落点:第一版只记了 fillText 的 x,没记 `textAlign`,"右对齐"的论证不成立(S 指出:x 是起点还是右边缘,取决于 textAlign)。补测时同时记 `textAlign` 和 `measureText` 宽度,伪造数字分别用 `9`、`999`、`99999999`,真值用 `7` 和 `12,345,678`:伪造文本和真值都是 `textAlign=right`,x 都等于 134,宽度分别是 8、23、62 和 8、70。所以 x 是右边缘,右对齐到数值列,落点由借到的样式决定(表头行的 `x` 在 x=10,伪造数字仍然出现在数值列)。系列名行里,伪造数字夹在名字和真值之间(上一轮记录是 `x 999 8`)。真值没有被替换。 +- 换行:`上\n下` 在表头拆成两行;在系列名行里,`下` 从行首开始,真值跟着落在 `下` 那一行。确定性复现,定级排在借样式之前。 +- tab:`上\t下` 作为一段字符整体画出(宽 32),数值列位置不变,不参与排版。 +- 未证:多个 token 连写的稳定命中、借 marker 样式伪造色块。按 S 的决定,第三步在后端规范化 `{}`,让这些路径不可达,所以不再测。 + +验证(最后一次改代码之后全部重跑):pytest **707 passed**(+27,含本地闸门数据 25 条;`test_fake_llm.py` 63 → 90,其中真链路 inject 5 kind × 2 agent + error × 2);vitest **77**(+10:inject.test.ts 7,夹具多 3 个带来 charts.test.ts SSR 渲染 +3);Playwright **11 passed**(nokey 7 + fake 4,新增 f3-inject 11.2 s);lint、tsc、build(check-dist 0 内联,主 chunk 964.38 kB 没变)、`gen_ui_types --check` 都通过。 + +未做: +- v2 字段的注入(title.link / sublink、各类 formatter、aria.*),留到层 2 再测。 +- tooltip 的 `{}` 处理:放在第三步,方案先由 S 评审。 +- 固定端口连续跑 e2e 会撞上 TIME_WAIT。定性为测试工具问题加 sidecar 契约缺口(壳必须用 `--port 0`),不要加 SO_REUSEADDR,见 issue #44。 +- `app/core/result.py:117`(`if res.chart_errors and not has_option`):有图时 chart_error 被静默丢弃,用户少看到一张图却不知道。这是有意的"部分失败不打扰"还是遗漏,待定。 +- 第三步要连带处理:后端 `_clean_str` / `_cat` 规范化 `{}` 和换行,`validate_spec` 加闸门:遍历整个 option,任何 string 值都不得含 `{`、`}`、`\n`,不按登记键逐个查;判定表的 `note()` 判据要跟着改(规范化后原串不再出现,判据不改的话会变成 raw 和 parsed 都为 false),改的是判据,不是期望值;重新生成 chart_fixtures 的注入例(inject.json 不变)。 diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index c7dc58e..9abb7c1 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -57,7 +57,7 @@ keyring 对逃逸不是完全没用:逃逸后偷 Key 的路径从"读任意文 | 面 | 怎么兜 | v1(builder-only) | v2(层 2 patch) | |---|---|---|---| | 图表(标题、坐标轴、图例、类目、系列名) | 画在 canvas 上,不进 DOM | 生效 | 生效 | -| 图表 tooltip | 前端统一 `tooltip.renderMode = 'richText'`(画在 canvas) | 生效(主防线) | 生效(主防线) | +| 图表 tooltip | 前端统一 `tooltip.renderMode = 'richText'`(画在 canvas)。挡住了代码执行,但 richText 自己有富文本边界问题:用户字符串里的 `}` 会跳出 ECharts 的包装(F3 第二步实测,见下文),第三步 prepare 处理 | 生效(主防线) | 生效(主防线) | | tooltip / label / legend 的 `formatter` | 白名单禁 `formatter` | 不可达:builder 不产出,模型改不到 | 生效 | | 图表的 DOM 通道 | 白名单禁 `title.link` / `sublink` / `graphic` / `aria` / `toolbox`(含 dataView) | 不可达:同上 | 生效 | | 错误消息、警告(含 chart_error) | 前端按纯文本渲染(`textContent` / React 默认转义),**禁止** `dangerouslySetInnerHTML` | 生效(第 5 步实现) | 生效 | @@ -72,10 +72,39 @@ keyring 对逃逸不是完全没用:逃逸后偷 Key 的路径从"读任意文 - 由渲染端重新注入 `renderMode = 'richText'`,不信任回传值; - **不得从 `BANNED_KEYS` 删掉 `renderMode`** 来让回灌通过。删它等于让后端 / 模型能把 tooltip 改回 HTML 模式,正好拆掉 T9 在 tooltip 上的主防线。tests/test_charts.py 有用例钉住 `renderMode` 在禁用集里。 -验证(第 5 步):用注入载荷逐字段喂,**断言打在渲染后的 DOM 上**(无新增元素、无事件属性、无可执行 href),至少测三个面:tooltip、错误消息、结论文本。 -- v1 可达的字段:title.text、series[].name、xAxis.data[](类目)、yAxis.name / xAxis.name(列名),以及错误消息、警告里的列名。 -- 只在 v2 才可达、v1 测的是不可达路径的字段(放到层 2 时再测):title.link / sublink、series[].label.formatter、legend.formatter、tooltip.formatter、aria.label / description。 -- tests/test_charts.py 里的 HTML 用例只证明载荷能原样到达前端,那是攻击的前提,不是防御。 +验证(F3 第二步实测,echarts 6.1.0,DEVLOG [38]):载荷是 `tests/fake_llm.py` 的 `INJECT_PAYLOADS`,经 `gen_ui_types` 导出成 `inject.json`(前端不另写一份): +P1 ``、P2 `">`、P3 `javascript:window.__pwned=3`、P4 `{a}{b}{c}`(ECharts 模板占位符)、P5 `{x|富文本}`(rich 语法)。 +夹具 CSV 由 `csv.writer` 生成,列名、类目值、系列列的单元格值都是载荷,经真 builder 进标题、轴名、类目、两种来源的 series.name(宽表 = 列名,长表 = 系列列单元格值)、图例、饼图扇区名。 + +| 面 | 怎么测(文件 / 测试名) | 结论 | +|---|---|---| +| 图表各位置(标题、轴名、类目轴标签、图例、饼图扇区标签、tooltip),托管 dist + CSP + canvas | `ui/web/e2e/f3-inject.spec.ts`「注入:图表各位置、tooltip、警告、结论、代码面板都不执行、不成元素;P4/P5 按位置判定」:三轮(inject-bar 宽表 + 长表两张、inject-pie、inject-error);包一层 `fillText` / `strokeText` 记录实际绘制的字符串;tooltip 真机悬停 5 处,每处都断言绘制记录里出现被悬停的名字、悬停前后 canvas 像素不同(截图前等连续两次逐字节相同) | P1–P3 **不执行**:`window.__pwned` 未定义、无 dialog;每个 `.echart` 的 `textContent` 为空、后代只有 div 和 canvas;全页新增元素里没有 img / svg / script / iframe / object / embed,没有 `on*` 属性和 `javascript:` 的 href / src;零 CSP 违规、零外部请求 | +| 同上,SSR SVG(断言工具,不是产品路径) | `ui/web/src/charts/inject.test.ts`:`chart_fixtures.json` 的 inject-bar / inject-bar-series / inject-pie | 载荷作为文本节点完整到达渲染器(攻击的前提成立);按 XML 结构解析后没有载荷造出的元素、`on*` 属性或 `javascript:` 链接 | +| 错误消息、警告(chart_error) | 同一 e2e 的 inject-error 轮:x 传不存在的列名 P1,`图表未生成:x 列 '' 不存在;可用列:、、、` 进 `result.warnings` | 按文本显示(`toContainText`),无新增元素 | +| 结论(Markdown) | 同一 e2e:结论含 P1–P3 和 `[点我](javascript:…)` | 按文本显示;链接渲染出来了(对照),href 不含 `javascript:` | +| 代码面板 | 同一 e2e:三轮都展开"生成代码",代码里有 P1、P4、P5 字面量 | 按文本显示 | +| 后端到达 | `tests/test_fake_llm.py::test_real_chain_inject_kind`(5 kind × 2 agent)、`test_real_chain_inject_error_reaches_warnings`、`test_inject_payloads_fit_and_csv_round_trips_through_pandas` | 载荷完整到达 option、warnings、结论(只证明前提,不是防御) | + +**P4 / P5:按位置的解析结论**(e2e 里 `expect(verdict).toEqual(...)` 钉住,升级 echarts 时会红,按新现象重判): + +| 位置 | P4 `{a}{b}{c}` | P5 `{x\|富文本}` | +|---|---|---| +| 标题 | 不解析(原样画出) | 不解析 | +| 轴名 | 不解析 | (未放在轴名上) | +| 类目轴标签 | 不解析 | 不解析 | +| 图例 | 不解析 | 不解析 | +| 饼图扇区标签 | 不解析 | 不解析 | +| **tooltip(richText)** | **解析** | **解析** | + +- 模板替换:所有位置都**没有**把 `{a}` 换成系列名或数值,P4 没有造成"数字被换"的篡改。 +- tooltip 的问题出在富文本边界:richText 模式下 ECharts 把每个名字包成 `{样式名|名字}`,用户字符串里的第一个 `}` 提前结束了这个包装。实测绘制:`{a}{b}{c}` 画成 `{a`(14px)+ `{b}{c}}`(12px),也就是屏幕上显示 `{a{b}{c}}`,字符顺序被改了;`{x|富文本}` 画成 `{x|富文本`(14px)+ `}`(12px)。tooltip 的类目表头、系列名行、饼图扇区名三处都是这样。 +- **换行:真值配上假标签(实测,确定性,本节最高一级)**:类目名或系列名里的 `\n` 会参与 richText 排版。`上\n下` 在表头被拆成两行;在系列名行里,`下` 从行首开始(不带色块缩进),真值 8 落在 `下` 那一行,读出来就是「下 = 8」。数字是真的,标签是攻击者写的,而数字本身经得起核对,所以比编一个假数字更难被发现。这条不用猜,也没有命中率,每次都复现。后端 `_CTRL` 放过了 `\x0a`。`\t`(同样被放过)实测不参与排版:作为一段字符整体画出,数值列位置不变。 +- **借用内部样式:在数值列伪造数字(实测,概率性)**:跳出包装后,后面的 `{名字|…}` 会按 rich 语法解析。`x}{y|999` 画成 `x` + `999`(`}{y|` 消失)。ECharts 给 tooltip 内部样式起名 `__EC_aUTo_`,n 从 `round(random×9)` 开始递增,每次显示 tooltip 都重新生成。`x}{__EC_aUTo_K|<数字>` 命中时,借到的不只是字体(`900 14px`),还有对齐方式:伪造文本的 `textAlign` 是 `right`,绘制 x 和同一个 tooltip 里真值的 x 相同。伪造 1 位、3 位、8 位数字时这个 x 都不变(真值 `7` 和 `12,345,678` 的 x 也是它),所以右边缘对齐到数值列,落点由样式决定,可以复现。**表头行**本来没有数值列,伪造数字仍然出现在数值列位置,看起来像一行没有色块的数值行。**系列名行**里,伪造数字夹在系列名和真值之间。两种情况下真值都没有被替换。命中率:单次机会 9%(18/200),类目和系列名各给一次机会时 18%(36/200)。攻击者控制整份 CSV(每个类目猜一个 K),放到数据集层面可以靠枚举稳定命中。 +- **对第三步的要求**:在后端规范化(`_clean_str` / `_cat`,一处覆盖全路径),并在 `validate_spec` 加闸门:遍历整个 option,任何 string 值都不得含 ASCII `{`、`}`、`\n`,不按登记键逐个查。这样白名单以后加字段,闸门照样覆盖。不在前端注入 `tooltip.formatter`,否则会把"产物里没有 formatter"这条防线降级成不可判定。具体在第三步方案里定,这条注入回归要继续跑。 + +未测(v2 层 2 才可达,到层 2 再测):title.link / sublink、series[].label.formatter、legend.formatter、tooltip.formatter、aria.label / description。 +prepare 的纵深防御(删被禁键)只在 vitest 层测过(`charts.test.ts`「对照:人为塞进各层级的被禁键确实被删掉」);托管 dist 上不可达,因为后端 `validate_spec` 先拒了。 +tests/test_charts.py 里的 HTML 用例同样只证明载荷能原样到达前端。 ## 2.4 错误文案:渲染面上的信息泄漏(阶段 5 F2 立项,修复单独一个 PR) | # | 威胁 | 例子 | diff --git a/docs/roadmap/phase5-frontend.md b/docs/roadmap/phase5-frontend.md index 529a06e..c56318b 100644 --- a/docs/roadmap/phase5-frontend.md +++ b/docs/roadmap/phase5-frontend.md @@ -172,7 +172,9 @@ v2 才可达的字段(link、formatter、aria 等)留到层 2 再测。 每一步开工前,先在本文写好"已定"规则和验收标准,由 S 评审。下面只展开第二步。 -**F3 第二步:注入实测(草案,待 S 评审)** +**F3 第二步:注入实测(S 10-17 评审通过;已实现,DEVLOG [38])** + +实测结论:tooltip(richText)会解析用户字符串里的 `}`,其余位置都不解析,模板占位符哪里都不替换。所以第三步 prepare **必须**处理进 tooltip 的 `{` `}`。详见 THREAT_MODEL §2.3。实现时的两处偏离见 DEVLOG [38]:chart_error 改成单独一个 `inject-error` 场景;第二张图的标题用 P4+P5。 目标:§0 验收第 7 条,"注入实测结论写进 THREAT_MODEL §2.3,DOM 断言在后端托管的 dist 上跑通"。本步只测 v1 可达的字段;v2(层 2)才可达的字段写明"未测",到层 2 再测。 diff --git a/scripts/gen_ui_types.py b/scripts/gen_ui_types.py index ea40915..9afa9b8 100644 --- a/scripts/gen_ui_types.py +++ b/scripts/gen_ui_types.py @@ -1,11 +1,12 @@ """把后端的事件类型、图表白名单与禁用键、图表夹具导出成前端用的 JSON(阶段 5 F2 / F3),前端不手抄、改了后端 CI 就报。 - python scripts/gen_ui_types.py # 写 ui/web/src/generated/{events,banned_keys,chart_schema,chart_fixtures}.json + python scripts/gen_ui_types.py # 写 ui/web/src/generated/{events,banned_keys,chart_schema,chart_fixtures,inject}.json python scripts/gen_ui_types.py --check # 只校验现有文件与重新生成的是否一致(CI 用) 来源:app/core/events.py 的 EVENT_TYPES、ERROR_KINDS 和常量文案;app/server/db.py 的 MESSAGE_STATUSES;app/tools/charts.py 的 BANNED_KEYS(前端渲染 ECharts 前再过一遍)、 KINDS / _KIND_SERIES_TYPE / _ENUMS / _ALLOWED 顶层键(chart_schema.json,前端组件注册表双向比对), -以及 build_option 在固定合成数据上的真实产出(chart_fixtures.json,前端渲染测试只用它,不手写 option)。 +以及 build_option 在固定合成数据上的真实产出(chart_fixtures.json,前端渲染测试只用它,不手写 option); +F3 第二步的注入载荷与 CSV(inject.json,来自 tests/fake_llm.py,前端 e2e 直接上传这份 CSV,不在 TS 里手拼)。 """ import argparse import json @@ -44,6 +45,9 @@ def cats(n): def pts(n): return pd.DataFrame({"x": rng.normal(0, 1, n).round(3), "y": rng.normal(0, 1, n).round(3)}) wide = pd.DataFrame({"季度": ["Q1", "Q2", "Q3", "Q4"], "收入": [10, 12, 9, 15], "成本": [7, 8, 8, 9], "利润": [3, 4, 1, 6]}) + from tests.fake_llm import INJECT_PAYLOADS as P, INJECT_COLUMNS, INJECT_ROWS + inj = pd.DataFrame(INJECT_ROWS, columns=INJECT_COLUMNS) + c_cat, c_ser, c_n1, c_n2 = INJECT_COLUMNS cases = [ ("bar", build_option("bar", small, x="城市", y="销量", title="各城市销量")), ("bar-untitled", build_option("bar", small, x="城市", y="销量")), @@ -61,10 +65,20 @@ def pts(n): (f"bar-{MAX_POINTS + 1}", build_option("bar", cats(MAX_POINTS + 1), x="k", y="v", title="截断柱图")), (f"scatter-{MAX_POINTS}", build_option("scatter", pts(MAX_POINTS), x="x", y="y")), (f"scatter-{MAX_POINTS + 1}", build_option("scatter", pts(MAX_POINTS + 1), x="x", y="y", title="截断散点")), + # 注入例(F3 第二步):与 fake inject- 同一份数据、同样的调用 + ("inject-bar", build_option("bar", inj, x=c_cat, y=[c_n1, c_n2], title=P["P1"])), + ("inject-bar-series", build_option("bar", inj, x=c_cat, y=c_n1, series=c_ser, title=f"{P['P4']} {P['P5']}")), + ("inject-pie", build_option("pie", inj, x=c_cat, y=c_n1, title=P["P1"])), ] return {"_generated_by": "scripts/gen_ui_types.py", "fixtures": [{"id": i, "spec": s} for i, s in cases]} +def inject() -> dict: + from tests.fake_llm import INJECT_ORDER, INJECT_PAYLOADS, inject_csv + return {"_generated_by": "scripts/gen_ui_types.py", "payloads": dict(INJECT_PAYLOADS), "order": list(INJECT_ORDER), + "csv": inject_csv()} + + def build() -> dict[str, str]: from app.core.events import (CANCELLED_MESSAGE, DISCONNECTED_MESSAGE, ERROR_KINDS, EVENT_TYPES, INTERRUPTED_MESSAGE, STREAM_ABORTED_MESSAGE) @@ -82,6 +96,7 @@ def build() -> dict[str, str]: "chart_schema.json": dump(chart_schema()), # indent=1:build_option 一改,diff 能看出改了哪个值(紧凑 JSON 一行,每次整份重写)。按字节比对。 "chart_fixtures.json": json.dumps(chart_fixtures(), ensure_ascii=False, indent=1) + "\n", + "inject.json": dump(inject()), } diff --git a/tests/fake_llm.py b/tests/fake_llm.py index 738e86e..b980a28 100644 --- a/tests/fake_llm.py +++ b/tests/fake_llm.py @@ -6,11 +6,16 @@ model 名语法(解析器严格,见 parse_model): [@] - scenario:ok | hang-s | fail- | slow-s | chart- + scenario:ok | hang-s | fail- | slow-s | chart- | inject- | inject-error ok 每一步正常回复(固定剧本,见 reply_for) chart- 同 ok,但写代码的那一步(codegen / fix / act)多调一次 chart('', df, …)(F3 图表测试); kind 取 app/tools/charts.py 的 KINDS,列按 dtype 选(chart_code),任何 CSV 只要有一个类目列和 一个数值列(scatter 要两个数值列)都能画。不带 @step。 + inject- F3 第二步注入实测:写代码的那一步对 inject_csv() 这份数据出图,标题、列名、类目、系列名都是 + INJECT_PAYLOADS;cartesian / scatter 出两张(宽表多 y:列名进图例;长表 series=:单元格值进图例), + pie 出一张;结论拼上 P1–P3(含一个 javascript: 链接)。不带 @step。 + inject-error 同上,但只调一次 chart(),x 传不存在的列名 P1:没有图时 chart_error 才进 result.warnings + (app/core/result.py chart_warnings 的 `not has_option`),所以单独一个场景。 hang-s 在目标调用上先睡 N 秒再正常回复(测停止) fail- 目标调用返回 HTTP (400/401/403/404/429/500/502/503) slow-s 每一次调用都先睡 N 秒(把事件拉开,测增量渲染) @@ -58,6 +63,53 @@ def chart_code(kind: str) -> str: f"chart({kind!r}, df, {xy}, title={'fake ' + kind!r})\n" + CODE) FAIL_ANSWER = "(fake)没能得出结果。" +# ------------------------------------------------------------ F3 第二步:注入载荷(gen_ui_types 导出给前端,前端不另写) +# 每条都短于 charts.NAME_MAX,不会被截断。P4 / P5 不是代码执行面,测的是 ECharts 会不会把用户字符串当模板 / 富文本解析。 +INJECT_PAYLOADS = { + "P1": '', + "P2": '">', + "P3": "javascript:window.__pwned=3", + "P4": "{a}{b}{c}", + "P5": "{x|富文本}", +} +_P = INJECT_PAYLOADS +# 列:类目列名 P4、系列列名 P5(进轴名 / 标题);数值列名 P2、P3(进宽表图例、长表 y 轴名)。 +# 5 行:类目值按 INJECT_ORDER,系列值错开一位(长表每个 (类目, 系列) 唯一)。P2 列全相等,饼图 5 个扇区等分(e2e 按角度悬停)。 +# 顺序有讲究:载荷标签长,1200px 宽的图里 echarts 自动隔一个画一个(第 0、2、4 个),P4、P5 放在画得出来的位置上, +# 这样类目轴标签这个位置上 P4、P5 都有实测结论。 +INJECT_COLUMNS = [_P["P4"], _P["P5"], _P["P2"], _P["P3"]] +INJECT_ORDER = ["P4", "P1", "P5", "P2", "P3"] +INJECT_ROWS = [[_P[INJECT_ORDER[i]], _P[INJECT_ORDER[(i + 1) % 5]], 2, i + 1] for i in range(5)] +INJECT_ANSWER = (f"(fake)结论:{_P['P1']} {_P['P2']} {_P['P3']} [点我]({_P['P3']})") + + +def inject_csv() -> str: + """注入夹具 CSV,用 csv.writer 生成:载荷里有 < " { |,手拼会把 CSV 结构撑坏。""" + import csv + import io + buf = io.StringIO() + w = csv.writer(buf, lineterminator="\n") + w.writerow(INJECT_COLUMNS) + w.writerows(INJECT_ROWS) + return buf.getvalue() + + +def inject_code(kind: str) -> str: + """inject- / inject-error 场景的代码。列按 dtype 选(不在代码里写列名),标题和错列名是载荷字面量(代码面板也是一个面)。""" + head = ("num = list(df.select_dtypes('number').columns)\n" + "cat = [c for c in df.columns if c not in num]\n") + if kind == "error": + return head + f"chart('bar', df, x={_P['P1']!r}, y=num[0], title={_P['P1']!r})\n" + CODE + if kind == "pie": + return head + f"chart('pie', df, x=cat[0], y=num[0], title={_P['P1']!r})\n" + CODE + title2 = f"{_P['P4']} {_P['P5']}" + if kind == "scatter": + wide, long_ = "x=num[0], y=num", "x=num[0], y=num[1], series=cat[1]" + else: + wide, long_ = "x=cat[0], y=num", "x=cat[0], y=num[0], series=cat[1]" + return (head + f"chart({kind!r}, df, {wide}, title={_P['P1']!r})\n" + f"chart({kind!r}, df, {long_}, title={title2!r})\n" + CODE) + # 步骤识别用的提示词片段。改了 app/nodes/* 或 app/react_agent.py 的提示词,这里会认不出 → 400, # tests/test_fake_llm.py 的真链路测试(两种 agent 都跑一遍)立刻红。 MARK_REACT = "你是一个数据分析智能体" @@ -74,10 +126,10 @@ class FakeError(Exception): @dataclass(frozen=True) class Scenario: - kind: str # ok | hang | fail | slow | chart + kind: str # ok | hang | fail | slow | chart | inject value: int = 0 # 秒数或 HTTP 状态码 step: Optional[str] = None # 目标步骤;None = 第一次调用 - chart: Optional[str] = None # chart 场景的图型 + chart: Optional[str] = None # chart / inject 场景的图型(inject 另有 "error") def targets(self, agent: str, step: str) -> bool: if self.step is not None and self.step not in (WORKFLOW_STEPS if agent == "workflow" else REACT_STEPS): @@ -86,6 +138,7 @@ def targets(self, agent: str, step: str) -> bool: _CHART_RE = re.compile(r"^chart-(?P[a-z]+)(?:@(?P[a-z]*))?$") +_INJECT_RE = re.compile(r"^inject-(?P[a-z]+)(?:@(?P[a-z]*))?$") _MODEL_RE = re.compile(r"^(?Pok|hang|fail|slow)(?:-(?P\d+)(?Ps?))?(?:@(?P[a-z]+))?$") @@ -97,9 +150,16 @@ def parse_model(name: str) -> Scenario: if c["step"] is not None: raise FakeError(f"chart 作用于写代码的那一步,不带 @step:{name!r}") return Scenario("chart", chart=c["kind"]) + j = _INJECT_RE.match(name or "") + if j: + if j["kind"] not in KINDS + ("error",): + raise FakeError(f"未知图型 {j['kind']!r}:可选 {', '.join(KINDS)}, error") + if j["step"] is not None: + raise FakeError(f"inject 作用于写代码的那一步,不带 @step:{name!r}") + return Scenario("inject", chart=j["kind"]) m = _MODEL_RE.match(name or "") if not m: - raise FakeError(f"无法解析 model 名 {name!r}:语法是 [@step]") + raise FakeError(f"无法解析 model 名 {name!r}:语法是 [@step]") kind, val, unit, step = m["kind"], m["val"], m["unit"], m["step"] if step is not None and step not in WORKFLOW_STEPS + REACT_STEPS: raise FakeError(f"未知步骤 @{step}:可选 {', '.join(WORKFLOW_STEPS + REACT_STEPS)}") @@ -149,21 +209,23 @@ def recognize(messages: list) -> tuple[str, str]: raise FakeError("无法识别当前步骤,提示词可能已变(更新 tests/fake_llm.py 的 MARK_*)") -def reply_for(agent: str, step: str, messages: list, chart: Optional[str] = None) -> dict: - """固定剧本:返回 OpenAI 的 assistant message。chart 给定时,写代码的那一步换成 chart_code(chart)。""" - code = chart_code(chart) if chart else CODE +def reply_for(agent: str, step: str, messages: list, chart: Optional[str] = None, inject: bool = False) -> dict: + """固定剧本:返回 OpenAI 的 assistant message。chart 给定时,写代码的那一步换成 chart_code(chart); + inject 时换成 inject_code(chart),成功结论换成 INJECT_ANSWER。""" + code = inject_code(chart) if inject else (chart_code(chart) if chart else CODE) + answer = INJECT_ANSWER if inject else ANSWER if agent == "react": if step == "act": return {"role": "assistant", "content": "", "tool_calls": [{ "id": "call_fake_1", "type": "function", "function": {"name": "run_python_on_data", "arguments": json.dumps({"code": code})}}]} - return {"role": "assistant", "content": ANSWER} + return {"role": "assistant", "content": answer} if step == "plan": return {"role": "assistant", "content": PLAN} if step in ("codegen", "fix"): return {"role": "assistant", "content": f"```python\n{code}\n```"} whole = "\n".join(_text(m.get("content")) for m in messages) - return {"role": "assistant", "content": FAIL_ANSWER if MARK_ANSWER_FAIL in whole else ANSWER} + return {"role": "assistant", "content": FAIL_ANSWER if MARK_ANSWER_FAIL in whole else answer} def handle(body: dict, sleep=time.sleep) -> tuple[int, dict]: @@ -181,7 +243,7 @@ def handle(body: dict, sleep=time.sleep) -> tuple[int, dict]: if sc.kind == "fail" and hit: return sc.value, {"error": {"message": f"[fake_llm] 按场景返回 {sc.value}", "type": "fake_failure", "code": str(sc.value)}} - msg = reply_for(agent, step, body["messages"], sc.chart) + msg = reply_for(agent, step, body["messages"], sc.chart, sc.kind == "inject") return 200, { "id": f"chatcmpl-fake-{uuid.uuid4().hex[:8]}", "object": "chat.completion", "created": int(time.time()), "model": body["model"], diff --git a/tests/test_fake_llm.py b/tests/test_fake_llm.py index 29e688f..81b70f1 100644 --- a/tests/test_fake_llm.py +++ b/tests/test_fake_llm.py @@ -10,7 +10,9 @@ from app.core import analyze from app.core.llm import LLMConfig from app.tools.charts import KINDS, MAX_POINTS, validate_spec -from tests.fake_llm import (ANSWER, CODE, FAIL_CODES, MARK_ANSWER_FAIL, MARK_CODEGEN, MARK_PLAN, MARK_REACT, PLAN, +from tests.fake_llm import (ANSWER, CODE, FAIL_CODES, INJECT_ANSWER, INJECT_COLUMNS, INJECT_ORDER, INJECT_PAYLOADS, + INJECT_ROWS, + inject_csv, MARK_ANSWER_FAIL, MARK_CODEGEN, MARK_PLAN, MARK_REACT, PLAN, REACT_STEPS, WORKFLOW_STEPS, Scenario, handle, make_server, parse_model) WF_PLAN = [{"role": "user", "content": MARK_PLAN + "……"}] @@ -28,6 +30,9 @@ ("slow-2s", Scenario("slow", 2)), ("chart-bar", Scenario("chart", chart="bar")), ("chart-scatter", Scenario("chart", chart="scatter")), + ("inject-bar", Scenario("inject", chart="bar")), + ("inject-pie", Scenario("inject", chart="pie")), + ("inject-error", Scenario("inject", chart="error")), ]) def test_parse_valid(name, sc): assert parse_model(name) == sc @@ -38,6 +43,8 @@ def test_parse_valid(name, sc): "fail-418", "ok-1s", "ok@plan", "slow-2s@plan", "hang-5s@codgen", "hang-5s@", "HANG-5s", " ok", "ok ", "chart", "chart-", "chart-heatmap", "chart-Bar", "chart-bar@codegen", "chart-bar@act", "chart-bar@", "chart-bar-1s", "chart-bar ", + "inject", "inject-", "inject-heatmap", "inject-Bar", "inject-bar@codegen", "inject-bar@act", "inject-bar@", + "inject-error@final", "inject-bar-1s", "chart-error", ]) def test_parse_rejects_everything_else(name): code, body = handle({"model": name, "messages": WF_PLAN}, sleep=lambda s: pytest.fail("不该睡")) @@ -187,3 +194,77 @@ def test_real_chain_truncated_chart_warns(agent, fake_url, tmp_path): assert spec["truncated"] is True and len(spec["option"]["series"][0]["data"]) == MAX_POINTS want = f"图表「fake bar」数据点超过 {MAX_POINTS},只显示了前面的部分" assert r.warnings.count(want) == 1, r.warnings + + +# ---------------------------------------------------------------- F3 第二步:注入场景 +def test_inject_payloads_fit_and_csv_round_trips_through_pandas(): + """载荷都短于 NAME_MAX(不被截断,攻击串完整到达);csv.writer 生成的 CSV 用 pandas 读回,列名、单元格逐个等于载荷。""" + import io + + import pandas as pd + + from app.tools.charts import NAME_MAX + assert all(len(v) < NAME_MAX for v in INJECT_PAYLOADS.values()) + df = pd.read_csv(io.StringIO(inject_csv())) + assert list(df.columns) == INJECT_COLUMNS + assert df.values.tolist() == INJECT_ROWS + assert set(df.iloc[:, 0]) == set(df.iloc[:, 1]) == set(INJECT_PAYLOADS.values()) + + +def test_inject_scenario_only_changes_code_and_answer(): + strip = lambda b: {k: v for k, v in b.items() if k not in ("id", "created", "model")} # noqa: E731 + assert strip(handle({"model": "inject-bar", "messages": WF_PLAN})[1]) == strip(handle({"model": "ok", "messages": WF_PLAN})[1]) + ok_answer = [{"role": "user", "content": "根据分析结果,用中文简洁、直接地回答用户问题……"}] + assert handle({"model": "inject-bar", "messages": ok_answer})[1]["choices"][0]["message"]["content"] == INJECT_ANSWER + assert handle({"model": "inject-bar", "messages": RE_FINAL})[1]["choices"][0]["message"]["content"] == INJECT_ANSWER + fail = [{"role": "user", "content": MARK_ANSWER_FAIL}] + assert strip(handle({"model": "inject-pie", "messages": fail})[1]) == strip(handle({"model": "ok", "messages": fail})[1]) + code = handle({"model": "inject-bar", "messages": WF_CODEGEN})[1]["choices"][0]["message"]["content"] + assert code.count("chart('bar'") == 2 and repr(INJECT_PAYLOADS["P1"]) in code and CODE in code + code = handle({"model": "inject-pie", "messages": WF_CODEGEN})[1]["choices"][0]["message"]["content"] + assert code.count("chart(") == 1 + args = handle({"model": "inject-error", "messages": RE_ACT})[1]["choices"][0]["message"]["tool_calls"][0] + assert f"x={INJECT_PAYLOADS['P1']!r}" in __import__("json").loads(args["function"]["arguments"])["code"] + + +@pytest.fixture +def inject_path(tmp_path): + p = tmp_path / "inject.csv" + p.write_text(inject_csv(), encoding="utf-8") + return str(p) + + +@pytest.mark.parametrize("agent", ["workflow", "react"]) +@pytest.mark.parametrize("kind", KINDS) +def test_real_chain_inject_kind(agent, kind, fake_url, inject_path): + """inject- 走真链路:载荷经真 builder 进标题、轴名、类目(饼图扇区名)、系列名两种来源(列名 / 单元格值),结论含载荷。""" + P = INJECT_PAYLOADS + r = analyze(inject_path, "画个图", agent=agent, llm_config=cfg(fake_url, f"inject-{kind}")) + assert r.ok and r.result == 5, (r.error_kind, r.error) + assert r.answer == INJECT_ANSWER and all(P[k] in r.answer for k in ("P1", "P2", "P3")) + specs = [c["option"] for c in r.charts if c.get("option")] + assert all(s["kind"] == kind and validate_spec(s) == [] for s in specs) + assert specs[0]["option"]["title"]["text"] == P["P1"] + if kind == "pie": + assert len(specs) == 1 + assert [d["name"] for d in specs[0]["option"]["series"][0]["data"]] == [P[k] for k in INJECT_ORDER] + return + assert len(specs) == 2 + wide, long_ = (s["option"] for s in specs) + assert long_["title"]["text"] == f"{P['P4']} {P['P5']}" + assert [s["name"] for s in wide["series"]] == ([P["P2"], P["P3"]]) # 列名 → series.name + assert [s["name"] for s in long_["series"]] == [P[k] for k in INJECT_ORDER[1:] + INJECT_ORDER[:1]] # 单元格值 → series.name + if kind == "scatter": + assert wide["xAxis"]["name"] == P["P2"] and long_["yAxis"]["name"] == P["P3"] + else: + assert wide["xAxis"]["data"] == [P[k] for k in INJECT_ORDER] and wide["xAxis"]["name"] == P["P4"] + assert long_["yAxis"]["name"] == P["P2"] + + +@pytest.mark.parametrize("agent", ["workflow", "react"]) +def test_real_chain_inject_error_reaches_warnings(agent, fake_url, inject_path): + """inject-error:chart() 传不存在的列名 P1,没有图,chart_error 带着载荷进 result.warnings(可用列也是载荷)。""" + r = analyze(inject_path, "画个图", agent=agent, llm_config=cfg(fake_url, "inject-error")) + assert r.ok and not [c for c in r.charts if c.get("option")], (r.error_kind, r.error) + w = [x for x in r.warnings if x.startswith("图表未生成:")] + assert len(w) == 1 and INJECT_PAYLOADS["P1"] in w[0] and all(c in w[0] for c in INJECT_COLUMNS), r.warnings diff --git a/ui/web/e2e/f3-inject.spec.ts b/ui/web/e2e/f3-inject.spec.ts new file mode 100644 index 0000000..36ecd9c --- /dev/null +++ b/ui/web/e2e/f3-inject.spec.ts @@ -0,0 +1,255 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; +import { writeFileSync } from "node:fs"; +import { E2E_TOKEN } from "../playwright.config"; +import inject from "../src/generated/inject.json" with { type: "json" }; + +// F3 第二步:注入实测(THREAT_MODEL §2.3)。后端托管 dist、带 CSP、canvas renderer,走真实产品路径。 +// 载荷和 CSV 都来自 inject.json(tests/fake_llm.py 的 INJECT_PAYLOADS + csv.writer),这里不手拼。 +// 三轮:inject-bar(宽表 + 长表两张图)、inject-pie、inject-error(无图,chart_error 进 warnings)。 +// canvas 上画了什么靠包一层 fillText / strokeText 记录(原样转调,不改行为);P4、P5 按渲染位置判"解析 / 未解析"。 +const P = inject.payloads; +const auth = { Authorization: `Bearer ${E2E_TOKEN}` }; + +type Draw = { m: string; text: string; x: number; y: number; font: string; align: string; chart: number; cw: number; ch: number; phase: string }; +type Win = { __draws: Draw[]; __phase: string; __armed: boolean; __added: string[]; __badAttrs: string[]; __csp: string[]; __pwned?: unknown }; + +async function setScenario(page: Page, model: string) { + const r = await page.request.put("/settings", { headers: auth, data: { model } }); + expect((await r.json()).model).toBe(model); +} + +// 连续两次截图逐字节相同才算稳定(入场动画、tooltip 过渡都会让单次截图时机不确定);不改产品的 animation。 +async function stableShot(el: Locator, label: string): Promise { + let prev = await el.screenshot(); + for (let i = 0; i < 30; i++) { + await el.page().waitForTimeout(150); + const cur = await el.screenshot(); + if (cur.equals(prev)) return cur; + prev = cur; + } + writeFileSync(test.info().outputPath(`unstable-${label}.png`), prev); + throw new Error(`画面 30 次内没有稳定:${label}`); +} + +// 渲染位置分类(echarts 6.1.0 默认布局,由本测试的记录确认):标题是 bold 18px;tooltip 是悬停阶段新出现的绘制; +// 饼图:左侧竖排是图例,其余是扇区标签;直角坐标系:底部是图例,align=center 且在下半部是类目轴标签, +// align=right 是 y 轴刻度数字,剩下的是轴名。 +type Pos = "title" | "legend" | "xAxisLabel" | "axisName" | "pieLabel" | "tooltip" | "yTick"; +function posOf(d: Draw, pie: boolean): Pos { + if (d.font.startsWith("bold")) return "title"; + if (pie) return d.x < 100 && d.align === "left" ? "legend" : "pieLabel"; + if (d.y > d.ch - 30) return "legend"; + if (d.align === "right") return "yTick"; + if (d.align === "center" && d.y > d.ch * 0.6) return "xAxisLabel"; + return "axisName"; +} + +test("注入:图表各位置、tooltip、警告、结论、代码面板都不执行、不成元素;P4/P5 按位置判定", async ({ page, baseURL }) => { + test.setTimeout(180_000); + await page.setViewportSize({ width: 1600, height: 1000 }); // .view 最宽 1200px,图宽到顶;类目标签隔一个画一个(见 INJECT_ORDER) + const origin = new URL(baseURL!).origin; + const external: string[] = []; + const sameOriginAssets: string[] = []; + const dialogs: string[] = []; + page.on("request", (req) => { + const u = new URL(req.url()); + if (u.protocol === "data:" || u.protocol === "blob:") return; + if (u.origin !== origin) external.push(req.url()); + else if (u.pathname.startsWith("/assets/")) sameOriginAssets.push(u.pathname); + }); + page.on("dialog", (d) => { + dialogs.push(d.message()); + void d.dismiss(); + }); + await page.addInitScript(() => { + const w = window as unknown as Win; + w.__draws = []; + w.__phase = "static"; + w.__added = []; + w.__armed = false; // index.html 自己的 ", + "{x|富文本}", + "\">", + "javascript:window.__pwned=3" + ], + "boundaryGap": true + }, + "yAxis": { + "type": "value", + "name": "" + }, + "series": [ + { + "name": "\">", + "type": "bar", + "data": [ + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "javascript:window.__pwned=3", + "type": "bar", + "data": [ + 1, + 2, + 3, + 4, + 5 + ] + } + ], + "color": [ + "#1F6FB2", + "#159A8C", + "#E2843B", + "#C0392B", + "#7E57C2", + "#3A7D44" + ], + "title": { + "text": "", + "left": "center" + } + } + } + }, + { + "id": "inject-bar-series", + "spec": { + "v": 1, + "kind": "bar", + "truncated": false, + "option": { + "tooltip": { + "trigger": "axis" + }, + "legend": { + "show": true, + "top": "bottom" + }, + "grid": { + "left": "3%", + "right": "4%", + "top": 60, + "bottom": 50, + "containLabel": true + }, + "xAxis": { + "type": "category", + "name": "{a}{b}{c}", + "data": [ + "{a}{b}{c}", + "", + "{x|富文本}", + "\">", + "javascript:window.__pwned=3" + ], + "boundaryGap": true + }, + "yAxis": { + "type": "value", + "name": "\">" + }, + "series": [ + { + "name": "", + "type": "bar", + "data": [ + 2.0, + null, + null, + null, + null + ] + }, + { + "name": "{x|富文本}", + "type": "bar", + "data": [ + null, + 2.0, + null, + null, + null + ] + }, + { + "name": "\">", + "type": "bar", + "data": [ + null, + null, + 2.0, + null, + null + ] + }, + { + "name": "javascript:window.__pwned=3", + "type": "bar", + "data": [ + null, + null, + null, + 2.0, + null + ] + }, + { + "name": "{a}{b}{c}", + "type": "bar", + "data": [ + null, + null, + null, + null, + 2.0 + ] + } + ], + "color": [ + "#1F6FB2", + "#159A8C", + "#E2843B", + "#C0392B", + "#7E57C2", + "#3A7D44" + ], + "title": { + "text": "{a}{b}{c} {x|富文本}", + "left": "center" + } + } + } + }, + { + "id": "inject-pie", + "spec": { + "v": 1, + "kind": "pie", + "truncated": false, + "option": { + "tooltip": { + "trigger": "item" + }, + "legend": { + "show": true, + "type": "scroll", + "orient": "vertical", + "left": "left", + "top": "middle" + }, + "series": [ + { + "name": "\">", + "type": "pie", + "radius": [ + "0%", + "65%" + ], + "center": [ + "60%", + "55%" + ], + "data": [ + { + "name": "{a}{b}{c}", + "value": 2 + }, + { + "name": "", + "value": 2 + }, + { + "name": "{x|富文本}", + "value": 2 + }, + { + "name": "\">", + "value": 2 + }, + { + "name": "javascript:window.__pwned=3", + "value": 2 + } + ] + } + ], + "color": [ + "#1F6FB2", + "#159A8C", + "#E2843B", + "#C0392B", + "#7E57C2", + "#3A7D44" + ], + "title": { + "text": "", + "left": "center" + } + } + } } ] } diff --git a/ui/web/src/generated/inject.json b/ui/web/src/generated/inject.json new file mode 100644 index 0000000..6f99721 --- /dev/null +++ b/ui/web/src/generated/inject.json @@ -0,0 +1,18 @@ +{ + "_generated_by": "scripts/gen_ui_types.py", + "payloads": { + "P1": "", + "P2": "\">", + "P3": "javascript:window.__pwned=3", + "P4": "{a}{b}{c}", + "P5": "{x|富文本}" + }, + "order": [ + "P4", + "P1", + "P5", + "P2", + "P3" + ], + "csv": "{a}{b}{c},{x|富文本},\"\"\">\",javascript:window.__pwned=3\n{a}{b}{c},\"\",2,1\n\"\",{x|富文本},2,2\n{x|富文本},\"\"\">\",2,3\n\"\"\">\",javascript:window.__pwned=3,2,4\njavascript:window.__pwned=3,{a}{b}{c},2,5\n" +}