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

Capture globals generically #719

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"type": "module",
"workspaces": [
"packages/special-pages",
"packages/messaging"
"packages/messaging",
"packages/safe-globals"
],
"dependencies": {
"immutable-json-patch": "^5.1.3",
Expand Down
22 changes: 22 additions & 0 deletions packages/safe-globals/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { captureGlobal, cleanup } from './scope.js'

export const String = captureGlobal('String')
export const Set = captureGlobal('Set')
export const Reflect = captureGlobal('Reflect')
export const Object = captureGlobal('Object')
export const RegExp = captureGlobal('RegExp')

/**
* Returns a string using the prototype of our safe iframe.
*/
export function getSafeString (stringIn) {
// eslint-disable-next-line no-new-wrappers
return new String(stringIn)
}

// Both of these aren't using our safe iframe as we need to have registered side effects on the page
// We still want to not have a tampered method though.
export const customElementsGet = globalThis.customElements?.get.bind(globalThis.customElements)
export const customElementsDefine = globalThis.customElements?.define.bind(globalThis.customElements)

cleanup()
13 changes: 13 additions & 0 deletions packages/safe-globals/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@duckduckgo/safe-globals",
"private": "true",
"version": "0.0.1",
"description": "Exposes a set of globals that aren't touched by a web page",
"main": "index.js",
"type": "module",
"scripts": {
},
"license": "ISC",
"devDependencies": {
}
}
60 changes: 60 additions & 0 deletions packages/safe-globals/scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* global mozProxies */
import { getInjectionElement } from '../../src/utils.js'
export const hasMozProxies = typeof mozProxies !== 'undefined' ? mozProxies : false

let dummyWindow
if (globalThis && 'document' in globalThis &&
// Prevent infinate recursion of injection into Chrome
globalThis.location.href !== 'about:blank') {
const injectionElement = getInjectionElement()
// injectionElement is null in some playwright context tests
if (injectionElement) {
dummyWindow = globalThis.document.createElement('iframe')
dummyWindow.style.display = 'none'
injectionElement.appendChild(dummyWindow)
}
}

let dummyContentWindow = dummyWindow?.contentWindow
if (hasMozProxies) {
// Purposefully prevent Firefox using the iframe as we can't hold stale references to iframes there.

Check failure on line 20 in packages/safe-globals/scope.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-20.04)

Irregular whitespace not allowed
// dummyContentWindow = undefined
// @ts-expect-error - mozProxies is not defined on window
dummyContentWindow = dummyContentWindow?.wrappedJSObject
}
// @ts-expect-error - Symbol is not defined on window
const dummySymbol = dummyContentWindow?.Symbol
const iteratorSymbol = dummySymbol?.iterator

/**
* Capture prototype to prevent overloading
* @template {string} T
* @param {T} globalName
* @returns {globalThis[T]}
*/
export function captureGlobal (globalName) {
const global = dummyContentWindow?.[globalName]

// if we were unable to create a dummy window, return the global
// this still has the advantage of preventing aliasing of the global through shawdowing
if (!global) {
// @ts-expect-error can't index typeof T
return globalThis[globalName]
}

// Alias the iterator symbol to the local symbol so for loops work
if (iteratorSymbol &&
global?.prototype &&
iteratorSymbol in global.prototype) {
global.prototype[Symbol.iterator] = global.prototype[iteratorSymbol]
}
return global
}

export function cleanup () {
// We can't remove the iframe in firefox as we get dead object issues.
if (import.meta.injectName === 'firefox') return

// Clean up the dummy window
dummyWindow?.remove()
}
6 changes: 0 additions & 6 deletions src/captured-globals.js

This file was deleted.

8 changes: 4 additions & 4 deletions src/content-feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { PerformanceMonitor } from './performance.js'
import { MessagingContext } from '../packages/messaging/index.js'
import { createMessaging } from './create-messaging.js'
import { hasMozProxies, wrapToString } from './wrapper-utils.js'
import { getOwnPropertyDescriptor, objectKeys } from './captured-globals.js'
import { Object } from '@duckduckgo/safe-globals'

