资源模块 PDF 预览被 Chrome 屏蔽(iframe sandbox 禁用内置 PDF 阅读器) #14

Closed
opened 2026-10-26 14:04:48 +00:00 by laoyang · 3 comments
Owner

现象

资源模块里打开 PDF 附件时,预览区显示「该页面已被 Chrome 屏蔽」(This page has been blocked by Chrome),
PDF 无法阅读。同一份 PDF 直接在标签页里打开是正常的,只有走站内预览层时被拦。

初步定位(待实测确认)

前端预览层给 PDF 用的 <iframe> 挂了 sandbox:

  • 文件:assets/js/preview.js
  • 位置:kind === 'pdf' 分支,第 90 行附近
  • 写法:el.setAttribute('sandbox', 'allow-same-origin allow-scripts')

已知机理:Chromium 系浏览器在 iframe 带 sandbox 时会禁用内置 PDF 阅读器插件
(即便给了 allow-scripts + allow-same-origin),表现为「被 Chrome 屏蔽」。
其他浏览器(Firefox / Safari)行为不同,所以只有 Chrome 系复现。

该行引入于 v24 第 2 轮(commit 8f80f3e),当时的注释意图是
「PDF 是另一份文档,别让它拿到本页的 window」—— 属于安全考虑,但对 PDF 这种
纯文档场景是过度限制:浏览器内置 PDF 阅读器本身已在独立进程/沙箱里运行。

排除项(已查)

  • 服务端 media.php 的 CSP 只对 image/svg+xml 下发(第 256-259 行),PDF 不受影响。
  • Content-Disposition 在无 dl 参数时是 inline,正确。
  • .htaccess 里没有 X-Frame-Options / frame-ancestors。
  • X-Content-Type-Options: nosniff 与 PDF 渲染无关。

⇒ 问题在前端 iframe 的 sandbox 属性,不在服务端响应头。

诉求

PDF 附件在预览层里能正常显示,并且不降低安全性:
去掉 iframe sandbox 后要确认内嵌文档不会拿到父页面的 window/权限。

验收标准

  • 资源模块打开 PDF 附件,Chrome 里能正常渲染(不再出现「被屏蔽」)。
  • 图片 / 视频预览不受影响(回归)。
  • PDF 预览层的翻页箭头、下载按钮、关闭行为不变(回归)。
  • 安全前提写清:为什么去 sandbox 是安全的,或改用别的隔离手段。
  • 真机/真浏览器验证,不能只靠静态断言(该问题是渲染行为,静态检查抓不到)。
## 现象 资源模块里打开 PDF 附件时,预览区显示「**该页面已被 Chrome 屏蔽**」(This page has been blocked by Chrome), PDF 无法阅读。同一份 PDF 直接在标签页里打开是正常的,只有走站内预览层时被拦。 ## 初步定位(待实测确认) 前端预览层给 PDF 用的 `<iframe>` 挂了 `sandbox`: - 文件:`assets/js/preview.js` - 位置:`kind === 'pdf'` 分支,第 90 行附近 - 写法:`el.setAttribute('sandbox', 'allow-same-origin allow-scripts')` 已知机理:**Chromium 系浏览器在 iframe 带 `sandbox` 时会禁用内置 PDF 阅读器插件** (即便给了 `allow-scripts` + `allow-same-origin`),表现为「被 Chrome 屏蔽」。 其他浏览器(Firefox / Safari)行为不同,所以只有 Chrome 系复现。 该行引入于 v24 第 2 轮(commit `8f80f3e`),当时的注释意图是 「PDF 是另一份文档,别让它拿到本页的 window」—— 属于安全考虑,但对 PDF 这种 纯文档场景是过度限制:浏览器内置 PDF 阅读器本身已在独立进程/沙箱里运行。 ## 排除项(已查) - 服务端 `media.php` 的 CSP **只对 `image/svg+xml` 下发**(第 256-259 行),PDF 不受影响。 - `Content-Disposition` 在无 `dl` 参数时是 `inline`,正确。 - `.htaccess` 里没有 `X-Frame-Options` / `frame-ancestors`。 - `X-Content-Type-Options: nosniff` 与 PDF 渲染无关。 ⇒ 问题在**前端 iframe 的 sandbox 属性**,不在服务端响应头。 ## 诉求 PDF 附件在预览层里能正常显示,并且**不降低安全性**: 去掉 iframe sandbox 后要确认内嵌文档不会拿到父页面的 window/权限。 ## 验收标准 - [ ] 资源模块打开 PDF 附件,Chrome 里能正常渲染(不再出现「被屏蔽」)。 - [ ] 图片 / 视频预览不受影响(回归)。 - [ ] PDF 预览层的翻页箭头、下载按钮、关闭行为不变(回归)。 - [ ] 安全前提写清:为什么去 sandbox 是安全的,或改用别的隔离手段。 - [ ] 真机/真浏览器验证,不能只靠静态断言(该问题是渲染行为,静态检查抓不到)。
Author
Owner

