From 15ccf24d79acb6e3698c271aee0784ece14b96d4 Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Fri, 27 Oct 2023 14:05:06 -0700 Subject: [PATCH] [web] Add 'nonce' prop to flutter.js loadEntrypoint (#137204) ## Description This PR adds a `nonce` parameter to flutter.js' `loadEntrypoint` method. When set, loadEntrypoint will add a `nonce` attribute to the `main.dart.js` script tag, which allows Flutter to run in environments slightly more restricted by CSP; those that don't add `'self'` as a valid source for `script-src`. ---- ### CSP directive After this change, the CSP directive for a Flutter Web index.html can be: ``` script-src 'nonce-YOUR_NONCE_VALUE' 'wasm-unsafe-eval'; font-src https://fonts.gstatic.com; style-src 'nonce-YOUR_NONCE_VALUE'; ``` When CSP is set via a `meta` tag (like in the test accompanying this change), and to use a service worker, the CSP needs an additional directive: [`worker-src 'self';`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src) When CSP set via response headers, the CSP that applies to `flutter_service_worker.js` is determined by its response headers. See **Web Workers API > [Content security policy](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers#content_security_policy)** in MDN.) ---- ### Initialization If the CSP is set to disallow `script-src 'self'`, a nonce needs to also be passed to `loadEntrypoint`: ```javascript _flutter.loader.loadEntrypoint({ nonce: 'SOME_NONCE', onEntrypointLoaded: (engineInitializer) async { const appRunner = await engineInitializer.initializeEngine({ nonce: 'SOME_NONCE', }); appRunner.runApp(); }, }); ``` (`nonce` shows twice for now, because the entrypoint loader script doesn't have direct access to the `initializeEngine` call.) ---- ## Tests * Added a smoke test to ensure an app configured as described above starts. ## Issues * Fixes https://github.com/flutter/flutter/issues/126977 --- dev/bots/service_worker_test.dart | 5 ++ dev/bots/test.dart | 1 + .../web/index_with_flutterjs_el_nonce.html | 49 +++++++++++++++++++ .../lib/src/web/file_generators/js/flutter.js | 13 +++-- 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 dev/integration_tests/web/web/index_with_flutterjs_el_nonce.html diff --git a/dev/bots/service_worker_test.dart b/dev/bots/service_worker_test.dart index 98e26be173..5860ce9f8c 100644 --- a/dev/bots/service_worker_test.dart +++ b/dev/bots/service_worker_test.dart @@ -37,6 +37,8 @@ enum ServiceWorkerTestType { withFlutterJsEntrypointLoadedEvent, // Same as withFlutterJsEntrypointLoadedEvent, but with TrustedTypes enabled. withFlutterJsTrustedTypesOn, + // Same as withFlutterJsEntrypointLoadedEvent, but with nonce required. + withFlutterJsNonceOn, // Uses custom serviceWorkerVersion. withFlutterJsCustomServiceWorkerVersion, // Entrypoint generated by `flutter create`. @@ -53,6 +55,7 @@ Future main() async { await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsTrustedTypesOn); + await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsNonceOn); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); @@ -120,6 +123,8 @@ String _testTypeToIndexFile(ServiceWorkerTestType type) { indexFile = 'index_with_flutterjs_entrypoint_loaded.html'; case ServiceWorkerTestType.withFlutterJsTrustedTypesOn: indexFile = 'index_with_flutterjs_el_tt_on.html'; + case ServiceWorkerTestType.withFlutterJsNonceOn: + indexFile = 'index_with_flutterjs_el_nonce.html'; case ServiceWorkerTestType.withFlutterJsCustomServiceWorkerVersion: indexFile = 'index_with_flutterjs_custom_sw_version.html'; case ServiceWorkerTestType.generatedEntrypoint: diff --git a/dev/bots/test.dart b/dev/bots/test.dart index fa0b884c2a..a8c06c8a5a 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -1260,6 +1260,7 @@ Future _runWebLongRunningTests() async { () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsTrustedTypesOn), + () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsNonceOn), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), diff --git a/dev/integration_tests/web/web/index_with_flutterjs_el_nonce.html b/dev/integration_tests/web/web/index_with_flutterjs_el_nonce.html new file mode 100644 index 0000000000..fe9c761c5d --- /dev/null +++ b/dev/integration_tests/web/web/index_with_flutterjs_el_nonce.html @@ -0,0 +1,49 @@ + + + + + + + + Integration test. App load with flutter.js and onEntrypointLoaded API. nonce required. + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_tools/lib/src/web/file_generators/js/flutter.js b/packages/flutter_tools/lib/src/web/file_generators/js/flutter.js index 4fd0e51f34..ac0e9fc333 100644 --- a/packages/flutter_tools/lib/src/web/file_generators/js/flutter.js +++ b/packages/flutter_tools/lib/src/web/file_generators/js/flutter.js @@ -244,10 +244,10 @@ _flutter.loader = null; * Returns undefined when an `onEntrypointLoaded` callback is supplied in `options`. */ async loadEntrypoint(options) { - const { entrypointUrl = `${baseUri}main.dart.js`, onEntrypointLoaded } = + const { entrypointUrl = `${baseUri}main.dart.js`, onEntrypointLoaded, nonce } = options || {}; - return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded); + return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded, nonce); } /** @@ -286,12 +286,12 @@ _flutter.loader = null; * is loaded, or undefined if `onEntrypointLoaded` * is a function. */ - _loadEntrypoint(entrypointUrl, onEntrypointLoaded) { + _loadEntrypoint(entrypointUrl, onEntrypointLoaded, nonce) { const useCallback = typeof onEntrypointLoaded === "function"; if (!this._scriptLoaded) { this._scriptLoaded = true; - const scriptTag = this._createScriptTag(entrypointUrl); + const scriptTag = this._createScriptTag(entrypointUrl, nonce); if (useCallback) { // Just inject the script tag, and return nothing; Flutter will call // `didCreateEngineInitializer` when it's done. @@ -319,9 +319,12 @@ _flutter.loader = null; * @param {string} url * @returns {HTMLScriptElement} */ - _createScriptTag(url) { + _createScriptTag(url, nonce) { const scriptTag = document.createElement("script"); scriptTag.type = "application/javascript"; + if (nonce) { + scriptTag.nonce = nonce; + } // Apply TrustedTypes validation, if available. let trustedUrl = url; if (this._ttPolicy != null) {