Skip to content

Commit

Permalink
fix: loader missing sub-resource integrity hashes (#837)
Browse files Browse the repository at this point in the history
  • Loading branch information
patrickhousley authored Dec 14, 2023
1 parent 3e3b810 commit a9b6f2e
Show file tree
Hide file tree
Showing 15 changed files with 411 additions and 238 deletions.
31 changes: 0 additions & 31 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ code, the source code can be found at [https://github.com/newrelic/newrelic-brow
* [webpack-bundle-analyzer](#webpack-bundle-analyzer)
* [webpack-cli](#webpack-cli)
* [webpack-stream](#webpack-stream)
* [webpack-subresource-integrity](#webpack-subresource-integrity)
* [webpack](#webpack)
* [yargs](#yargs)

Expand Down Expand Up @@ -3350,36 +3349,6 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

### webpack-subresource-integrity

This product includes source derived from [webpack-subresource-integrity](https://github.com/waysact/webpack-subresource-integrity) ([v5.1.0](https://github.com/waysact/webpack-subresource-integrity/tree/v5.1.0)), distributed under the [MIT License](https://github.com/waysact/webpack-subresource-integrity/blob/v5.1.0/LICENSE):

```
(The MIT License)
Copyright (c) 2015-present Waysact Pty Ltd
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

### webpack

This product includes source derived from [webpack](https://github.com/webpack/webpack) ([v5.84.1](https://github.com/webpack/webpack/tree/v5.84.1)), distributed under the [MIT License](https://github.com/webpack/webpack/blob/v5.84.1/LICENSE):
Expand Down
4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,11 @@
"cdn:build": "npm run cdn:build:prod",
"cdn:build:local": "npm run cdn:webpack",
"cdn:build:prod": "npm run cdn:webpack -- --env mode=prod",
"postcdn:build:prod": "npm run cdn:clone",
"cdn:build:dev": "npm run cdn:webpack -- --env mode=dev",
"cdn:build:experiment": "npm run cdn:webpack -- --env mode=experiment",
"cdn:webpack": "npx webpack --progress --config ./tools/webpack/index.mjs",
"postcdn:webpack": "npm run cdn:cleanup",
"cdn:watch": "jung -r ./src -F '.*\\.test\\.js' --run -- npm run cdn:build:local",
"cdn:cleanup": "node ./tools/webpack/scripts/cleanup.mjs",
"cdn:clone": "node ./tools/webpack/scripts/clone.mjs",
"test-server": "node ./tools/wdio/bin/server",
"sauce:connect": "node ./tools/saucelabs/bin.mjs",
"sauce:get-browsers": "node ./tools/browsers-lists/sauce-browsers.mjs",
Expand Down Expand Up @@ -251,7 +248,6 @@
"webpack-bundle-analyzer": "^4.7.0",
"webpack-cli": "^4.10.0",
"webpack-stream": "^7.0.0",
"webpack-subresource-integrity": "^5.1.0",
"yargs": "^17.6.2"
},
"files": [
Expand Down
4 changes: 2 additions & 2 deletions tests/assets/subresource-integrity-capture.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
<html>
<head>
<script>
var chunkIntegratyValues = []
var chunkIntegrityValues = []
var observer = new MutationObserver(function (mutationList) {
for (var i = 0; i < mutationList.length; i++) {
var mutation = mutationList[i]
if (mutation.addedNodes.length > 0) {
for (var j = 0; j < mutation.addedNodes.length; j++) {
var node = mutation.addedNodes[j]
if (node.localName === 'script' && node.src.indexOf('build/nr-') > 0) {
chunkIntegratyValues.push(node.integrity)
chunkIntegrityValues.push(node.integrity)
}
}
}
Expand Down
33 changes: 25 additions & 8 deletions tests/specs/csp.e2e.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { notIE } from '../../tools/browser-matcher/common-matchers.mjs'
import { faker } from '@faker-js/faker'

describe('Content Security Policy', () => {
it.withBrowsersMatching(notIE)('should support a nonce script element', async () => {
describe.withBrowsersMatching(notIE)('Content Security Policy', () => {
it('should support a nonce script element', async () => {
const nonce = faker.datatype.uuid()
await Promise.all([
browser.testHandle.expectRum(),
browser.url(await browser.testHandle.assetURL('instrumented.html', { nonce }))
.then(() => browser.waitForAgentLoad())
])

const foundNonce = await browser.execute(function () {
const foundNonces = await browser.execute(function () {
var scriptTags = document.querySelectorAll('script')
var nonceValues = []
for (let i = 0; i < scriptTags.length; i++) {
Expand All @@ -19,7 +19,10 @@ describe('Content Security Policy', () => {
return nonceValues
})

expect(foundNonce).toEqual([nonce, nonce, nonce, nonce])
expect(foundNonces.length).toBeGreaterThanOrEqual(1)
foundNonces.forEach(foundNonce => {
expect(foundNonce).toEqual(nonce)
})
})

it.withBrowsersMatching(notIE)('should send a nonce supportability metric', async () => {
Expand All @@ -40,17 +43,31 @@ describe('Content Security Policy', () => {
})

it('should load async chunk with subresource integrity', async () => {
await browser.enableSessionReplay()

const url = await browser.testHandle.assetURL('subresource-integrity-capture.html', {
init: {
privacy: { cookies_enabled: true },
session_replay: { enabled: true, sampling_rate: 100, error_sampling_rate: 100 }
}
})
await Promise.all([
browser.testHandle.expectRum(),
browser.url(await browser.testHandle.assetURL('subresource-integrity-capture.html'))
browser.url(url)
.then(() => browser.waitForAgentLoad())
])

await browser.waitUntil(
() => browser.execute(function () {
return window.chunkIntegrityValues.length === 3
})
)
const foundIntegrityValues = await browser.execute(function () {
return window.chunkIntegratyValues
return window.chunkIntegrityValues
})

expect(foundIntegrityValues.length).toEqual(1)
expect(foundIntegrityValues[0]).toMatch(/^sha512-[a-zA-Z0-9=/+]+$/)
foundIntegrityValues.forEach(hash =>
expect(hash).toMatch(/^sha512-[a-zA-Z0-9=/+]+$/)
)
})
})
15 changes: 1 addition & 14 deletions third_party_manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"lastUpdated": "Thu Nov 16 2023 17:54:23 GMT+0000 (Coordinated Universal Time)",
"lastUpdated": "Tue Dec 12 2023 10:40:55 GMT-0600 (Central Standard Time)",
"projectName": "New Relic Browser Agent",
"projectUrl": "https://github.com/newrelic/newrelic-browser-agent",
"includeOptDeps": true,
Expand Down Expand Up @@ -1060,19 +1060,6 @@
"email": "[email protected]",
"url": "http://dontkry.com"
},
"[email protected]": {
"name": "webpack-subresource-integrity",
"version": "5.1.0",
"range": "^5.1.0",
"licenses": "MIT",
"repoUrl": "https://github.com/waysact/webpack-subresource-integrity",
"versionedRepoUrl": "https://github.com/waysact/webpack-subresource-integrity/tree/v5.1.0",
"licenseFile": "node_modules/webpack-subresource-integrity/LICENSE",
"licenseUrl": "https://github.com/waysact/webpack-subresource-integrity/blob/v5.1.0/LICENSE",
"licenseTextSource": "file",
"publisher": "Julian Scheid",
"email": "[email protected]"
},
"[email protected]": {
"name": "webpack",
"version": "5.84.1",
Expand Down
37 changes: 27 additions & 10 deletions tools/webpack/configs/common.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import webpack from 'webpack'
import TerserPlugin from 'terser-webpack-plugin'
import { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity'
import NRBAChunkingPlugin from '../plugins/nrba-chunking/index.mjs'
import NRBAPrependSemicolonPlugin from '../plugins/prepend-semicolon.mjs'
import NRBARemoveNonAsciiPlugin from '../plugins/remove-non-ascii.mjs'
import NRBASubresourceIntegrityPlugin from '../plugins/sri-plugin.mjs'
import NRBALoaderApmCheckPlugin from '../plugins/loader-apm-check.mjs'
import NRBAFuzzyLoadersPlugin from '../plugins/fuzzy-loaders.mjs'

/**
* @typedef {import('../index.mjs').WebpackBuildOptions} WebpackBuildOptions
Expand All @@ -19,6 +22,7 @@ export default (env, asyncChunkName) => {
devtool: false,
mode: env.SUBVERSION === 'LOCAL' ? 'development' : 'production',
optimization: {
realContentHash: true,
minimize: true,
minimizer: [new TerserPlugin({
include: [/\.min\.js$/, /^(?:[0-9])/],
Expand All @@ -36,7 +40,22 @@ export default (env, asyncChunkName) => {
chunks: 'async',
cacheGroups: {
defaultVendors: false,
default: false
default: false,
'agent-chunk': {
name: asyncChunkName,
enforce: true,
test: (module, { chunkGraph }) => chunkGraph.getModuleChunks(module).filter(chunk => !['recorder', 'compressor'].includes(chunk.name)).length > 0
},
recorder: {
name: `${asyncChunkName}-recorder`,
enforce: true,
test: (module, { chunkGraph }) => chunkGraph.getModuleChunks(module).filter(chunk => !['recorder'].includes(chunk.name)).length === 0
},
compressor: {
name: `${asyncChunkName}-compressor`,
enforce: true,
test: (module, { chunkGraph }) => chunkGraph.getModuleChunks(module).filter(chunk => !['compressor'].includes(chunk.name)).length === 0
}
}
}
},
Expand Down Expand Up @@ -64,13 +83,11 @@ export default (env, asyncChunkName) => {
publicPath: env.PUBLIC_PATH,
append: env.SUBVERSION === 'PROD' ? false : '//# sourceMappingURL=[url]'
}),
new SubresourceIntegrityPlugin({
enabled: true,
hashFuncNames: ['sha512']
}),
new NRBAChunkingPlugin({
asyncChunkName
})
new NRBAPrependSemicolonPlugin(),
new NRBARemoveNonAsciiPlugin(),
new NRBALoaderApmCheckPlugin(),
new NRBASubresourceIntegrityPlugin(),
new NRBAFuzzyLoadersPlugin()
]
}
}
54 changes: 54 additions & 0 deletions tools/webpack/plugins/fuzzy-loaders.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from 'node:fs'
import path from 'node:path'

/**
* Webpack plugin that generates fuzzy version matching loader files
* after a compilation has emitted its files. This will only apply
* when the loader files name contains a properly formatted semver.
*/
export default class NRBAFuzzyLoadersPlugin {
#pluginName = 'NRBAFuzzyLoadersPlugin'

/**
* @param compiler {import('webpack/lib/Compiler.js').default}
*/
apply (compiler) {
compiler.hooks.assetEmitted.tapPromise(this.#pluginName, async (file, { content, outputPath }) => {
await this.#writeFuzzyMinor(file, outputPath, content)
await this.#writeFuzzyMajor(file, outputPath, content)
})
}

/**
* If the file is a loader with a version number, write a new file using
* the same name with the third version octet replaced with an x as a wildcard.
* @param file {string}
* @param outputPath {string}
* @param content {string | Buffer}
* @return {Promise<void>}
*/
async #writeFuzzyMinor (file, outputPath, content) {
// Assuming the filename contains a semantic version pattern, "-#.#.#.", replace the minor and patch numbers.
const allPatch = file.replace(/(^nr-loader.*-\d+\.\d+\.)(\d+)(.*\.js$)/, '$1x$3')
if (allPatch !== file) { // we only get a different string back if the filename has that pattern, in which case we'll create the respective "fuzzy" file
await fs.promises.writeFile(path.join(outputPath, allPatch), content)
}
}

/**
* If the file is a loader with a version number, write a new file using
* the same name with the second and third version octets replaced with
* an x as a wildcard.
* @param file {string}
* @param outputPath {string}
* @param content {string | Buffer}
* @return {Promise<void>}
*/
async #writeFuzzyMajor (file, outputPath, content) {
// Assuming the filename contains a semantic version pattern, "-#.#.#.", replace the minor and patch numbers.
const allMinorAndPatch = file.replace(/(^nr-loader.*-\d+\.)(\d+)\.(\d+)(.*\.js$)/, '$1x.x$4')
if (allMinorAndPatch !== file) { // we only get a different string back if the filename has that pattern, in which case we'll create the respective "fuzzy" file
await fs.promises.writeFile(path.join(outputPath, allMinorAndPatch), content)
}
}
}
42 changes: 42 additions & 0 deletions tools/webpack/plugins/loader-apm-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Webpack plugin that checks loader files for any character sequences
* that are known to break APM injection. If found, an error is registered
* in the compilation and the compilation process will end with a non-zero
* status code.
*/
export default class NRBALoaderApmCheckPlugin {
#pluginName = 'NRBALoaderApmCheckPlugin'

/**
* @param compiler {import('webpack/lib/Compiler.js').default}
*/
apply (compiler) {
compiler.hooks.thisCompilation.tap(this.#pluginName, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: this.#pluginName,
stage: compilation.compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE + 1,
additionalAssets: true
},
(assets) => {
Object.entries(assets)
.filter(([assetKey]) => assetKey.indexOf('-loader') > -1 && assetKey.endsWith('.js'))
.forEach(([assetKey, assetSource]) => {
let source = assetSource.source()

if (typeof source !== 'string') {
source = source.toString('utf-8')
}

const matches = Array.from(source.matchAll(/\$&/g))
for (const match of matches) {
const error = new compilation.compiler.webpack.WebpackError(`Character sequence known to break APM injection detected: ${match[0]}`)
error.file = assetKey
compilation.errors.push(error)
}
})
}
)
})
}
}
35 changes: 0 additions & 35 deletions tools/webpack/plugins/nrba-chunking/index.mjs

This file was deleted.

Loading

0 comments on commit a9b6f2e

Please sign in to comment.