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

fix: untainted prototype access in Angular #1633

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/tall-poems-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrweb/utils": patch
---

load unpatched versions of things from Angular zone when present
2 changes: 1 addition & 1 deletion .github/workflows/style-check.yml
Original file line number Diff line number Diff line change
@@ -40,7 +40,7 @@ jobs:
runs-on: ubuntu-latest
name: ESLint Annotation
steps:
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
name: eslint_report.json
- name: Annotate Code Linting Results
97 changes: 60 additions & 37 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -28,30 +28,47 @@ const testableMethods = {

const untaintedBasePrototype: Partial<BasePrototypeCache> = {};

/*
When angular patches things - particularly the MutationObserver -
they pass the `isNativeFunction` check
That then causes performance issues
because Angular's change detection
doesn't like sharing a mutation observer
Checking for the presence of the Zone object
on global is a good-enough proxy for Angular
to cover most cases
(you can configure zone.js to have a different name
on the global object and should then manually run rrweb
outside the Zone)
*/
export const isAngularZonePresent = (): boolean => {
return !!(globalThis as { Zone?: unknown }).Zone;
type WindowWithZone = typeof globalThis & {
Zone?: {
__symbol__?: (key: string) => string;
};
};

type WindowWithUnpatchedSymbols = typeof globalThis &
Record<string, TypeofPrototypeOwner>;

/*
Angular zone patches many things and can pass the untainted checks below, causing performance issues
Angular zone, puts the unpatched originals on the window, and the names for hose on the zone object.
So, we get the unpatched versions from the window object if they exist.
You can rename Zone, but this is a good enough proxy to avoid going to an iframe to get the untainted versions.
see: https://github.com/angular/angular/issues/26948
*/
function angularZoneUnpatchedAlternative(key: keyof BasePrototypeCache) {
const angularUnpatchedVersionSymbol = (
globalThis as WindowWithZone
)?.Zone?.__symbol__?.(key);
if (
angularUnpatchedVersionSymbol &&
(globalThis as WindowWithUnpatchedSymbols)[angularUnpatchedVersionSymbol]
) {
return (globalThis as WindowWithUnpatchedSymbols)[
angularUnpatchedVersionSymbol
];
} else {
return undefined;
}
}

export function getUntaintedPrototype<T extends keyof BasePrototypeCache>(
key: T,
): BasePrototypeCache[T] {
if (untaintedBasePrototype[key])
return untaintedBasePrototype[key] as BasePrototypeCache[T];

const defaultObj = globalThis[key] as TypeofPrototypeOwner;
const defaultObj =
angularZoneUnpatchedAlternative(key) ||
(globalThis[key] as TypeofPrototypeOwner);
const defaultPrototype = defaultObj.prototype as BasePrototypeCache[T];

// use list of testable accessors to check if the prototype is tainted
@@ -80,29 +97,35 @@ export function getUntaintedPrototype<T extends keyof BasePrototypeCache>(
),
);

if (isUntaintedAccessors && isUntaintedMethods && !isAngularZonePresent()) {
untaintedBasePrototype[key] = defaultObj.prototype as BasePrototypeCache[T];
return defaultObj.prototype as BasePrototypeCache[T];
const isUntainted = isUntaintedAccessors && isUntaintedMethods;
// we're going to default to what we do have
let impl: BasePrototypeCache[T] =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i kept the reorganization here since I did find it difficult to track what was happening previously

defaultObj.prototype as BasePrototypeCache[T];
// but if it is tainted
if (!isUntainted) {
// try to load a fresh copy from a sandbox iframe
let iframeEl: HTMLIFrameElement | undefined = undefined;
try {
iframeEl = document.createElement('iframe');
iframeEl.hidden = true;
document.body.appendChild(iframeEl);
const win = iframeEl.contentWindow;
if (win) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
const candidate = (win as any)[key].prototype as BasePrototypeCache[T];
if (candidate) {
impl = candidate;
}
}
} finally {
if (iframeEl) {
document.body.removeChild(iframeEl);
}
}
}

try {
const iframeEl = document.createElement('iframe');
document.body.appendChild(iframeEl);
const win = iframeEl.contentWindow;
if (!win) return defaultObj.prototype as BasePrototypeCache[T];

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
const untaintedObject = (win as any)[key]
.prototype as BasePrototypeCache[T];
// cleanup
document.body.removeChild(iframeEl);

if (!untaintedObject) return defaultPrototype;

return (untaintedBasePrototype[key] = untaintedObject);
} catch {
return defaultPrototype;
}
untaintedBasePrototype[key] = impl;
return impl;
}

const untaintedAccessorCache: Record<