/**
* @typedef {object} AssetConfig
Expand Down Expand Up @@ -324,7 +324,7 @@ export default class ContentFeature {
object = object.wrappedJSObject || object
}

const origDescriptor = getOwnPropertyDescriptor(object, propertyName)
const origDescriptor = Object.getOwnPropertyDescriptor(object, propertyName)
if (!origDescriptor) {
// this happens if the property is not implemented in the browser
return
Expand All @@ -345,7 +345,7 @@ export default class ContentFeature {
return origDescriptor
} else {
// if the property is defined with get/set it must be wrapped with a get/set. If it's defined with a `value`, it must be wrapped with a `value`
throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${objectKeys(origDescriptor)}`)
throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${Object.keys(origDescriptor)}`)
}
}

Expand All @@ -363,7 +363,7 @@ export default class ContentFeature {
if (hasMozProxies) {
object = object.wrappedJSObject || object
}
const origDescriptor = getOwnPropertyDescriptor(object, propertyName)
const origDescriptor = Object.getOwnPropertyDescriptor(object, propertyName)
if (!origDescriptor) {
// this happens if the property is not implemented in the browser
return
Expand Down
2 changes: 1 addition & 1 deletion src/content-scope-features.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function injectFeatures (args) {
${codeFeatures.join('\n')}
})();`
script.src = 'data:text/javascript;base64,' + btoa(code)
getInjectionElement().appendChild(script)
getInjectionElement()?.appendChild(script)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Was this just noticed during testing and fixed on the fly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I changed the method signature to return null also.

The reason this was added is the utils.js is used within the extension testing which doesn't have a DOM, so the code falls back to just using the existing global (under a different alias so still some slight improvements).

script.remove()
}

Expand Down
1 change: 1 addition & 0 deletions src/features/cookie.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { postDebugMessage, getStackTraceOrigins, getStack, isBeingFramed, isThir
import { Cookie } from '../cookie.js'
import ContentFeature from '../content-feature.js'
import { isTrackerOrigin } from '../trackers.js'
import { Object } from '@duckduckgo/safe-globals'

/**
* @typedef ExtensionCookiePolicy
Expand Down
2 changes: 1 addition & 1 deletion src/features/duckplayer/components/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DDGVideoOverlay } from './ddg-video-overlay.js'
import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'
import { customElementsDefine, customElementsGet } from '@duckduckgo/safe-globals'

/**
* Register custom elements in this wrapper function to be called only when we need to
Expand Down
2 changes: 1 addition & 1 deletion src/features/runtime-checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { DDGProxy, getStackTraceOrigins, getStack, matchHostname, injectGlobalSt
import { wrapFunction } from '../wrapper-utils.js'
import { wrapScriptCodeOverload } from './runtime-checks/script-overload.js'
import { findClosestBreakpoint } from './runtime-checks/helpers.js'
import { Reflect } from '../captured-globals.js'
import { Reflect, Object } from '@duckduckgo/safe-globals/index.js'

let stackDomains = []
let matchAllStackDomains = false
Expand Down
6 changes: 5 additions & 1 deletion src/features/runtime-checks/script-overload.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { processAttr, getContextId } from '../../utils.js'
import { Object, Reflect } from '@duckduckgo/safe-globals'

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const globalStates = new Set()
Expand Down Expand Up @@ -233,13 +234,16 @@ export function wrapScriptCodeOverload (code, config) {
// Ensure globalThis === window
const globalThis = window
`
// Hack to use default capture instead of rollups replaced variable names.
// This is covered by testing so should break if rollup is changed.
const proxyString = constructProxy.toString().replaceAll('Object$1', 'Object').replaceAll('Reflect$1', 'Reflect')
return removeIndent(`(function (parentScope) {
/**
* DuckDuckGo Runtime Checks injected code.
* If you're reading this, you're probably trying to debug a site that is breaking due to our runtime checks.
* Please raise an issues on our GitHub repo: https://github.com/duckduckgo/content-scope-scripts/
*/
${constructProxy.toString()}
${proxyString}
${prepend}

${getContextId.toString()}
Expand Down
1 change: 1 addition & 0 deletions src/features/windows-permission-usage.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* global Bluetooth, Geolocation, HID, Serial, USB */
import { DDGProxy, DDGReflect } from '../utils'
import ContentFeature from '../content-feature'
import { Object } from '@duckduckgo/safe-globals'