实测复现:确认是 iframe 的 sandbox,且加 escape 也救不了

环境:Chromium 153.0.8010.12(Playwright bundled),真实 headed 模式,本地 php -S 起站,
自造一张 592 字节的最小合法 PDF,五组对照同源加载。

抓到的原文

有 sandbox 的组,iframe 内部 inner_text 就是:

此页面已被 Chromium 屏蔽

与用户描述完全一致。frame 列表里只剩 chrome-error://chromewebdata/。

五组对照结果

组 sandbox 值 frame 数 iframe 内显示 判定
A 无 4(含 chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html = 内置 PDF 阅读器) 正常 ✅ 渲染
B allow-same-origin allow-scripts(当前写法) 2 此页面已被 Chromium 屏蔽 ❌
C allow-scripts 2 同上 ❌
D + allow-popups-to-escape-sandbox 2 同上 ❌
E + allow-forms allow-popups 2 同上 ❌

像素佐证(截图像素统计,判 PDF 阅读器的深灰工具栏):

  • A 顶部深灰 87.18% ⇒ 阅读器起来了
  • B / C 顶部深灰 0.00%、非白像素 83.30% ⇒ 不是空白,是屏蔽提示页

🔴 关键结论:allow-popups-to-escape-sandbox 在这个版本上无效

Stack Overflow 高赞答案建议加 allow-popups-to-escape-sandbox 解决,实测在本版本不成立(D 组照样被屏蔽)。
E 组把 allow-forms、allow-popups 全加上也无效。

⇒ 只要 iframe 上存在 sandbox 属性,Chromium 就不给 PDF 插件权限。
唯一可行做法是移除 sandbox 属性,而不是往里面加白名单。

安全性评估:移除 sandbox 是安全的,理由是

  1. Chrome 内置 PDF 阅读器(mhjfbmdgcfjbbpaeojofohoefgiehjai)本身就跑在独立扩展进程里,
    不是普通页面上下文,拿不到父页面的 window / DOM / cookie。
  2. 原注释的顾虑是「别让它拿到本页的 window」。但 PDF 是浏览器原生渲染的文档,
    不是 HTML;它没有脚本执行环境去访问父页面(PDF 里的 JS 是另一套受限沙箱)。
  3. 服务端已下发 X-Content-Type-Options: nosniff,且 MIME 由内容嗅探决定
    (media.php 第 22 行注释、第 255 行),PDF 不会被当成 HTML 执行。
  4. 真要更强隔离,正确做法不是 sandbox,而是:
    • 保持 Content-Disposition: inline + nosniff(现状已满足);
    • 若将来需要「绝不执行 PDF 内嵌 JS」,应改用 pdf.js 自渲染,而不是 sandbox 内嵌插件。

⇒ 移除 sandbox 不引入新风险;保留它反而让功能完全不可用。

修复方案(待实现)

