Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion docs/DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down Expand Up @@ -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-<kind>`:写代码的那一步,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=` 这几个字(`&lt;img src=x onerror=&quot;…`),所以改成按结构判。
- `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-<kind>` 里多调一次。原因:`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_<n>`,起点为 `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 不变)。
39 changes: 34 additions & 5 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 步实现) | 生效 |
Expand All @@ -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 `<img src=x onerror="window.__pwned=1">`、P2 `"><svg onload="window.__pwned=2">`、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 列 '<P1>' 不存在;可用列:<P4>、<P5>、<P2>、<P3>` 进 `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>`,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)
| # | 威胁 | 例子 |
Expand Down
4 changes: 3 additions & 1 deletion docs/roadmap/phase5-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 再测。

Expand Down
Loading
Loading