Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"test:e2e:init": "yarn build && yarn build:apps && yarn playwright install chromium --with-deps",
"test:e2e": "playwright test --config test/e2e/playwright.local.config.ts --project chromium",
"test:e2e:bs": "node --env-file-if-exists=.env ./scripts/test/bs-wrapper.ts playwright test --config test/e2e/playwright.bs.config.ts",
"test:e2e:salesforce": "playwright test --config test/e2e/playwright.salesforce.config.ts",
"test:e2e:ci": "yarn test:e2e:init && yarn test:e2e",
"test:e2e:ci:bs": "yarn build && yarn build:apps && yarn test:e2e:bs",
"test:compat:tsc": "node scripts/check-typescript-compatibility.ts",
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/browser/addEventListener.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,31 @@ describe('addEventListener', () => {
expect(customEventTarget.removeEventListener).toHaveBeenCalled()
})

it('does not break stop() when removeEventListener is missing', () => {
const addEventListenerSpy = jasmine.createSpy()
const customEventTarget = {
addEventListener: addEventListenerSpy,
} as unknown as HTMLElement

const { stop } = addEventListener({ allowUntrustedEvents: false }, customEventTarget, 'change', noop)

expect(addEventListenerSpy).toHaveBeenCalled()
expect(stop).not.toThrow()
})

it('skips registration when addEventListener is missing', () => {
const listener = jasmine.createSpy()
const removeEventListenerSpy = jasmine.createSpy()
const customEventTarget = {
removeEventListener: removeEventListenerSpy,
} as unknown as HTMLElement

const { stop } = addEventListener({ allowUntrustedEvents: false }, customEventTarget, 'change', listener)

expect(stop).not.toThrow()
expect(removeEventListenerSpy).not.toHaveBeenCalled()
})

