From 7ecb2c923633827efcf9cad1bd665806135d7cb5 Mon Sep 17 00:00:00 2001 From: Paul Berry Date: Tue, 12 Nov 2024 20:44:52 +0000 Subject: [PATCH 1/2] Set nonce properly in loadWebSdk(). --- .../src/js_interop/package_web_tweaks.dart | 12 +++++++++++ .../lib/src/js_loader.dart | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart b/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart index ed55d5d8129..e841aaa54d0 100644 --- a/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart +++ b/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart @@ -34,3 +34,15 @@ extension CreateScriptUrlNoArgs on web.TrustedTypePolicy { String input, ); } + +/// This extension gives web.HTMLScriptElement a nullable getter to the +/// `nonce` property, which needs to be used to check for feature support. +extension NullableNonceGetter on web.HTMLScriptElement { + /// (Nullable) Bindings to HTMLScriptElement.nonce. + /// + /// This may be null if the browser doesn't support the Nonce API. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce + @JS('nonce') + external String? get nullableNonce; +} diff --git a/packages/google_identity_services_web/lib/src/js_loader.dart b/packages/google_identity_services_web/lib/src/js_loader.dart index e32f59c802b..2cd1aa8ee8b 100644 --- a/packages/google_identity_services_web/lib/src/js_loader.dart +++ b/packages/google_identity_services_web/lib/src/js_loader.dart @@ -48,6 +48,7 @@ Future loadWebSdk({ ..defer = true; if (trustedUrl != null) { script.trustedSrc = trustedUrl; + if (_getNonce() case var nonce?) script.nonce = nonce; } else { script.src = _url; } @@ -57,6 +58,25 @@ Future loadWebSdk({ return completer.future; } +/// Returns CSP nonce, if set for any script tag. +String? _getNonce({web.Window? window}) { + final currentWindow = window ?? web.window; + final elements = currentWindow.document.querySelectorAll('script'); + for (var i = 0; i < elements.length; i++) { + if (elements.item(i) case web.HTMLScriptElement element) { + final nonceValue = element.nullableNonce ?? element.getAttribute('nonce'); + if (nonceValue != null && _noncePattern.hasMatch(nonceValue)) { + return nonceValue; + } + } + } + return null; +} + +// According to the CSP3 spec a nonce must be a valid base64 string. +// https://w3c.github.io/webappsec-csp/#grammardef-base64-value +final _noncePattern = RegExp('^[\\w+/_-]+[=]{0,2}\$'); + /// Exception thrown if the Trusted Types feature is supported, enabled, and it /// has prevented this loader from injecting the JS SDK. class TrustedTypesException implements Exception { From a0e7073daa40137e8b88587dcf2ee77aabd3a4c7 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 13 Nov 2024 18:43:26 -0800 Subject: [PATCH 2/2] Some tweaks to the PR. * Adds a `nonce` parameter to the `loadWebSdk` method. * Applies the `nonce` regardless of TrustedTypes being available or not (this is a CSP feature, more widely available than TTs) * Does not attempt to validate the `nonce` value, the browser should complain instead (in practice, any value seems valid, see tests) * Adds unit tests (dart test --platform chrome) --- .../google_identity_services_web/CHANGELOG.md | 4 + .../lib/src/js_loader.dart | 58 +++++++++----- .../google_identity_services_web/pubspec.yaml | 2 +- .../test/js_loader_test.dart | 76 ++++++++++++++++++- 4 files changed, 118 insertions(+), 22 deletions(-) diff --git a/packages/google_identity_services_web/CHANGELOG.md b/packages/google_identity_services_web/CHANGELOG.md index 8f476099fc4..f6d8de6fa0b 100644 --- a/packages/google_identity_services_web/CHANGELOG.md +++ b/packages/google_identity_services_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.2 + +* Adds the `nonce` parameter to `loadWebSdk`. + ## 0.3.1+5 * Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. diff --git a/packages/google_identity_services_web/lib/src/js_loader.dart b/packages/google_identity_services_web/lib/src/js_loader.dart index 2cd1aa8ee8b..af59526501b 100644 --- a/packages/google_identity_services_web/lib/src/js_loader.dart +++ b/packages/google_identity_services_web/lib/src/js_loader.dart @@ -16,10 +16,22 @@ const String _url = 'https://accounts.google.com/gsi/client'; // The default TrustedPolicy name that will be used to inject the script. const String _defaultTrustedPolicyName = 'gis-dart'; +// Sentinel value to tell apart when users explicitly set the nonce value to `null`. +const String _undefined = '___undefined___'; + /// Loads the GIS SDK for web, using Trusted Types API when available. +/// +/// This attempts to use Trusted Types when available, and creates a new policy +/// with the given [trustedTypePolicyName]. +/// +/// By default, the script will attempt to copy the `nonce` attribute from other +/// scripts in the page. The [nonce] parameter will be used when passed, and +/// not-null. When [nonce] parameter is explicitly `null`, no `nonce` +/// attribute is applied to the script. Future loadWebSdk({ web.HTMLElement? target, String trustedTypePolicyName = _defaultTrustedPolicyName, + String? nonce = _undefined, }) { final Completer completer = Completer(); onGoogleLibraryLoad = () => completer.complete(); @@ -42,41 +54,51 @@ Future loadWebSdk({ } } - final web.HTMLScriptElement script = - web.document.createElement('script') as web.HTMLScriptElement - ..async = true - ..defer = true; + final web.HTMLScriptElement script = web.HTMLScriptElement() + ..async = true + ..defer = true; if (trustedUrl != null) { script.trustedSrc = trustedUrl; - if (_getNonce() case var nonce?) script.nonce = nonce; } else { script.src = _url; } + if (_getNonce(suppliedNonce: nonce) case final String nonce?) { + script.nonce = nonce; + } + (target ?? web.document.head!).appendChild(script); return completer.future; } -/// Returns CSP nonce, if set for any script tag. -String? _getNonce({web.Window? window}) { - final currentWindow = window ?? web.window; - final elements = currentWindow.document.querySelectorAll('script'); - for (var i = 0; i < elements.length; i++) { - if (elements.item(i) case web.HTMLScriptElement element) { - final nonceValue = element.nullableNonce ?? element.getAttribute('nonce'); - if (nonceValue != null && _noncePattern.hasMatch(nonceValue)) { - return nonceValue; +/// Computes the actual nonce value to use. +/// +/// If [suppliedNonce] has been explicitly passed, returns that. +/// If `suppliedNonce` is null, it attempts to locate the `nonce` +/// attribute from other script in the page. +String? _getNonce({String? suppliedNonce, web.Window? window}) { + if (suppliedNonce != _undefined) { + return suppliedNonce; + } + + final web.Window currentWindow = window ?? web.window; + final web.NodeList elements = + currentWindow.document.querySelectorAll('script'); + + for (int i = 0; i < elements.length; i++) { + if (elements.item(i) case final web.HTMLScriptElement element) { + // Chrome may return an empty string instead of null. + final String nonce = + element.nullableNonce ?? element.getAttribute('nonce') ?? ''; + if (nonce.isNotEmpty) { + return nonce; } } } return null; } -// According to the CSP3 spec a nonce must be a valid base64 string. -// https://w3c.github.io/webappsec-csp/#grammardef-base64-value -final _noncePattern = RegExp('^[\\w+/_-]+[=]{0,2}\$'); - /// Exception thrown if the Trusted Types feature is supported, enabled, and it /// has prevented this loader from injecting the JS SDK. class TrustedTypesException implements Exception { diff --git a/packages/google_identity_services_web/pubspec.yaml b/packages/google_identity_services_web/pubspec.yaml index 21f6a0c3d58..97af6ef049f 100644 --- a/packages/google_identity_services_web/pubspec.yaml +++ b/packages/google_identity_services_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_identity_services_web description: A Dart JS-interop layer for Google Identity Services. Google's new sign-in SDK for Web that supports multiple types of credentials. repository: https://github.com/flutter/packages/tree/main/packages/google_identity_services_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_identiy_services_web%22 -version: 0.3.1+5 +version: 0.3.2 environment: sdk: ^3.4.0 diff --git a/packages/google_identity_services_web/test/js_loader_test.dart b/packages/google_identity_services_web/test/js_loader_test.dart index cbb684acfe6..58b73bb9a4f 100644 --- a/packages/google_identity_services_web/test/js_loader_test.dart +++ b/packages/google_identity_services_web/test/js_loader_test.dart @@ -25,8 +25,11 @@ import 'package:web/web.dart' as web; void main() { group('loadWebSdk (no TrustedTypes)', () { - final web.HTMLDivElement target = - web.document.createElement('div') as web.HTMLDivElement; + final web.HTMLDivElement target = web.HTMLDivElement(); + + tearDown(() { + target.replaceChildren([].toJS); + }); test('Injects script into desired target', () async { // This test doesn't simulate the callback that completes the future, and @@ -34,7 +37,7 @@ void main() { unawaited(loadWebSdk(target: target)); // Target now should have a child that is a script element - final web.Node? injected = target.firstChild; + final web.Node? injected = target.firstElementChild; expect(injected, isNotNull); expect(injected, isA()); @@ -54,6 +57,73 @@ void main() { await expectLater(loadFuture, completes); }); + + group('`nonce` parameter', () { + test('can be set', () async { + const String expectedNonce = 'some-random-nonce'; + unawaited(loadWebSdk(target: target, nonce: expectedNonce)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + expect(script.nonce, expectedNonce); + }); + + test('defaults to a nonce set in other script of the page', () async { + const String expectedNonce = 'another-random-nonce'; + final web.HTMLScriptElement otherScript = web.HTMLScriptElement() + ..nonce = expectedNonce; + web.document.head?.appendChild(otherScript); + + // This test doesn't simulate the callback that completes the future, and + // the code being tested runs synchronously. + unawaited(loadWebSdk(target: target)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + expect(script.nonce, expectedNonce); + + otherScript.remove(); + }); + + test('when explicitly set overrides the default', () async { + const String expectedNonce = 'third-random-nonce'; + final web.HTMLScriptElement otherScript = web.HTMLScriptElement() + ..nonce = 'this-is-the-wrong-nonce'; + web.document.head?.appendChild(otherScript); + + // This test doesn't simulate the callback that completes the future, and + // the code being tested runs synchronously. + unawaited(loadWebSdk(target: target, nonce: expectedNonce)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + expect(script.nonce, expectedNonce); + + otherScript.remove(); + }); + + test('when null disables the feature', () async { + final web.HTMLScriptElement otherScript = web.HTMLScriptElement() + ..nonce = 'this-is-the-wrong-nonce'; + web.document.head?.appendChild(otherScript); + + // This test doesn't simulate the callback that completes the future, and + // the code being tested runs synchronously. + unawaited(loadWebSdk(target: target, nonce: null)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + + expect(script.nonce, isEmpty); + expect(script.hasAttribute('nonce'), isFalse); + + otherScript.remove(); + }); + }); }); }