diff --git a/app/helpers/script_helper.rb b/app/helpers/script_helper.rb index 4ab9708d705..1d2a7e8ac2d 100644 --- a/app/helpers/script_helper.rb +++ b/app/helpers/script_helper.rb @@ -23,7 +23,13 @@ def render_javascript_pack_once_tags(*names) [ javascript_assets_tag(*@scripts), javascript_polyfill_pack_tag, - javascript_include_tag(*sources, crossorigin: local_crossorigin_sources? ? true : nil), + *sources.map do |source| + javascript_include_tag( + source, + crossorigin: local_crossorigin_sources? ? true : nil, + integrity: AssetSources.get_integrity(source), + ) + end, ], ) end diff --git a/lib/asset_sources.rb b/lib/asset_sources.rb index 067d81f748f..1e2579d4922 100644 --- a/lib/asset_sources.rb +++ b/lib/asset_sources.rb @@ -9,7 +9,7 @@ def get_sources(*names) # See: app/javascript/packages/rails-i18n-webpack-plugin/extract-keys-webpack-plugin.js regexp_locale_suffix = %r{\.(#{I18n.available_locales.join('|')})\.js$} - load_manifest if !manifest || !cache_manifest + load_manifest_if_needed locale_sources, sources = names.flat_map do |name| manifest&.dig('entrypoints', name, 'assets', 'js') @@ -22,13 +22,19 @@ def get_sources(*names) end def get_assets(*names) - load_manifest if !manifest || !cache_manifest + load_manifest_if_needed names.flat_map do |name| manifest&.dig('entrypoints', name, 'assets')&.except('js')&.values&.flatten end.uniq.compact end + def get_integrity(path) + load_manifest_if_needed + + manifest&.dig('integrity', path) + end + def load_manifest self.manifest = begin JSON.parse(File.read(manifest_path)) @@ -36,5 +42,11 @@ def load_manifest nil end end + + private + + def load_manifest_if_needed + load_manifest if !manifest || !cache_manifest + end end end diff --git a/spec/helpers/script_helper_spec.rb b/spec/helpers/script_helper_spec.rb index 1483415be93..ed1137d6f02 100644 --- a/spec/helpers/script_helper_spec.rb +++ b/spec/helpers/script_helper_spec.rb @@ -86,6 +86,25 @@ ) end + context 'with script integrity available' do + before do + allow(AssetSources).to receive(:get_integrity).and_return(nil) + allow(AssetSources).to receive(:get_integrity).with('/application.js'). + and_return('sha256-aztp/wpATyjXXpigZtP8ZP/9mUCHDMaL7OKFRbmnUIazQ9ehNmg4CD5Ljzym/TyA') + end + + it 'adds integrity attribute' do + output = render_javascript_pack_once_tags + + expect(output).to have_css( + "script[src^='/polyfill.js']:not([integrity]) ~ \ + script[src^='/application.js'][integrity^='sha256-']", + count: 1, + visible: :all, + ) + end + end + context 'local development crossorigin sources' do let(:webpack_port) { '3035' } diff --git a/spec/lib/asset_sources_spec.rb b/spec/lib/asset_sources_spec.rb index 70c369625fc..9c640345e2b 100644 --- a/spec/lib/asset_sources_spec.rb +++ b/spec/lib/asset_sources_spec.rb @@ -41,6 +41,9 @@ ] } } + }, + "integrity": { + "vendor.js": "sha256-aztp/wpATyjXXpigZtP8ZP/9mUCHDMaL7OKFRbmnUIazQ9ehNmg4CD5Ljzym/TyA" } } STR @@ -136,6 +139,23 @@ end end + describe '.get_integrity' do + let(:path) { 'vendor.js' } + subject(:integrity) { AssetSources.get_integrity(path) } + + it 'returns the integrity hash' do + expect(integrity).to start_with('sha256-') + end + + context 'a path which does not exist in the manifest' do + let(:path) { 'missing.js' } + + it 'returns nil' do + expect(integrity).to be_nil + end + end + end + describe '.load_manifest' do it 'sets the manifest' do AssetSources.load_manifest diff --git a/webpack.config.js b/webpack.config.js index 4f61c742a80..13ff61f0d82 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -77,7 +77,20 @@ module.exports = /** @type {import('webpack').Configuration} */ ({ : filename; }, writeToDisk: true, + integrity: isProductionEnv, output: 'manifest.json', + transform(manifest) { + const srcIntegrity = {}; + for (const [key, { src, integrity }] of Object.entries(manifest)) { + if (integrity) { + srcIntegrity[src] = integrity; + delete manifest[key]; + } + } + + manifest.integrity = srcIntegrity; + return manifest; + }, }), new RailsI18nWebpackPlugin({ onMissingString(key, locale) {