describe('Untrusted event', () => {
beforeEach(() => {
configuration = { allowUntrustedEvents: false } as Configuration
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/browser/addEventListener.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { monitor } from '../tools/monitor'
import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue'
import { noop } from '../tools/utils/functionUtils'
import type { CookieStore, CookieStoreEventMap, VisualViewport, VisualViewportEventMap } from './browser.types'

export type TrustableEvent<E extends Event = Event> = E & { __ddIsTrusted?: boolean }
Expand Down Expand Up @@ -132,10 +133,20 @@ export function addEventListeners<Target extends EventTarget, EventName extends
window.EventTarget && eventTarget instanceof EventTarget ? window.EventTarget.prototype : eventTarget

const add = getZoneJsOriginalValue(listenerTarget, 'addEventListener')
if (typeof add !== 'function') {
return {
stop: noop,
}
}

eventNames.forEach((eventName) => add.call(eventTarget, eventName, listenerWithMonitor, options))

function stop() {
const remove = getZoneJsOriginalValue(listenerTarget, 'removeEventListener')
if (typeof remove !== 'function') {
return
}

eventNames.forEach((eventName) => remove.call(eventTarget, eventName, listenerWithMonitor, options))
}

Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/domain/report/reportObservable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,18 @@ describe('report observable', () => {
csp: { disposition: 'enforce' },
})
})

it(`should ignore ${RawReportType.cspViolation} when the environment rejects the event listener`, () => {
;(EventTarget.prototype.addEventListener as jasmine.Spy).and.callFake((type: string) => {
if (type === 'securitypolicyviolation') {
throw new Error('unsupported event listener')
}
})

expect(() => {
consoleSubscription = initReportObservable(configuration, [RawReportType.cspViolation]).subscribe(notifyReport)
}).not.toThrow()

expect(notifyReport).not.toHaveBeenCalled()
})
})
12 changes: 8 additions & 4 deletions packages/core/src/domain/report/reportObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ function createReportObservable(reportTypes: ReportType[]) {

function createCspViolationReportObservable(configuration: Configuration) {
return new Observable<RawReportError>((observable) => {
const { stop } = addEventListener(configuration, document, DOM_EVENT.SECURITY_POLICY_VIOLATION, (event) => {
observable.notify(buildRawReportErrorFromCspViolation(event))
})
try {
const { stop } = addEventListener(configuration, document, DOM_EVENT.SECURITY_POLICY_VIOLATION, (event) => {
observable.notify(buildRawReportErrorFromCspViolation(event))
})

return stop
return stop
} catch {
return
}
})
}

Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/tools/globalObject.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { getGlobalObject } from './globalObject'

describe('getGlobalObject', () => {
it('returns self when globalThis is unavailable', () => {
const globalThisDescriptor = Object.getOwnPropertyDescriptor(window, 'globalThis')
const selfDescriptor = Object.getOwnPropertyDescriptor(window, 'self')

if (!globalThisDescriptor?.configurable) {
pending('globalThis descriptor is not configurable in this environment')
}
if (!selfDescriptor?.configurable) {
pending('self descriptor is not configurable in this environment')
}

const fakeSelf = { dd: 'sandbox-global' }

Object.defineProperty(window, 'globalThis', {
value: undefined,
configurable: true,
writable: true,
})
Object.defineProperty(window, 'self', {
value: fakeSelf,
configurable: true,
writable: true,
})

try {
expect(getGlobalObject()).toBe(fakeSelf)
} finally {
Object.defineProperty(window, 'globalThis', globalThisDescriptor!)
Object.defineProperty(window, 'self', selfDescriptor!)
}
})

it('returns self without relying on the Object.prototype fallback when globalThis is unavailable', () => {
const globalThisDescriptor = Object.getOwnPropertyDescriptor(window, 'globalThis')
const selfDescriptor = Object.getOwnPropertyDescriptor(window, 'self')

if (!globalThisDescriptor?.configurable) {
pending('globalThis descriptor is not configurable in this environment')
}
if (!selfDescriptor?.configurable) {
pending('self descriptor is not configurable in this environment')
}

const fakeSelf = { dd: 'sandbox-global' }

Object.defineProperty(window, 'globalThis', {
value: undefined,
configurable: true,
writable: true,
})
Object.defineProperty(window, 'self', {
value: fakeSelf,
configurable: true,
writable: true,
})

const definePropertySpy = spyOn(Object, 'defineProperty').and.callThrough()

try {
expect(getGlobalObject()).toBe(fakeSelf)
expect(definePropertySpy).not.toHaveBeenCalledWith(Object.prototype, '_dd_temp_', jasmine.any(Object))
} finally {
Object.defineProperty(window, 'globalThis', globalThisDescriptor!)
Object.defineProperty(window, 'self', selfDescriptor!)
}
})
})
50 changes: 31 additions & 19 deletions packages/core/src/tools/globalObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,39 @@ export function getGlobalObject<T = typeof globalThis>(): T {
if (typeof globalThis === 'object') {
return globalThis as unknown as T
}
Object.defineProperty(Object.prototype, '_dd_temp_', {
get() {
return this as object
},
configurable: true,
})
// @ts-ignore _dd_temp is defined using defineProperty
let globalObject: unknown = _dd_temp_
// @ts-ignore _dd_temp is defined using defineProperty
delete Object.prototype._dd_temp_

// Under Lightning Web Security, third-party code should rely on `self` to
// access the sandbox global object. The Object.prototype fallback below can
// also fail there because Object.prototype is sealed.
if (typeof self === 'object') {
return self as unknown as T
}

if (typeof window === 'object') {
return window as unknown as T
}

let globalObject: unknown

try {
Object.defineProperty(Object.prototype, '_dd_temp_', {
get() {
return this as object
},
configurable: true,
})
// @ts-ignore _dd_temp is defined using defineProperty
globalObject = _dd_temp_
// @ts-ignore _dd_temp is defined using defineProperty
delete Object.prototype._dd_temp_
} catch {
globalObject = {}
}

if (typeof globalObject !== 'object') {
// on safari _dd_temp_ is available on window but not globally
// fallback on other browser globals check
if (typeof self === 'object') {
globalObject = self
} else if (typeof window === 'object') {
globalObject = window
} else {
globalObject = {}
}
globalObject = {}
}

return globalObject as T
}

Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/tools/instrumentMethod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,43 @@ describe('instrumentMethod', () => {
expect('onevent' in object).toBeFalse()
})

it('skips instrumentation on readonly methods', () => {
const originalMethod = () => 1
const object = {} as { method: () => number }
Object.defineProperty(object, 'method', {
value: originalMethod,
writable: false,
configurable: true,
})

const instrumentationSpy = jasmine.createSpy()
const { stop } = instrumentMethod(object, 'method', instrumentationSpy)

expect(object.method).toBe(originalMethod)
expect(object.method()).toBe(1)
expect(instrumentationSpy).not.toHaveBeenCalled()
expect(stop).not.toThrow()
})

it('skips instrumentation on readonly methods defined on the prototype chain', () => {
const originalMethod = jasmine.createSpy().and.returnValue(1)
const prototype = {} as { method: () => number }
Object.defineProperty(prototype, 'method', {
value: originalMethod,
writable: false,
configurable: true,
})
const object = Object.create(prototype) as { method: () => number }

const instrumentationSpy = jasmine.createSpy()
const { stop } = instrumentMethod(object, 'method', instrumentationSpy)

expect(object.method()).toBe(1)
expect(instrumentationSpy).not.toHaveBeenCalled()
expect(Object.prototype.hasOwnProperty.call(object, 'method')).toBeFalse()
expect(stop).not.toThrow()
})

it('calls the instrumentation with method target and parameters', () => {
const object = { method: (a: number, b: number) => a + b }
const instrumentationSpy = jasmine.createSpy<(call: InstrumentedMethodCall<typeof object, 'method'>) => void>()
Expand Down
39 changes: 37 additions & 2 deletions packages/core/src/tools/instrumentMethod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ export function instrumentMethod<TARGET extends { [key: string]: any }, METHOD e
onPreCall: (this: null, callInfos: InstrumentedMethodCall<TARGET, METHOD>) => void,
{ computeHandlingStack }: { computeHandlingStack?: boolean } = {}
) {
const methodDescriptor = findDescriptorInPrototypeChain(targetPrototype, method)
if (methodDescriptor && !canAssignDescriptor(methodDescriptor)) {
return { stop: noop }
}

let original = targetPrototype[method]

if (typeof original !== 'function') {
Expand Down Expand Up @@ -117,14 +122,22 @@ export function instrumentMethod<TARGET extends { [key: string]: any }, METHOD e
return result
}

targetPrototype[method] = instrumentation as TARGET[METHOD]
try {
targetPrototype[method] = instrumentation as TARGET[METHOD]
} catch {
return { stop: noop }
}

return {
stop: () => {
stopped = true
// If the instrumentation has been removed by a third party, keep the last one
if (targetPrototype[method] === instrumentation) {
targetPrototype[method] = original
try {
targetPrototype[method] = original
} catch {
// Ignore restore failures on readonly properties.
}
}
},
}
Expand Down Expand Up @@ -168,3 +181,25 @@ export function instrumentSetter<TARGET extends { [key: string]: any }, PROPERTY
},
}
}

