Files
safe-wallet/apps/web/plugins/sri-manifest-webpack-plugin.mjs
f2864e2fc1 feat(web): add SRI support for dynamically loaded webpack chunks (#7026)
* feat(web): add SRI support for dynamically loaded webpack chunks

Patches webpack runtime to inject integrity attributes for chunks
loaded via import() and React.lazy(). The script now:

1. Patches webpack-*.js to look up hashes from __CHUNK_SRI_MANIFEST
2. Generates SRI manifest with hashes for all chunks
3. Injects manifest into HTML files
4. Adds integrity attributes to static script tags

This closes the gap where dynamic imports were not covered by SRI,
while page route loading was already patched via route-loader.js.

* refactor(web): make SRI webpack patching robust to minification changes

Previously hardcoded the ".tu" method name when patching webpack runtime,
but minifiers can use any identifier. Now dynamically detect the actual
method name by searching for webpack's createScriptURL assignment pattern.

* refactor(web): convert SRI implementation to hybrid webpack plugin approach

Convert the post-build SRI implementation to a hybrid approach that leverages webpack's compilation lifecycle:

- Create webpack plugin (SriManifestWebpackPlugin) that:
  - Computes SRI hashes for all JS chunks during compilation
  - Patches webpack runtime to inject SRI lookup code
  - Generates and emits chunks-sri-manifest.js file

- Simplify post-build script to only handle HTML:
  - Inject manifest script tag into HTML files
  - Add integrity attributes to static script tags
  - Remove webpack-related functions (~55% code reduction)

This approach is cleaner, more reliable, and more maintainable than file-based patching after minification.

* refactor(web): address SRI implementation code review feedback

Critical improvements:
1. Add multiple fallback patterns for webpack minification detection
2. Add build-time validation that patching succeeded
3. Fail build if no webpack runtime files can be patched
4. Add runtime manifest validation with format checking and Object.freeze
5. Replace console.log with webpack compilation warnings/errors

Performance improvements:
6. Optimize HTML processing to single-pass (was parsing each file twice)
7. Use statSync instead of existsSync + lstatSync

Code quality:
8. Add proper error handling with informative messages
9. Add validation marker check after patching
10. Return processing counts for better observability

Addresses review feedback: https://github.com/safe-global/safe-wallet-monorepo/pull/7026#discussion_r1901234567

* fix(web): adjust SRI plugin validation to handle intermediate webpack compilations

- Change error to warning when no webpack runtime files are patched
- This handles server-side and intermediate compilations that don't need SRI patching
- Final client compilation still gets properly patched (verified with __CHUNK_SRI_MANIFEST marker)
- Build now succeeds with warnings instead of failing

* fix(web): prevent server/edge compilations from overwriting SRI manifest

The SRI webpack plugin was running for all Next.js compilations (client,
server, edge), causing later compilations to overwrite the valid client
manifest with empty manifests (0 chunks).

This fix adds a check to only generate the SRI manifest during the client
compilation, which is the only one that has browser JS chunks.

Fixes E2E test failures caused by empty SRI manifest.

* fix(ci): run integrity script after build to inject SRI manifest

The SRI manifest was being generated by webpack during build, but the
post-build HTML processing step that injects the manifest <script> tag
into HTML files was never run in CI.

This adds a step to run `yarn workspace @safe-global/web integrity`
after the build completes, which:
1. Injects the SRI manifest script tag into all HTML files
2. Adds integrity attributes to static script tags

Fixes E2E test failures caused by missing SRI manifest in HTML.

* fix(web): compute SRI hashes from actual files after Next.js export

The previous approach computed SRI hashes during webpack compilation,
but Next.js export mode writes files to disk AFTER webpack completes.
This caused hash mismatches because the files on disk were different
from what was hashed during compilation, leading to browsers blocking
scripts due to integrity check failures.

Changes:
- Webpack plugin now only patches runtime for SRI, doesn't generate manifest
- Integrity script generates chunk manifest from actual files on disk
- This ensures hashes match the final exported files

This fixes all E2E test failures caused by SRI validation errors.

* fix(ci): remove duplicate SRI script execution in deploy workflow

The integrity script was running twice:
1. In the build action (which runs for all builds including Cypress tests)
2. In the deploy workflow (which was the original location)

This caused the manifest script tag to be injected twice into HTML files,
potentially causing JavaScript errors or unexpected behavior in tests.

Now the script only runs once in the build action, which is called by
both the Cypress workflow and the deploy workflow.

* feat(web): disable SRI for Cypress test environment

SRI validation adds complexity to the test environment that can cause
test failures. Since E2E tests run in a controlled environment where
integrity validation is not critical, we disable SRI when NODE_ENV=cypress.

This allows:
- Tests to run without SRI-related issues
- SRI to remain enabled in all production/staging deployments
- Faster test execution without hash validation overhead

Changes:
- Skip webpack SRI plugin when NODE_ENV=cypress
- Skip integrity script generation in Cypress builds

* fix(ci): set NODE_ENV=cypress for build action in Cypress workflow

The NODE_ENV=cypress was only set in the cypress-io/github-action step,
but the build action runs before that, so it didn't have NODE_ENV set.

This fix passes NODE_ENV=cypress to the build action so that:
- Webpack SRI plugin is skipped during build
- Integrity script generation is skipped after build

This ensures Cypress tests run without any SRI validation.

* fix(web): fix syntax error in webpack runtime SRI patching

The previous regex replacement was creating invalid JavaScript by not
properly handling trailing characters after the src assignment. When
webpack had code like:

  r.src=c.tu(d)),e[d]=[f]