export default class WindowsPermissionUsage extends ContentFeature {
init () {
Expand Down
16 changes: 16 additions & 0 deletions src/global-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// This file is used to assist with ordering of setup of globals from utils.js with safe-globals
// It's to allow for the extension to setGlobal in testing

// Only use globalThis for testing this breaks window.wrappedJSObject code in Firefox
// eslint-disable-next-line no-global-assign
export let globalObj = typeof window === 'undefined' ? globalThis : window
export let Error = globalObj.Error

/**
* Used for testing to override the globals used within this file.
* @param {window} globalObjIn
*/
export function setGlobal (globalObjIn) {
globalObj = globalObjIn
Error = globalObj.Error
}
42 changes: 18 additions & 24 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/* global cloneInto, exportFunction, mozProxies */
import { Set } from './captured-globals.js'
import { globalObj, Error } from './global-setup.js'
import { Set, getSafeString, RegExp } from '@duckduckgo/safe-globals'

// Only use globalThis for testing this breaks window.wrappedJSObject code in Firefox
// eslint-disable-next-line no-global-assign
let globalObj = typeof window === 'undefined' ? globalThis : window
let Error = globalObj.Error
let messageSecret

export const taintSymbol = Symbol('taint')

export { setGlobal } from './global-setup.js'

// save a reference to original CustomEvent amd dispatchEvent so they can't be overriden to forge messages
export const OriginalCustomEvent = typeof CustomEvent === 'undefined' ? null : CustomEvent
export const originalWindowDispatchEvent = typeof window === 'undefined' ? null : window.dispatchEvent.bind(window)
Expand All @@ -17,10 +16,14 @@ export function registerMessageSecret (secret) {
}

/**
* @returns {HTMLElement} the element to inject the script into
* @returns {null | HTMLElement} the element to inject the script into
*/
export function getInjectionElement () {
return document.head || document.documentElement
// Account for test setups
if (!globalObj || !('document' in globalObj)) {
return null
}
return globalObj.document.head || globalObj.document.documentElement
}

// Tests don't define this variable so fallback to behave like chrome
Expand Down Expand Up @@ -50,16 +53,7 @@ export function createStyleElement (css) {
*/
export function injectGlobalStyles (css) {
const style = createStyleElement(css)
getInjectionElement().appendChild(style)
}

/**
* Used for testing to override the globals used within this file.
* @param {window} globalObjIn
*/
export function setGlobal (globalObjIn) {
globalObj = globalObjIn
Error = globalObj.Error
getInjectionElement()?.appendChild(style)
}

// linear feedback shift register to find a random approximation
Expand Down Expand Up @@ -137,16 +131,16 @@ export function hasThirdPartyOrigin (scriptOrigins) {
export function getTabHostname () {
let framingOrigin = null
try {
// @ts-expect-error - globalThis.top is possibly 'null' here
framingOrigin = globalThis.top.location.href
// @ts-expect-error - globalObj.top is possibly 'null' here
framingOrigin = globalObj.top.location.href
} catch {
framingOrigin = globalThis.document.referrer
framingOrigin = globalObj.document.referrer
}

// Not supported in Firefox
if ('ancestorOrigins' in globalThis.location && globalThis.location.ancestorOrigins.length) {
if ('ancestorOrigins' in globalObj.location && globalObj.location.ancestorOrigins.length) {
// ancestorOrigins is reverse order, with the last item being the top frame
framingOrigin = globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1)
framingOrigin = globalObj.location.ancestorOrigins.item(globalObj.location.ancestorOrigins.length - 1)
}

try {
Expand All @@ -168,7 +162,7 @@ export function matchHostname (hostname, exceptionDomain) {
return hostname === exceptionDomain || hostname.endsWith(`.${exceptionDomain}`)
}

const lineTest = /(\()?(https?:[^)]+):[0-9]+:[0-9]+(\))?/
const lineTest = new RegExp('([(])?(https?:[^)]+):[0-9]+:[0-9]+([)])?')
export function getStackTraceUrls (stack) {
const urls = new Set()
try {
Expand Down Expand Up @@ -332,7 +326,7 @@ export function processAttr (configSetting, defaultValue) {
}

export function getStack () {
return new Error().stack
return getSafeString(new Error().stack)
}

export function getContextId (scope) {
Expand Down