function findDescriptorInPrototypeChain(target: object, property: PropertyKey): PropertyDescriptor | undefined {
let currentTarget: object | null = target

while (currentTarget) {
const descriptor = Object.getOwnPropertyDescriptor(currentTarget, property)
if (descriptor) {
return descriptor
}
currentTarget = Object.getPrototypeOf(currentTarget)
}

return undefined
}

function canAssignDescriptor(descriptor: PropertyDescriptor) {
if ('writable' in descriptor) {
return descriptor.writable !== false
}

return typeof descriptor.set === 'function'
}
23 changes: 23 additions & 0 deletions packages/rum-core/src/browser/cookieObservable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,27 @@ describe('cookieObservable', () => {

expect(cookieChanges).toEqual(['foo', 'bar'])
})

it('should fallback to polling when cookieStore rejects change listeners', () => {
Object.defineProperty(window, 'cookieStore', {
configurable: true,
get: () => ({
addEventListener: () => {
throw new Error("Lightning Web Security: Cannot add 'change' event listener to CookieStore object.")
},
removeEventListener: () => undefined,
}),
})
const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME)

let cookieChange: string | undefined
expect(() => {
subscription = observable.subscribe((change) => (cookieChange = change))
}).not.toThrow()

setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)
clock.tick(WATCH_COOKIE_INTERVAL_DELAY)

expect(cookieChange).toEqual('foo')
})
})
Loading
Loading