The replacement would insert code but leave the stray `)`:

  r.src=c.tu(d);...r.integrity=_sri[d]),e[d]=[f]
                                      ^ syntax error

This fix:
1. Captures the trailing character ([,);]) after the closing paren
2. Uses comma operators instead of semicolons to maintain expression flow
3. Uses && short-circuit instead of if statement for inline expression
4. Preserves the trailing character at the end

Result:
  r.src=c.tu(d),_sri=...,_sri[d]&&(r.integrity=_sri[d])),e[d]=[f]

This generates syntactically valid JavaScript that properly integrates
with webpack's minified code structure.

Fixes: Uncaught SyntaxError: Unexpected token ';'

* chore: fix prettier formatting and ignore build output

- Add apps/web/out to .prettierignore
- Fix formatting in sri-manifest-webpack-plugin.mjs

* fix(web): fix ReferenceError for _sri variable in webpack runtime

The previous code tried to assign to _sri without declaring it, which
fails in strict mode. This fix avoids using a temporary variable and
instead directly checks window.__CHUNK_SRI_MANIFEST inline.

Fixes: ReferenceError: _sri is not defined

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-01-27 16:11:58 +01:00

298 lines
12 KiB
JavaScript

import crypto from 'crypto'
import path from 'path'
const MANIFEST_FILENAME = 'chunks-sri-manifest.js'
/**
* Webpack plugin that generates SRI (Subresource Integrity) hashes for all JS chunks
* and patches the webpack runtime to inject integrity attributes for dynamically loaded chunks.
*
* This plugin runs during webpack's compilation phase and:
* 1. Computes SHA-384 hashes for all JS chunk files
* 2. Patches webpack runtime chunks to inject SRI lookup code
* 3. Generates and emits the SRI manifest file
*
* The post-build script then handles HTML manipulation (injecting the manifest script tag).
*/
export class SriManifestWebpackPlugin {
constructor(options = {}) {
this.options = {
manifestFilename: options.manifestFilename || MANIFEST_FILENAME,
chunksPath: options.chunksPath || '_next/static/chunks',
}
}
apply(compiler) {
const pluginName = 'SriManifestWebpackPlugin'
compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: pluginName,
// Run at OPTIMIZE_HASH stage so we can modify assets before final output
stage: compilation.constructor.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH,
},
(assets) => {
try {
// Next.js runs multiple compilations (client, server, edge).
// Only process the client compilation which has the actual chunks.
// Server/edge compilations don't have browser chunks and would generate empty manifests.
const isClient = !compilation.options.name || compilation.options.name === 'client'
if (!isClient) {
// Skip server/edge compilations - they don't have browser chunks
return
}
// Patch webpack runtime chunks to inject SRI lookup for dynamic chunks
// Note: The actual SRI manifest is generated post-build by the integrity script
// to ensure hashes match the final files on disk (after Next.js export)
this.patchWebpackRuntime(compilation, assets)
const info = new Error('Patched webpack runtime for SRI dynamic chunk loading')
info.name = 'SriManifestInfo'
compilation.warnings.push(info)
} catch (error) {
compilation.errors.push(new Error(`${pluginName}: ${error.message}`))
}
},
)
})
}
/**
* Finds the minified method name webpack uses for creating script URLs.
* This is typically `c.tu` but the minifier can use any identifier.
*
* Tries multiple patterns to handle different minification strategies.
*
* @param {string} content - The webpack runtime file content
* @returns {{varName: string, methodName: string} | null} The extracted identifiers or null
*/
findWebpackUrlMethod(content) {
// Try multiple patterns to handle different minification strategies
const patterns = [
// Pattern 1: Standard minified format with arrow function
// someVar.someMethod=e=>someVar.tt().createScriptURL(e)
/(\w)\.(\w+)=\w=>\1\.tt\(\)\.createScriptURL\(\w\)/,
// Pattern 2: With function keyword (less common but possible)
// someVar.someMethod=function(e){return someVar.tt().createScriptURL(e)}
/(\w)\.(\w+)=function\(\w\)\{return \1\.tt\(\)\.createScriptURL\(\w\)\}/,
// Pattern 3: With different whitespace/formatting
/(\w)\.(\w+)\s*=\s*\w\s*=>\s*\1\.tt\(\)\.createScriptURL\(\w\)/,
]
for (const pattern of patterns) {
const match = content.match(pattern)
if (match) {
return {
varName: match[1],
methodName: match[2],
}
}
}
return null
}
/**
* Patch webpack runtime chunks to inject SRI lookup for dynamically loaded chunks.
*
* Webpack's chunk loader (`__webpack_require__.l`) creates script tags for dynamic imports.
* This function patches the minified webpack runtime to add integrity attributes
* by looking up hashes from `window.__CHUNK_SRI_MANIFEST`.
*
* @param {import('webpack').Compilation} compilation - Webpack compilation object
* @param {Object} assets - Webpack assets object
* @throws {Error} If webpack runtime files are not found or patching fails
*/
patchWebpackRuntime(compilation, assets) {
// Find webpack runtime assets (typically webpack-*.js)
// Asset names in webpack compilation are relative, e.g. "static/chunks/webpack-*.js"
const webpackAssets = Object.keys(assets).filter((name) => name.includes('webpack-') && name.endsWith('.js'))
if (webpackAssets.length === 0) {
// During client/server compilation, webpack runtime might not be in this compilation
// Only warn - the final client compilation will have the runtime
const warning = new Error(
'No webpack runtime files found in this compilation. If this is the final build, SRI patching failed.',
)
warning.name = 'SriManifestWarning'
compilation.warnings.push(warning)
return
}
let patchedCount = 0
for (const assetName of webpackAssets) {
const asset = assets[assetName]
let content = asset.source().toString()
const originalContent = content
// Dynamically find the webpack URL method name (e.g., 'tu', 'ab', etc.)
const urlMethod = this.findWebpackUrlMethod(content)
if (!urlMethod) {
const warning = new Error(
`Could not find webpack URL method in ${path.basename(assetName)}. The webpack runtime structure may have changed. SRI for dynamic chunks will not work.`,
)
warning.name = 'SriManifestWarning'
compilation.warnings.push(warning)
continue
}
// Pattern: scriptVar.src = webpackObj.urlMethod(urlVar)
// This is webpack's __webpack_require__.l function setting script.src
// We inject SRI lookup right after the src is set
//
// The pattern needs to capture the character after the closing paren (could be ), comma, semicolon, etc.)
// to preserve the original code structure and avoid syntax errors
//
// Example matches (depending on minified names):
// r.src=c.tu(d)), -> r.src=c.tu(d),_sri=...,_sri[d]&&(r.integrity=_sri[d])),
// a.src=o.ab(e), -> a.src=o.ab(e),_sri=...,_sri[e]&&(a.integrity=_sri[e]),
//
// Using comma operator to maintain expression flow without semicolons
const pattern = new RegExp(`(\\w)\\.src=(\\w)\\.${urlMethod.methodName}\\((\\w)\\)([,);])`, 'g')
content = content.replace(pattern, (match, scriptVar, webpackObj, urlVar, trailingChar) => {
// Use comma operator to chain expressions without breaking syntax
// Check if manifest exists and has the hash, then set integrity attribute
// Repeating the window lookup avoids variable scoping issues in strict mode
return `${scriptVar}.src=${webpackObj}.${urlMethod.methodName}(${urlVar}),window.__CHUNK_SRI_MANIFEST&&window.__CHUNK_SRI_MANIFEST[${urlVar}]&&(${scriptVar}.integrity=window.__CHUNK_SRI_MANIFEST[${urlVar}])${trailingChar}`
})
if (content !== originalContent) {
// Validate that the patch was applied correctly by checking for our marker
if (!content.includes('__CHUNK_SRI_MANIFEST')) {
throw new Error(`Failed to inject SRI code into ${path.basename(assetName)}. Build validation failed.`)
}
// Update the asset with the patched content
compilation.updateAsset(assetName, new compilation.compiler.webpack.sources.RawSource(content))
patchedCount++
const info = new Error(
`Patched webpack runtime for SRI (method: ${urlMethod.methodName}): ${path.basename(assetName)}`,
)
info.name = 'SriManifestInfo'
compilation.warnings.push(info)
} else {
const warning = new Error(
`Could not find webpack chunk loader pattern in ${path.basename(assetName)}. The script.src assignment may have changed. SRI for dynamic chunks will not work.`,
)
warning.name = 'SriManifestWarning'
compilation.warnings.push(warning)
}
}
// Validate that at least one file was successfully patched
if (patchedCount === 0 && webpackAssets.length > 0) {
// Warn if we found webpack assets but couldn't patch any of them
// This might be OK if the webpack runtime doesn't have dynamic chunk loading
const warning = new Error(
`Could not patch any of ${webpackAssets.length} webpack runtime file(s). ` +
`If this is the client build and you use dynamic imports, SRI may not work for dynamic chunks.`,
)
warning.name = 'SriManifestWarning'
compilation.warnings.push(warning)
}
}
/**
* Compute the SHA-384 SRI hash for given content
* @param {Buffer|string} content - File content
* @returns {string} SRI hash in format "sha384-..."
*/
computeSriHash(content) {
const hash = crypto.createHash('sha384').update(content).digest('base64')
return `sha384-${hash}`
}
/**
* Generate SRI manifest for all JS chunk files
* @param {import('webpack').Compilation} compilation - Webpack compilation object
* @param {Object} assets - Webpack assets object
* @returns {Object} Manifest mapping public paths to SRI hashes
*/
generateManifest(compilation, assets) {
const manifest = {}
// Find all JS assets in the chunks directory
for (const assetName of Object.keys(assets)) {
// Only process JS files in the chunks directory (static/chunks/* or pages/*)
if (!assetName.endsWith('.js') || !assetName.includes('static/chunks')) {
continue
}
const asset = assets[assetName]
const content = asset.source()
// Compute SRI hash
const hash = this.computeSriHash(content)
// Create public path (e.g., "/_next/static/chunks/foo.js")
// Webpack asset names are like "static/chunks/foo.js", we need "/_next/static/chunks/foo.js"
const publicPath = `/_next/${assetName}`
manifest[publicPath] = hash
}
return manifest
}
/**
* Emit the SRI manifest as a webpack asset
* @param {import('webpack').Compilation} compilation - Webpack compilation object
* @param {Object} manifest - SRI manifest object
*/
emitManifest(compilation, manifest) {
const manifestJson = JSON.stringify(manifest, null, 2)
// Add runtime validation and integrity check
const fileContents = `/**
* Auto-generated chunk SRI manifest.
* DO NOT EDIT.
* Generated at: ${new Date().toISOString()}
*/
(function() {
'use strict';
// Validate manifest integrity
var manifest = ${manifestJson};
// Basic validation: check manifest is an object with valid SRI hashes
if (typeof manifest !== 'object' || manifest === null) {
console.error('[SRI] Invalid manifest: not an object');
return;
}
// Validate hash format for first entry (sha384-base64)
var firstKey = Object.keys(manifest)[0];
if (firstKey && !/^sha384-[A-Za-z0-9+/=]+$/.test(manifest[firstKey])) {
console.error('[SRI] Invalid manifest: malformed hash format');
return;
}
// Freeze manifest to prevent tampering
if (Object.freeze) {
Object.freeze(manifest);
}
window.__CHUNK_SRI_MANIFEST = manifest;
})();
`
// Emit the manifest file in the chunks directory
// Webpack asset paths are relative, e.g. "static/chunks/chunks-sri-manifest.js"
const manifestPath = path.posix.join('static/chunks', this.options.manifestFilename)
compilation.emitAsset(manifestPath, new compilation.compiler.webpack.sources.RawSource(fileContents))
const info = new Error(`Emitted SRI manifest: ${manifestPath}`)
info.name = 'SriManifestInfo'
compilation.warnings.push(info)
}
}