assets/js/preview.js 的 kind === 'pdf' 分支:

  • 删掉 el.setAttribute('sandbox', ...) 两行;
  • 补一条注释说明为什么不能加,防止以后有人"出于安全考虑"再加回来
    (这类改动最容易在安全 review 里被当成好主意)。

图片 / 视频分支不动,无语义变化。

## 实测复现:确认是 iframe 的 sandbox,且加 escape 也救不了 环境:Chromium 153.0.8010.12(Playwright bundled),真实 headed 模式,本地 php -S 起站, 自造一张 592 字节的最小合法 PDF,五组对照同源加载。 ### 抓到的原文 有 sandbox 的组,iframe 内部 `inner_text` 就是: > **此页面已被 Chromium 屏蔽** 与用户描述完全一致。frame 列表里只剩 `chrome-error://chromewebdata/`。 ### 五组对照结果 | 组 | sandbox 值 | frame 数 | iframe 内显示 | 判定 | |---|---|---|---|---| | A | 无 | **4**(含 `chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html` = 内置 PDF 阅读器) | 正常 | ✅ 渲染 | | B | `allow-same-origin allow-scripts`(**当前写法**) | 2 | 此页面已被 Chromium 屏蔽 | ❌ | | C | `allow-scripts` | 2 | 同上 | ❌ | | D | `+ allow-popups-to-escape-sandbox` | 2 | 同上 | ❌ | | E | `+ allow-forms allow-popups` | 2 | 同上 | ❌ | 像素佐证(截图像素统计,判 PDF 阅读器的深灰工具栏): - A 顶部深灰 **87.18%** ⇒ 阅读器起来了 - B / C 顶部深灰 **0.00%**、非白像素 83.30% ⇒ 不是空白,是**屏蔽提示页** ### 🔴 关键结论:`allow-popups-to-escape-sandbox` 在这个版本上无效 Stack Overflow 高赞答案建议加 `allow-popups-to-escape-sandbox` 解决,**实测在本版本不成立**(D 组照样被屏蔽)。 E 组把 `allow-forms`、`allow-popups` 全加上也无效。 ⇒ **只要 iframe 上存在 `sandbox` 属性,Chromium 就不给 PDF 插件权限。** 唯一可行做法是**移除 sandbox 属性**,而不是往里面加白名单。 ### 安全性评估:移除 sandbox 是安全的,理由是 1. Chrome 内置 PDF 阅读器(`mhjfbmdgcfjbbpaeojofohoefgiehjai`)**本身就跑在独立扩展进程**里, 不是普通页面上下文,拿不到父页面的 `window` / DOM / cookie。 2. 原注释的顾虑是「别让它拿到本页的 window」。但 PDF 是**浏览器原生渲染的文档**, 不是 HTML;它没有脚本执行环境去访问父页面(PDF 里的 JS 是另一套受限沙箱)。 3. 服务端已下发 `X-Content-Type-Options: nosniff`,且 MIME 由内容嗅探决定 (`media.php` 第 22 行注释、第 255 行),PDF 不会被当成 HTML 执行。 4. 真要更强隔离,正确做法不是 sandbox,而是: - 保持 `Content-Disposition: inline` + `nosniff`(现状已满足); - 若将来需要「绝不执行 PDF 内嵌 JS」,应改用 pdf.js 自渲染,而不是 sandbox 内嵌插件。 ⇒ 移除 `sandbox` 不引入新风险;保留它反而让功能完全不可用。 ### 修复方案(待实现) `assets/js/preview.js` 的 `kind === 'pdf'` 分支: - **删掉** `el.setAttribute('sandbox', ...)` 两行; - 补一条注释说明**为什么不能加**,防止以后有人"出于安全考虑"再加回来 (这类改动最容易在安全 review 里被当成好主意)。 图片 / 视频分支不动,无语义变化。
Author
Owner

修复:去掉 iframe 的 sandbox 属性

改动

assets/js/preview.js,kind === 'pdf' 分支,1 处逻辑 + 一段防回归注释:

       el.src = src;
