new sandbox strategy

In this commit we switched the sandbox strategy to use iframe's
sandbox attribute. Indeed we do not need delegate event anymore,
but need to add some styles into the iframe.
The details were documented in the sandbox part of internal design.
This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent df990cd731
commit efa7a8fa1b
6 changed files with 102 additions and 28 deletions

View File

@@ -18,7 +18,6 @@ rrweb 主要由 3 部分组成:
- 处理跨域请求错误 - 处理跨域请求错误
- 转移至 web worker 中执行 - 转移至 web worker 中执行
- 实现传输数据压缩 - 实现传输数据压缩
- 移除 inline script
- 验证移动端录制效果 - 验证移动端录制效果
- rrweb-player - rrweb-player
- 改进播放器 UI 样式 - 改进播放器 UI 样式
@@ -37,6 +36,7 @@ rrweb 主要由 3 部分组成:
- [序列化](./docs/serialization.md) - [序列化](./docs/serialization.md)
- [增量快照](./docs/observer.md) - [增量快照](./docs/observer.md)
- [回放](./docs/replay.md) - [回放](./docs/replay.md)
- [沙盒](./docs/sandbox.md)
## Contribute Guide ## Contribute Guide

45
docs/sandbox.md Normal file
View File

@@ -0,0 +1,45 @@
# 沙盒
在[序列化设计](./serialization.md)中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript在重建快照的过程中我们将所有 `script` 标签改写为 `noscript` 标签解决了部分问题。但仍有一些脚本化的行为是不包含在 `script` 标签中的,例如 HTML 中的 inline script、表单提交等。
脚本化的行为多种多样,如果仅过滤已知场景难免有所疏漏,而一旦有脚本被执行就可能造成不可逆的非预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制。
## iframe sandbox
我们在重建快照时将被录制的 DOM 重建在一个 `iframe` 元素中,通过设置它的 `sandbox` 属性,我们可以禁止以下行为:
- 表单提交
- `window.open` 等弹出窗
- JS 脚本(包含 inline event handler 和 `<URL>`
这与我们的预期是相符的,尤其是对 JS 脚本的处理相比自行实现会更加安全、可靠。
## 避免链接跳转
当点击 a 元素链接时默认事件为跳转至它的 href 属性对应的 URL。在重放时我们会通过重建跳转后页面 DOM 的方式保证视觉上的正确重放,而原本的跳转则应该被禁止执行。
通常我们会通过事件代理捕获所有的 a 元素点击事件,再通过 `event.preventDefault()` 禁用默认事件。但当我们将回放页面放在沙盒内时,所有的 event handler 都将不被执行,我们也就无法实现事件代理。
重新查看我们回放交互事件增量快照的实现,我们会发现其实 `click` 事件是可以不被重放的。因为在禁用 JS 的情况下点击行为并不会产生视觉上的影响,也无需被感知。
不过为了优化回放的效果,我们可以在点击时给模拟的鼠标元素添加特殊的动画效果,用来提示观看者此处发生了一次点击。
## iframe 样式设置
由于我们将 DOM 重建在 iframe 中,所以我们无法通过父页面的 CSS 样式表影响 iframe 中的元素。但是在不允许 JS 脚本执行的情况下 `noscript` 标签会被显示,而我们希望将其隐藏,就需要动态的向 iframe 中添加样式,示例代码如下:
```typescript
const injectStyleRules: string[] = [
'iframe { background: #f1f3f5 }',
'noscript { display: none !important; }',
];
const styleEl = document.createElement('style');
const { documentElement, head } = this.iframe.contentDocument!;
documentElement!.insertBefore(styleEl, head);
for (let idx = 0; idx < injectStyleRules.length; idx++) {
(styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
}
```
需要注意的是这个插入的 style 元素在被录制页面中并不存在,所以我们不能将其序列化,否则 `id -> Node` 的映射将出现错误。

View File

@@ -53,8 +53,7 @@
"typescript": "^3.1.6" "typescript": "^3.1.6"
}, },
"dependencies": { "dependencies": {
"delegated-events": "git+https://git@github.com/rrweb-io/delegated-events.git",
"mitt": "^1.1.3", "mitt": "^1.1.3",
"rrweb-snapshot": "^0.6.6" "rrweb-snapshot": "^0.6.8"
} }
} }

View File

