Feature: Introduce subresource integrity checks (SRI) for frontend assets (#100983)

* feat(featuremgmt): introduce feature toggle for enabling sri checks

* feat(frontend): use assetSriChecks feature toggle to inject integrity hash into script tags

* chore(webpack): align sri algorithms across dev and prod builds

* docs(featuremgmt): update assetSriChecks to pass CI

* docs(featuremgmt): fix more spelling complaints with assetSriChecks

* Add crossorigin attribute

* chore(webpack): add subresource-integrity plugin

* build(webpack): wrap webpack jsonp loader integrity checks in feature flag checks

* revert(index.html): remove crossorigin attribute if assertSriChecks is disabled

---------

Co-authored-by: Kristian Bremberg <kristian.bremberg@grafana.com>
pull/101659/head
Jack Westbrook 3 months ago committed by GitHub
parent bf9a34f2ca
commit bbfeb8d220
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      package.json
  3. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 1
      pkg/services/featuremgmt/codeowners.go
  5. 7
      pkg/services/featuremgmt/registry.go
  6. 1
      pkg/services/featuremgmt/toggles_gen.csv
  7. 4
      pkg/services/featuremgmt/toggles_gen.go
  8. 16
      pkg/services/featuremgmt/toggles_gen.json
  9. 27
      public/views/index.html
  10. 66
      scripts/webpack/plugins/FeatureFlaggedSriPlugin.js
  11. 1
      scripts/webpack/webpack.dev.js
  12. 8
      scripts/webpack/webpack.prod.js
  13. 14
      yarn.lock

@ -226,6 +226,7 @@ Experimental features might be changed or removed without prior notice.
| `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page |
| `newLogsPanel` | Enables the new logs panel in Explore |
| `pluginsCDNSyncLoader` | Load plugins from CDN synchronously |
| `assetSriChecks` | Enables SRI checks for Grafana JavaScript assets |
## Development feature toggles

@ -248,6 +248,7 @@
"webpack-livereload-plugin": "3.0.2",
"webpack-manifest-plugin": "5.0.0",
"webpack-merge": "6.0.1",
"webpack-subresource-integrity": "^5.2.0-rc.1",
"webpackbar": "^7.0.0",
"yaml": "^2.0.0",
"yargs": "^17.5.1"

@ -254,4 +254,5 @@ export interface FeatureToggles {
alertingRuleVersionHistoryRestore?: boolean;
newShareReportDrawer?: boolean;
rendererDisableAppPluginsPreload?: boolean;
assetSriChecks?: boolean;
}

@ -13,6 +13,7 @@ const (
grafanaBackendServicesSquad codeowner = "@grafana/grafana-backend-services-squad"
grafanaSearchAndStorageSquad codeowner = "@grafana/search-and-storage"
grafanaPluginsPlatformSquad codeowner = "@grafana/plugins-platform-backend"
grafanaFrontendOpsWG codeowner = "@grafana/frontend-ops"
grafanaAsCodeSquad codeowner = "@grafana/grafana-as-code"
identityAccessTeam codeowner = "@grafana/identity-access-team"
grafanaObservabilityLogsSquad codeowner = "@grafana/observability-logs"

@ -1775,6 +1775,13 @@ var (
HideFromDocs: true,
FrontendOnly: true,
},
{
Name: "assetSriChecks",
Description: "Enables SRI checks for Grafana JavaScript assets",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendOpsWG,
FrontendOnly: true,
},
}
)

@ -235,3 +235,4 @@ alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true
alertingRuleVersionHistoryRestore,GA,@grafana/alerting-squad,false,false,true
newShareReportDrawer,experimental,@grafana/sharing-squad,false,false,false
rendererDisableAppPluginsPreload,experimental,@grafana/sharing-squad,false,false,true
assetSriChecks,experimental,@grafana/frontend-ops,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
235 alertingRuleVersionHistoryRestore GA @grafana/alerting-squad false false true
236 newShareReportDrawer experimental @grafana/sharing-squad false false false
237 rendererDisableAppPluginsPreload experimental @grafana/sharing-squad false false true
238 assetSriChecks experimental @grafana/frontend-ops false false true

@ -950,4 +950,8 @@ const (
// FlagRendererDisableAppPluginsPreload
// Disable pre-loading app plugins when the request is coming from the renderer
FlagRendererDisableAppPluginsPreload = "rendererDisableAppPluginsPreload"
// FlagAssetSriChecks
// Enables SRI checks for Grafana JavaScript assets
FlagAssetSriChecks = "assetSriChecks"
)

@ -598,6 +598,22 @@
"codeowner": "@grafana/grafana-frontend-platform"
}
},
{
"metadata": {
"name": "assetSriChecks",
"resourceVersion": "1739984409734",
"creationTimestamp": "2025-02-19T15:56:59Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-02-19 17:00:09.734088 +0000 UTC"
}
},
"spec": {
"description": "Enables SRI checks for Grafana JavaScript assets",
"stage": "experimental",
"codeowner": "@grafana/frontend-ops",
"frontend": true
}
},
{
"metadata": {
"name": "authAPIAccessTokenAuth",

@ -355,13 +355,26 @@
<!-- End Google Tag Manager -->
[[end]]
[[range $asset := .Assets.JSFiles]]
<script
nonce="[[$.Nonce]]"
src="[[$asset.FilePath]]"
type="text/javascript"
defer
></script>
[[if .Settings.FeatureToggles.assetSriChecks ]]
[[range $asset := .Assets.JSFiles]]
<script
nonce="[[$.Nonce]]"
src="[[$asset.FilePath]]"
integrity="[[$asset.Integrity]]"
crossorigin="anonymous"
type="text/javascript"
defer
></script>
[[end]]
[[else]]
[[range $asset := .Assets.JSFiles]]
<script
nonce="[[$.Nonce]]"
src="[[$asset.FilePath]]"
type="text/javascript"
defer
></script>
[[end]]
[[end]]
<script nonce="[[.Nonce]]">

@ -0,0 +1,66 @@
// @ts-check
const webpack = require('webpack');
/** @typedef {import('webpack/lib/Compiler.js')} Compiler */
const PLUGIN_NAME = 'FeatureFlaggedSRIPlugin';
const FEATURE_TOGGLE_WRAP = [
'if (window.grafanaBootData && window.grafanaBootData.settings && window.grafanaBootData.settings.featureToggles && window.grafanaBootData.settings.featureToggles.assetSriChecks) {',
'}',
];
/**
* Webpack plugin that wraps Webpack runtime integrity checks in a feature flag
* This allows us to disable SRI checks in both the initial chunks but also in the
* dynamically loaded chunks.
*/
class FeatureFlaggedSRIPlugin {
/**
* @param {Compiler} compiler The webpack compiler instance
*/
apply(compiler) {
compiler.hooks.afterPlugins.tap(PLUGIN_NAME, (compiler) => {
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);
compiler.hooks.thisCompilation.tap(
{
name: PLUGIN_NAME,
},
(compilation) => {
const { mainTemplate } = compilation;
mainTemplate.hooks.jsonpScript.tap(
PLUGIN_NAME,
/**
* @param {string} source
*/
(source) => {
if (source.includes('script.integrity =')) {
logger.log('FeatureFlaggedSRIPlugin: Wrapping SRI checks in feature flag');
return createFeatureFlaggedSRITemplate(source);
}
return source;
}
);
}
);
});
}
}
/**
* Creates a template string wrapping the integrity and crossorigin attributes in a feature flag
* @param {string} source The original webpack template source
* @returns {string} The modified template source
*/
function createFeatureFlaggedSRITemplate(source) {
const lines = source.split('\n');
const integrityAttributeLineNumber = lines.findIndex((line) => line.includes('script.integrity ='));
const [prefix, suffix] = FEATURE_TOGGLE_WRAP;
return webpack.Template.asString([
...lines.slice(0, integrityAttributeLineNumber),
prefix,
webpack.Template.indent(lines.slice(integrityAttributeLineNumber)),
suffix,
]);
}
module.exports = FeatureFlaggedSRIPlugin;

@ -149,6 +149,7 @@ module.exports = (env = {}) => {
new WebpackAssetsManifest({
entrypoints: true,
integrity: true,
integrityHashes: ['sha384', 'sha512'],
publicPath: true,
}),
new WebpackBar({

@ -10,8 +10,10 @@ const { EnvironmentPlugin } = require('webpack');
const WebpackAssetsManifest = require('webpack-assets-manifest');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const { merge } = require('webpack-merge');
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
const getEnvConfig = require('./env-util.js');
const FeatureFlaggedSRIPlugin = require('./plugins/FeatureFlaggedSriPlugin');
const common = require('./webpack.common.js');
const esbuildTargets = resolveToEsbuildTarget(browserslist(), { printUnknownTargets: false });
@ -51,6 +53,9 @@ module.exports = (env = {}) =>
}),
],
},
output: {
crossOriginLoading: 'anonymous',
},
optimization: {
nodeEnv: 'production',
minimize: parseInt(env.noMinify, 10) !== 1,
@ -70,6 +75,8 @@ module.exports = (env = {}) =>
new MiniCssExtractPlugin({
filename: 'grafana.[name].[contenthash].css',
}),
new SubresourceIntegrityPlugin(),
new FeatureFlaggedSRIPlugin(),
/**
* I know we have two manifest plugins here.
* WebpackManifestPlugin was only used in prod before and does not support integrity hashes
@ -77,6 +84,7 @@ module.exports = (env = {}) =>
new WebpackAssetsManifest({
entrypoints: true,
integrity: true,
integrityHashes: ['sha384', 'sha512'],
publicPath: true,
}),
new WebpackManifestPlugin({

@ -18449,6 +18449,7 @@ __metadata:
webpack-livereload-plugin: "npm:3.0.2"
webpack-manifest-plugin: "npm:5.0.0"
webpack-merge: "npm:6.0.1"
webpack-subresource-integrity: "npm:^5.2.0-rc.1"
webpackbar: "npm:^7.0.0"
whatwg-fetch: "npm:3.6.20"
yaml: "npm:^2.0.0"
@ -31911,6 +31912,19 @@ __metadata:
languageName: node
linkType: hard
"webpack-subresource-integrity@npm:^5.2.0-rc.1":
version: 5.2.0-rc.1
resolution: "webpack-subresource-integrity@npm:5.2.0-rc.1"
peerDependencies:
html-webpack-plugin: ">= 5.0.0-beta.1 < 6"
webpack: ^5.12.0
peerDependenciesMeta:
html-webpack-plugin:
optional: true
checksum: 10/a63e4e999812a753c70070457e6a2fb7e7ef1a05d8525a89796024fc56f26a0e56bd3e8f2b25e61f0ae969d627770a1fe326bebb58c296577d40c6a12aa4be37
languageName: node
linkType: hard
"webpack-virtual-modules@npm:^0.5.0":
version: 0.5.0
resolution: "webpack-virtual-modules@npm:0.5.0"

Loading…
Cancel
Save