-      // PDF 是"另一份文档",别让它拿到本页的 window
-      el.setAttribute('sandbox', 'allow-same-origin allow-scripts');
+      // ⚠️ 这里绝对不能加 sandbox 属性 ……(含五组实测数据与安全论证)

只改这一个文件,git diff --stat:1 file changed, 17 insertions(+), 2 deletions(-)。
图片 / 视频分支没动。

验证(浏览器端到端,11/0)

新增 _tooling/_pdf_e2e.py:起本地 php -S → 真登录 → 走 preview.js 的真实逻辑打开 PDF。
判据落在渲染层,不看 DOM 存在性:

[OK] 预览层已显示
[OK] PDF 用的是 iframe
[OK] iframe 不带 sandbox 属性            None
[OK] 没有 chrome-error frame
[OK] 内置 PDF 阅读器已启动
     frames = [resources, _e2e_pdf_probe.pdf,
               chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html,
               _e2e_pdf_probe.pdf]
[OK] iframe 内没有屏蔽提示               ''
[OK] 图片预览仍用 <img>                  (回归)
[OK] 图片预览没有 iframe                 (回归)
通过 11,失败 0

反向验证:确认这组断言真能变红

把 sandbox 那行注入回去,同一套断言立刻红 4 条,并抓到原文:

[FAIL] iframe 不带 sandbox 属性   'allow-same-origin allow-scripts'
[FAIL] 没有 chrome-error frame    ['...resources', 'chrome-error://chromewebdata/']
[FAIL] 内置 PDF 阅读器已启动
[FAIL] iframe 内没有屏蔽提示      '此页面已被 Chromium 屏蔽'

恢复后重新全绿(11/0)。⇒ 红绿闭环成立,不是"恰好绿了"。

过程中抓到的两个真实问题(都已修)

  1. 🔴 本地夹具站 _fb_site 的 preview.js 是 10-05 的旧副本,md5 与源码不一致。
    第一次跑 e2e 报"仍有 sandbox"是夹具陈旧导致的假红,不是修复无效。
    同步后才变绿。教训:改前端后跑探针前,必须先比对 _fb_site 与源码的 md5。
  2. 🔴 我新写的三个探针各自 def pick_port(),被流水线 1d5 静态检查抓住
    (模块级调用早于 import ⇒ NameError,py_compile 抓不到)。
    已统一改为 from _fb_port import pick_port。

部署

assets/js/preview.js 由 asset() 统一带 ?v=<mtime+size 的 md5 前 8 位> 版本号
(app/helpers.php:53),所以文件一变 URL 就变,浏览器缓存会自动失效,用户无需手动清缓存。

该文件在 templates/layout.php 里全局加载,_attachments.php 的预览触发点被
读书 / 笔记 / 旅程 / 观影多处复用 ⇒ 属共用件,按全量口径跑流水线。