@@ -1,6 +1,5 @@
import { rebuild, buildNodeWithSN } from 'rrweb-snapshot'; import { rebuild, buildNodeWithSN } from 'rrweb-snapshot';
import * as mittProxy from 'mitt'; import * as mittProxy from 'mitt';
import { on, off } from 'delegated-events';
import Timer from './timer'; import Timer from './timer';
import { import {
EventType, EventType,
@@ -18,6 +17,7 @@ import {
actionWithDelay, actionWithDelay,
} from '../types'; } from '../types';
import { mirror } from '../utils'; import { mirror } from '../utils';
import injectStyleRules from './styles/inject-style';
import './styles/style.css'; import './styles/style.css';
// https://github.com/rollup/rollup/issues/1267#issuecomment-296395734 // https://github.com/rollup/rollup/issues/1267#issuecomment-296395734
@@ -138,6 +138,7 @@ export class Replayer {
this.wrapper.appendChild(this.mouse); this.wrapper.appendChild(this.mouse);
this.iframe = document.createElement('iframe'); this.iframe = document.createElement('iframe');
this.iframe.setAttribute('sandbox', 'allow-same-origin');
this.wrapper.appendChild(this.iframe); this.wrapper.appendChild(this.iframe);
} }
@@ -208,21 +209,13 @@ export class Replayer {
event: fullSnapshotEvent & { timestamp: number }, event: fullSnapshotEvent & { timestamp: number },
) { ) {
mirror.map = rebuild(event.data.node, this.iframe.contentDocument!)[1]; mirror.map = rebuild(event.data.node, this.iframe.contentDocument!)[1];
const styleEl = document.createElement('style');
const { documentElement, head } = this.iframe.contentDocument!;
documentElement!.insertBefore(styleEl, head);
for (let idx = 0; idx < injectStyleRules.length; idx++) {
(styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
}
this.waitForStylesheetLoad(); this.waitForStylesheetLoad();
// avoid form submit to refresh the iframe
off('submit', 'form', this.preventDefault, {
document: this.iframe.contentDocument!,
});
on('submit', 'form', this.preventDefault, {
document: this.iframe.contentDocument!,
});
// avoid a link click to refresh the iframe
off('click', 'a', this.preventDefault, {
document: this.iframe.contentDocument!,
});
on('click', 'a', this.preventDefault, {
document: this.iframe.contentDocument!,
});
} }
/** /**
@@ -262,10 +255,6 @@ export class Replayer {
} }
} }
private preventDefault(evt: Event) {
evt.preventDefault();
}
private applyIncremental(d: incrementalData, isSync: boolean) { private applyIncremental(d: incrementalData, isSync: boolean) {
switch (d.source) { switch (d.source) {
case IncrementalSource.Mutation: { case IncrementalSource.Mutation: {
@@ -377,13 +366,24 @@ export class Replayer {
} }
const event = new Event(MouseInteractions[d.type].toLowerCase()); const event = new Event(MouseInteractions[d.type].toLowerCase());
const target = (mirror.getNode(d.id) as Node) as HTMLElement; const target = (mirror.getNode(d.id) as Node) as HTMLElement;
target.dispatchEvent(event);
if (d.type === MouseInteractions.Blur) { if (d.type === MouseInteractions.Blur) {
target.blur(); target.blur();
} else if (d.type === MouseInteractions.Click) { } else if (d.type === MouseInteractions.Click) {
target.click(); /**
* Click has no visual impact when replaying and may
* trigger navigation when apply to an <a> link.
* So we will not call click(), instead we add an
* animation to the mouse element which indicate user
* clicked at this moment.
*/
this.mouse.classList.remove('active');
// tslint:disable-next-line
void this.mouse.offsetWidth;
this.mouse.classList.add('active');
} else if (d.type === MouseInteractions.Focus) { } else if (d.type === MouseInteractions.Focus) {
target.focus(); target.focus();
} else {
target.dispatchEvent(event);
} }
break; break;
} }

View File

@@ -0,0 +1,6 @@
const rules: string[] = [
'iframe { background: #ccc }',
'noscript { display: none !important; }',
];
export default rules;

View File

@@ -3,8 +3,12 @@
} }
.replayer-mouse { .replayer-mouse {
position: absolute; position: absolute;
width: 1px; width: 20px;
height: 1px; height: 20px;
background-size: contain;
background-position: center center;
background-repeat: no-repeat;
background-image: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg==');
} }
.replayer-mouse::after { .replayer-mouse::after {
content: ''; content: '';
@@ -12,7 +16,27 @@
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 10px; border-radius: 10px;
background: thistle; background: rgb(73, 80, 246);
transform: translate(-10px, -10px); transform: translate(-10px, -10px);
opacity: 0.5; opacity: 0.3;
}
.replayer-mouse.active::after {
animation: click 0.3s ease-in-out 1;
}
@keyframes click {
0% {
opacity: 0.3;
width: 20px;
height: 20px;
border-radius: 10px;
transform: translate(-10px, -10px);
}
50% {
opacity: 0.5;
width: 10px;
height: 10px;
border-radius: 5px;
transform: translate(-5px, -5px);
}
} }