Skip to content

Support arbitary depth config overloading of DOM properties #383

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

Merged
merged 5 commits into from
Apr 4, 2023
Merged
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 .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Sources/ContentScopeScripts/dist/
integration-test/extension/contentScope.js
integration-test/pages/build
packages/special-pages/pages/**/public
unit-test/script-overload-snapshots/
6 changes: 5 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
name: Test

on: [push, pull_request]
on:
push:
branches:
- main
pull_request:

jobs:
unit:
Expand Down
3 changes: 2 additions & 1 deletion CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ inject/chrome.js @jonathanKingston @sammacbeth
inject/windows.js @jonathanKingston @q71114 @szanto90balazs

# Test owners
integration-tests/test-pages/ @kdzwinel @jonathanKingston
integration-tests/test-pages/ @kdzwinel @jonathanKingston
unit-tests/script-overload-snapshots/ @shakyShane @jonathanKingston @englehardt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
"document.cookie": {
"type": "string",
"value": "testingThisOut"
},
"navigator.mediaSession.playbackState": {
"type": "string",
"value": "playing"
},
"navigator.mediaSession.doesNotExist.depth.a.lot": {
"type": "string",
"value": "boop"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
n: navigator.userAgent,
w: window.navigator.userAgent,
g: globalThis.navigator.userAgent,
pb: navigator.mediaSession.playbackState,
wpb: window.navigator.mediaSession.playbackState,
gpb: globalThis.navigator.mediaSession.playbackState,
undef: navigator.mediaSession.doesNotExist.depth.a.lot
}
`;
scriptElement.id = 'overloadedScript';
Expand All @@ -34,14 +38,23 @@
const scripty = document.querySelector('script#overloadedScript');
const nodeAndFakeNodeMatch = scripty === scriptElement;
const expectedUserAgentOverload = 'testingThisOut';
const expectedPlaybackState = 'playing';
// We shouldn't break out of the context we're overloading
const doesntMatchParentContext = navigator.userAgent !== 'testingThisOut';
return [
{ name: 'hadInspectorNode', result: hadInspectorNode, expected: true },
{ name: 'instanceof matches HTMLScriptElement', result: instanceofResult, expected: true },
{ name: 'script ran', result: window.scriptyRan, expected: true },
{ name: 'node and fake node match', result: nodeAndFakeNodeMatch, expected: false },
{ name: 'user agent is overloaded', result: window.scriptOutput, expected: { n: expectedUserAgentOverload, w: expectedUserAgentOverload, g: expectedUserAgentOverload } },
{ name: 'user agent is overloaded', result: window.scriptOutput, expected: {
n: expectedUserAgentOverload,
w: expectedUserAgentOverload,
g: expectedUserAgentOverload,
pb: expectedPlaybackState,
wpb: expectedPlaybackState,
gpb: expectedPlaybackState,
undef: 'boop'
} },
{ name: 'user agent doesnt match parent context', result: doesntMatchParentContext, expected: true }
];
});
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"lint": "eslint . && npm run tsc",
"lint-no-output-globals": "eslint --no-eslintrc --config=build-output.eslintrc --no-ignore Sources/ContentScopeScripts/dist/contentScope.js",
"lint-fix": "eslint . --fix && npm run tsc",
"pretest-unit": "node scripts/generateOverloadSnapshots.js",
"test-unit": "jasmine --config=unit-test/config.json",
"test-int": "npm run build-integration && jasmine --config=integration-test/config.js",
"test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int",
Expand Down
3 changes: 2 additions & 1 deletion scripts/check-for-changes.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
git update-index --refresh
git update-index --refresh
git diff-index --patch-with-raw HEAD --
git diff-index --quiet HEAD --
28 changes: 28 additions & 0 deletions scripts/generateOverloadSnapshots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { wrapScriptCodeOverload } from '../src/features/runtime-checks/script-overload.js'

import { join } from 'node:path'
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
import { cwd } from './script-utils.js'
const ROOT = join(cwd(import.meta.url))
const configPath = join(ROOT, '../unit-test/script-overload-snapshots/config')

/**
* Generates a bunch of snapshots for script-overload.js results using the configs.
* These are used in unit-test/script-overload.js and ran automatically in automation so that we verify the output is correct.
*/
function generateOut () {
if (process.platform === 'win32') {
console.log('skipping test generation on windows')
return
}

const files = readdirSync(configPath)
for (const fileName of files) {
const config = readFileSync(join(configPath, fileName)).toString()
const out = wrapScriptCodeOverload('console.log(1)', JSON.parse(config))
const outName = fileName.replace(/.json$/, '.js')
writeFileSync(join(ROOT, '../unit-test/script-overload-snapshots/out/', outName), out)
}
}

generateOut()
73 changes: 3 additions & 70 deletions src/features/runtime-checks.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* global TrustedScriptURL, TrustedScript */

import ContentFeature from '../content-feature.js'
import { DDGProxy, getStackTraceOrigins, getStack, matchHostname, injectGlobalStyles, createStyleElement, processAttr } from '../utils.js'
import { DDGProxy, getStackTraceOrigins, getStack, matchHostname, injectGlobalStyles, createStyleElement } from '../utils.js'
import { wrapScriptCodeOverload } from './runtime-checks/script-overload.js'

let stackDomains = []
let matchAllStackDomains = false
Expand Down Expand Up @@ -124,75 +125,7 @@ class DDGRuntimeChecks extends HTMLElement {
// @ts-expect-error TrustedScript is not defined in the TS lib
if (supportedTrustedTypes && el.textContent instanceof TrustedScript) return

const config = scriptOverload
const processedConfig = {}
for (const [key, value] of Object.entries(config)) {
processedConfig[key] = processAttr(value)
}
// Don't do anything if the config is empty
if (Object.keys(processedConfig).length === 0) return

/**
* @param {*} scope
* @param {Record<string, any>} outputs
* @returns {Proxy}
*/
function constructProxy (scope, outputs) {
return new Proxy(scope, {
get (target, property, receiver) {
const targetObj = target[property]
if (typeof targetObj === 'function') {
return (...args) => {
return Reflect.apply(target[property], target, args)
}
} else {
if (typeof property === 'string' && property in outputs) {
return Reflect.get(outputs, property, receiver)
}
return Reflect.get(target, property, receiver)
}
}
})
}

let prepend = ''
const aggregatedLookup = new Map()
/* Convert the config into a map of scopePath -> { key: value } */
for (const [key, value] of Object.entries(processedConfig)) {
const path = key.split('.')
const scopePath = path.slice(0, -1).join('.')
const pathOut = path[path.length - 1]
if (aggregatedLookup.has(scopePath)) {
aggregatedLookup.get(scopePath)[pathOut] = value
} else {
aggregatedLookup.set(scopePath, {
[pathOut]: value
})
}
}

for (const [key, value] of aggregatedLookup) {
const path = key.split('.')
if (path.length !== 1) {
console.error('Invalid config, currently only one layer depth is supported')
continue
}
const scopeName = path[0]
prepend += `
let ${scopeName} = constructProxy(parentScope.${scopeName}, ${JSON.stringify(value)});
`
}
const keysOut = [...aggregatedLookup.keys()].join(',\n')
prepend += `
const window = constructProxy(parentScope, {
${keysOut}
});
const globalThis = constructProxy(parentScope, {
${keysOut}
});
`
const innerCode = prepend + el.textContent
el.textContent = '(function (parentScope) {' + constructProxy.toString() + ' ' + innerCode + '})(globalThis)'
el.textContent = wrapScriptCodeOverload(el.textContent, scriptOverload)
}

/**
Expand Down
115 changes: 115 additions & 0 deletions src/features/runtime-checks/script-overload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { processAttr } from '../../utils.js'

/**
* Code generates wrapping variables for code that is injected into the page
* @param {*} code
* @param {*} config
* @returns {string}
*/
export function wrapScriptCodeOverload (code, config) {
const processedConfig = {}
for (const [key, value] of Object.entries(config)) {
processedConfig[key] = processAttr(value)
}
// Don't do anything if the config is empty
if (Object.keys(processedConfig).length === 0) return code

/**
* @param {*} scope
* @param {Record<string, any>} outputs
* @returns {Proxy}
*/
function constructProxy (scope, outputs) {
if (Object.is(scope)) {
// Should not happen, but just in case fail safely
console.error('Runtime checks: Scope must be an object', scope, outputs)
return scope
}
return new Proxy(scope, {
get (target, property, receiver) {
const targetObj = target[property]
if (typeof targetObj === 'function') {
return (...args) => {
return Reflect.apply(target[property], target, args)
}
} else {
if (typeof property === 'string' && property in outputs) {
return Reflect.get(outputs, property, receiver)
}
return Reflect.get(target, property, receiver)
}
}
})
}

let prepend = ''
const aggregatedLookup = new Map()
let currentScope = null
/* Convert the config into a map of scopePath -> { key: value } */
for (const [key, value] of Object.entries(processedConfig)) {
const path = key.split('.')

currentScope = aggregatedLookup
const pathOut = path[path.length - 1]
// Traverse the path and create the nested objects
path.slice(0, -1).forEach((pathPart, index) => {
if (!currentScope.has(pathPart)) {
currentScope.set(pathPart, new Map())
}
currentScope = currentScope.get(pathPart)
})
currentScope.set(pathOut, value)
}

/**
* Output scope variable definitions to arbitrary depth
*/
function stringifyScope (scope, scopePath) {
let output = ''
for (const [key, value] of scope) {
const varOutName = [...scopePath, key].join('_')
if (value instanceof Map) {
const proxyName = `_proxyFor_${varOutName}`
output += `
let ${proxyName}
if (${scopePath.join('?.')}?.${key} === undefined) {
${proxyName} = Object.bind(null);
} else {
${proxyName} = ${scopePath.join('.')}.${key};
}
`
const keys = Array.from(value.keys())
output += stringifyScope(value, [...scopePath, key])
const proxyOut = keys.map((keyName) => `${keyName}: ${[...scopePath, key, keyName].join('_')}`)
output += `
let ${varOutName} = constructProxy(${proxyName}, {${proxyOut.join(', ')}});
`
// If we're at the top level, we need to add the window and globalThis variables (Eg: let navigator = parentScope_navigator)
if (scopePath.length === 1) {
output += `
let ${key} = ${varOutName};
`
}
} else {
output += `
let ${varOutName} = ${JSON.stringify(value)};
`
}
}
return output
}

prepend += stringifyScope(aggregatedLookup, ['parentScope'])
// Stringify top level keys
const keysOut = [...aggregatedLookup.keys()].map((keyName) => `${keyName}: parentScope_${keyName}`).join(',\n')
prepend += `
const window = constructProxy(parentScope, {
${keysOut}
});
const globalThis = constructProxy(parentScope, {
${keysOut}
});
`
const innerCode = prepend + code
return '(function (parentScope) {' + constructProxy.toString() + ' ' + innerCode + '})(globalThis)'
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"packages"
],
"exclude": [
"unit-test/script-overload-snapshots",
"integration-test/pages",
"integration-test/extension",
"packages/special-pages/pages/**/public",
Expand Down
3 changes: 2 additions & 1 deletion unit-test/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"spec_dir": "unit-test",
"spec_files": [
"**/*[sS]pec.?(m)js",
"**/*.js"
"**/*.js",
"!unit-test/script-overload-snapshots/out/*.js"
],
"jsLoader": "import",
"helpers": [
Expand Down
18 changes: 18 additions & 0 deletions unit-test/script-overload-snapshots/config/1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"navigator.userAgent": {
"type": "string",
"value": "testingThisOut"
},
"document.cookie": {
"type": "string",
"value": "testingThisOut"
},
"navigator.mediaSession.playbackState": {
"type": "string",
"value": "playing"
},
"navigator.mediaSession.doesNotExist.depth.a.lot": {
"type": "string",
"value": "boop"
}
}
6 changes: 6 additions & 0 deletions unit-test/script-overload-snapshots/config/2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"single": {
"type": "string",
"value": "meep"
}
}
Loading