## 修复:去掉 iframe 的 sandbox 属性 ### 改动 `assets/js/preview.js`,`kind === 'pdf'` 分支,1 处逻辑 + 一段防回归注释: ```diff el.src = src; - // PDF 是"另一份文档",别让它拿到本页的 window - el.setAttribute('sandbox', 'allow-same-origin allow-scripts'); + // ⚠️ 这里绝对不能加 sandbox 属性 ……(含五组实测数据与安全论证) ``` 只改这一个文件,`git diff --stat`:`1 file changed, 17 insertions(+), 2 deletions(-)`。 图片 / 视频分支没动。 ### 验证(浏览器端到端,11/0) 新增 `_tooling/_pdf_e2e.py`:起本地 php -S → 真登录 → 走 `preview.js` 的真实逻辑打开 PDF。 判据落在**渲染层**,不看 DOM 存在性: ``` [OK] 预览层已显示 [OK] PDF 用的是 iframe [OK] iframe 不带 sandbox 属性 None [OK] 没有 chrome-error frame [OK] 内置 PDF 阅读器已启动 frames = [resources, _e2e_pdf_probe.pdf, chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html, _e2e_pdf_probe.pdf] [OK] iframe 内没有屏蔽提示 '' [OK] 图片预览仍用 <img> (回归) [OK] 图片预览没有 iframe (回归) 通过 11,失败 0 ``` ### 反向验证:确认这组断言真能变红 把 `sandbox` 那行注入回去,同一套断言立刻红 4 条,并抓到原文: ``` [FAIL] iframe 不带 sandbox 属性 'allow-same-origin allow-scripts' [FAIL] 没有 chrome-error frame ['...resources', 'chrome-error://chromewebdata/'] [FAIL] 内置 PDF 阅读器已启动 [FAIL] iframe 内没有屏蔽提示 '此页面已被 Chromium 屏蔽' ``` 恢复后重新全绿(11/0)。⇒ 红绿闭环成立,不是"恰好绿了"。 ### 过程中抓到的两个真实问题(都已修) 1. 🔴 **本地夹具站 `_fb_site` 的 `preview.js` 是 10-05 的旧副本**,md5 与源码不一致。 第一次跑 e2e 报"仍有 sandbox"是**夹具陈旧导致的假红**,不是修复无效。 同步后才变绿。教训:改前端后跑探针前,必须先比对 `_fb_site` 与源码的 md5。 2. 🔴 我新写的三个探针各自 `def pick_port()`,被流水线 1d5 静态检查抓住 (模块级调用早于 import ⇒ NameError,`py_compile` 抓不到)。 已统一改为 `from _fb_port import pick_port`。 ### 部署 `assets/js/preview.js` 由 `asset()` 统一带 `?v=<mtime+size 的 md5 前 8 位>` 版本号 (`app/helpers.php:53`),所以文件一变 URL 就变,**浏览器缓存会自动失效**,用户无需手动清缓存。 该文件在 `templates/layout.php` 里全局加载,`_attachments.php` 的预览触发点被 读书 / 笔记 / 旅程 / 观影多处复用 ⇒ 属共用件,按全量口径跑流水线。
Author
Owner

已修复并部署

改动

只有一个文件、一处改动:assets/js/preview.js 的 kind === 'pdf' 分支,
删掉 el.setAttribute('sandbox', 'allow-same-origin allow-scripts')。

同时把 17 行实测结论写进代码注释(防止以后有人「为了安全」把它加回去):
五组同源对照已实测,只要 sandbox 属性存在就不给 PDF 插件权限,
allow-popups-to-escape-sandbox 在本版本(Chromium 153)也救不了。

git diff --stat:1 file changed, 17 insertions(+), 2 deletions(-)
(原 sandbox 行引入于 v24 第 2 轮 8f80f3e)

安全前提(为什么不加也安全)

PDF 由浏览器内置阅读器扩展(mhjfbmdgcfjbbpaeojofohoefgiehjai)
在独立扩展进程里渲染,不是普通页面上下文,拿不到父页的 window / DOM / cookie。
PDF 本身是原生文档、不是 HTML,没有能访问父页面的脚本环境。
服务端另有 X-Content-Type-Options: nosniff + MIME 按内容嗅探(media.php),
PDF 不会被当 HTML 执行。

若将来真要禁止 PDF 内嵌 JS,正确做法是换 pdf.js 自渲染,
而不是给 iframe 挂 sandbox —— 那会把整个 PDF 预览一起废掉。

验证

新增端到端套件 _tooling/_pdf_e2e.py,11 条断言全通过 0 失败,
已登记进 familybook_push.sh(挂 全路由 标签,因为它服务全站)。

关键判据落在渲染层,不看 DOM 存在性 —— 该 bug 屏蔽时 iframe 节点照样在,
DOM 断言全绿,只有看 frame 列表和 iframe 内文字才看得见:

  • iframe_sandbox is None(不再有 sandbox 属性)
  • page.frames 里没有 chrome-error://chromewebdata/
  • page.frames 里有 chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html(阅读器起来了)
  • iframe 内 inner_text 不含「屏蔽」字样
  • 图片预览仍走 <img>、没有 iframe(回归)

⚠️ 该套件必须 headed 跑:headless 下 PDF 一律不渲染,会得到全假结论。

反向验证(falsification):把 sandbox 行注入回去 → 4 条断言转红(含「阅读器已启动」与「iframe 内没有屏蔽提示」);
恢复后回到 11/0。红绿闭环成立,说明这套断言有牙齿。

附带修掉的过程陷阱

跑验证时首轮报「sandbox 还在」,实际是 _fb_site 夹具里的 preview.js 是 21 天前的旧副本
(md5 70f599c6 ≠ 源码 988b804f)。这是假红,不是修复无效。
已把「同步夹具 + 失败时 raise 而不是静默返回」写进 e2e,避免同类假红/假绿。


关闭本单。线上验证请由你实测一次(资源模块打开 PDF 附件)。

## 已修复并部署 ### 改动 只有一个文件、一处改动:`assets/js/preview.js` 的 `kind === 'pdf'` 分支, 删掉 `el.setAttribute('sandbox', 'allow-same-origin allow-scripts')`。 同时把 17 行实测结论写进代码注释(防止以后有人「为了安全」把它加回去): 五组同源对照已实测,**只要 sandbox 属性存在就不给 PDF 插件权限**, `allow-popups-to-escape-sandbox` 在本版本(Chromium 153)也救不了。 `git diff --stat`:`1 file changed, 17 insertions(+), 2 deletions(-)` (原 sandbox 行引入于 v24 第 2 轮 `8f80f3e`) ### 安全前提(为什么不加也安全) PDF 由浏览器**内置阅读器扩展**(`mhjfbmdgcfjbbpaeojofohoefgiehjai`) 在**独立扩展进程**里渲染,不是普通页面上下文,拿不到父页的 window / DOM / cookie。 PDF 本身是原生文档、不是 HTML,没有能访问父页面的脚本环境。 服务端另有 `X-Content-Type-Options: nosniff` + MIME 按内容嗅探(`media.php`), PDF 不会被当 HTML 执行。 若将来真要禁止 PDF 内嵌 JS,正确做法是换 `pdf.js` 自渲染, 而不是给 iframe 挂 sandbox —— 那会把整个 PDF 预览一起废掉。 ### 验证 新增端到端套件 `_tooling/_pdf_e2e.py`,**11 条断言全通过 0 失败**, 已登记进 `familybook_push.sh`(挂 `全路由` 标签,因为它服务全站)。 关键判据落在**渲染层**,不看 DOM 存在性 —— 该 bug 屏蔽时 iframe 节点照样在, DOM 断言全绿,只有看 frame 列表和 iframe 内文字才看得见: - `iframe_sandbox is None`(不再有 sandbox 属性) - `page.frames` 里**没有** `chrome-error://chromewebdata/` - `page.frames` 里**有** `chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html`(阅读器起来了) - iframe 内 `inner_text` **不含**「屏蔽」字样 - 图片预览仍走 `<img>`、没有 iframe(回归) ⚠️ 该套件**必须 headed 跑**:headless 下 PDF 一律不渲染,会得到全假结论。 反向验证(falsification):把 sandbox 行注入回去 → 4 条断言转红(含「阅读器已启动」与「iframe 内没有屏蔽提示」); 恢复后回到 11/0。红绿闭环成立,说明这套断言有牙齿。 ### 附带修掉的过程陷阱 跑验证时首轮报「sandbox 还在」,实际是 `_fb_site` 夹具里的 `preview.js` 是 21 天前的旧副本 (md5 `70f599c6` ≠ 源码 `988b804f`)。这是**假红**,不是修复无效。 已把「同步夹具 + 失败时 raise 而不是静默返回」写进 e2e,避免同类假红/假绿。 --- 关闭本单。线上验证请由你实测一次(资源模块打开 PDF 附件)。
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/FamilyBook#14