diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 78374285..f2adfe0a 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -5,6 +5,11 @@ `dart:io` and `dart:html`. - Added `JSImmutableListWrapper` which helps create a dart list from a JS list. - Deprecated `TouchListWrapper` and `TouchListConvert` in favor of `JSImmutableListWrapper`. +- Added `CrossOriginWindow` and `CrossOriginLocation` wrappers for cross-origin + windows and locations, respectively, that can be accessed through + `HTMLIFrameElement.contentWindowCrossOrigin`, `Window.openCrossOrigin`, + `Window.openerCrossOrigin`, `Window.topCrossOrigin`, + and `Window.parentCrossOrigin`. ## 1.0.0 diff --git a/web/lib/src/helpers.dart b/web/lib/src/helpers.dart index 22bed6ea..caaf4f46 100644 --- a/web/lib/src/helpers.dart +++ b/web/lib/src/helpers.dart @@ -27,6 +27,7 @@ import 'dart:js_interop_unsafe'; import 'dom.dart'; import 'helpers/lists.dart'; +export 'helpers/cross_origin.dart' show CrossOriginLocation, CrossOriginWindow; export 'helpers/enums.dart'; export 'helpers/events/events.dart'; export 'helpers/events/providers.dart'; diff --git a/web/lib/src/helpers/cross_origin.dart b/web/lib/src/helpers/cross_origin.dart new file mode 100644 index 00000000..ebc7fc29 --- /dev/null +++ b/web/lib/src/helpers/cross_origin.dart @@ -0,0 +1,184 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import '../dom.dart' show HTMLIFrameElement, Location, Window; + +// Includes all the allowed and necessary APIs from the W3 spec located here: +// https://html.spec.whatwg.org/multipage/nav-history-apis.html#cross-origin-objects +// Some browsers may provide more access. These APIs are exposed as +//`_DOMWindowCrossFrame` and `_LocationCrossFrame` in `dart:html`. + +// The Dart runtime does not allow this to be typed as any better than `JSAny?`. +extension type _CrossOriginWindow(JSAny? any) { + external bool get closed; + external int get length; + // While you can set the location to a string value, this is the same as + // `location.href`, so we only allow the getter to avoid a + // `getter_not_subtype_setter_types` error. + external JSAny? get location; + external JSAny? get opener; + external JSAny? get parent; + external JSAny? get top; + // `frames`, `self`, and `window` are all supported for cross-origin windows, + // but simply return the calling window, so there's no use in supporting them + // for interop. + external void blur(); + external void close(); + external void focus(); + external void postMessage( + JSAny? message, [ + JSAny optionsOrTargetOrigin, + JSArray transfer, + ]); +} + +// The Dart runtime does not allow this to be typed as any better than `JSAny?`. +extension type _CrossOriginLocation(JSAny? any) { + external void replace(String url); + external set href(String value); +} + +class CrossOriginWindow { + CrossOriginWindow._(JSAny? o) : _window = _CrossOriginWindow(o); + + static CrossOriginWindow? _create(JSAny? o) { + if (o == null) return null; + return CrossOriginWindow._(o); + } + + final _CrossOriginWindow _window; + + /// The [Window.closed] value of this cross-origin window. + bool get closed => _window.closed; + + /// The [Window.length] value of this cross-origin window. + int get length => _window.length; + + /// A [CrossOriginLocation] wrapper of the [Window.location] value of this + /// cross-origin window. + CrossOriginLocation? get location => + CrossOriginLocation._create(_window.location); + + /// A [CrossOriginWindow] wrapper of the [Window.opener] value of this + /// cross-origin window. + CrossOriginWindow? get opener => _create(_window.opener); + + /// A [CrossOriginWindow] wrapper of the [Window.top] value of this + /// cross-origin window. + CrossOriginWindow? get parent => _create(_window.parent); + + /// A [CrossOriginWindow] wrapper of the [Window.parent] value of this + /// cross-origin window. + CrossOriginWindow? get top => _create(_window.top); + + /// Calls [Window.blur] on this cross-origin window. + void blur() => _window.blur(); + + /// Calls [Window.close] on this cross-origin window. + void close() => _window.close(); + + /// Calls [Window.focus] on this cross-origin window. + void focus() => _window.focus(); + + /// Calls [Window.postMessage] on this cross-origin window with the given + /// [message], [optionsOrTargetOrigin] if not `null`, and [transfer] if not + /// `null`. + void postMessage( + JSAny? message, [ + JSAny? optionsOrTargetOrigin, + JSArray? transfer, + ]) { + if (optionsOrTargetOrigin == null) { + _window.postMessage(message); + } else if (transfer == null) { + _window.postMessage(message, optionsOrTargetOrigin); + } else { + _window.postMessage(message, optionsOrTargetOrigin, transfer); + } + } + + /// The unsafe window value that this wrapper wraps that should only ever be + /// typed as [JSAny]?. + /// + /// > [!NOTE] + /// > This is only intended to be passed to an interop member that expects a + /// > [JSAny]?. Safety for any other operations is not + /// > guaranteed. + JSAny? get window => _window.any; +} + +/// A safe wrapper for a cross-origin location obtained through a cross-origin +/// window. +/// +/// Since cross-origin access is limited by the browser, the Dart runtime can't +/// provide a type for or null-assert this value. To safely interact with this +/// value, use this wrapper instead. +class CrossOriginLocation { + CrossOriginLocation._(JSAny? o) : _location = _CrossOriginLocation(o); + + static CrossOriginLocation? _create(JSAny? o) { + if (o == null) return null; + return CrossOriginLocation._(o); + } + + final _CrossOriginLocation _location; + + /// Sets the [Location.href] value of this cross-origin location to [value]. + set href(String value) => _location.href = value; + + /// Calls [Location.replace] on this cross-origin location with the given + /// [url]. + void replace(String url) => _location.replace(url); + + /// The unsafe location value that this wrapper wraps that should only ever be + /// typed as [JSAny]?. + /// + /// > [!NOTE] + /// > This is only intended to be passed to an interop member that expects a + /// > [JSAny]?. Safety for any other operations is not + /// > guaranteed. + JSAny? get location => _location.any; +} + +extension CrossOriginContentWindowExtension on HTMLIFrameElement { + @JS('contentWindow') + external JSAny? get _contentWindow; + + /// A [CrossOriginWindow] wrapper of the [HTMLIFrameElement.contentWindow] + /// value of this `iframe`. + CrossOriginWindow? get contentWindowCrossOrigin => + CrossOriginWindow._create(_contentWindow); +} + +extension CrossOriginWindowExtension on Window { + @JS('open') + external JSAny? _open(String url); + + /// A [CrossOriginWindow] wrapper of the value returned from calling + /// [Window.open] with [url]. + CrossOriginWindow? openCrossOrigin(String url) => + CrossOriginWindow._create(_open(url)); + @JS('opener') + external JSAny? get _opener; + + /// A [CrossOriginWindow] wrapper of the [Window.opener] value of this + /// cross-origin window. + CrossOriginWindow? get openerCrossOrigin => + CrossOriginWindow._create(_opener); + @JS('parent') + external JSAny? get _parent; + + /// A [CrossOriginWindow] wrapper of the [Window.parent] value of this + /// cross-origin window. + CrossOriginWindow? get parentCrossOrigin => + CrossOriginWindow._create(_parent); + @JS('top') + external JSAny? get _top; + + /// A [CrossOriginWindow] wrapper of the [Window.top] value of this + /// cross-origin window. + CrossOriginWindow? get topCrossOrigin => CrossOriginWindow._create(_top); +} diff --git a/web/lib/src/helpers/extensions.dart b/web/lib/src/helpers/extensions.dart index 24f5be3b..c976dcde 100644 --- a/web/lib/src/helpers/extensions.dart +++ b/web/lib/src/helpers/extensions.dart @@ -27,6 +27,9 @@ import 'dart:math' show Point; import '../dom.dart'; import 'lists.dart'; +export 'cross_origin.dart' + show CrossOriginContentWindowExtension, CrossOriginWindowExtension; + extension HTMLCanvasElementGlue on HTMLCanvasElement { CanvasRenderingContext2D get context2D => getContext('2d') as CanvasRenderingContext2D; diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index aa419ac3..177c3fab 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -10,6 +10,9 @@ import 'dart:js_interop'; import 'package:test/test.dart'; import 'package:web/web.dart'; +@JS('Object.is') +external bool _is(JSAny? a, JSAny? b); + void main() { test('instanceOfString works with package:web types', () { final div = document.createElement('div') as JSObject; @@ -36,4 +39,65 @@ void main() { // Ensure accessing any arbitrary item in the list does not throw. expect(() => dartList[0], returnsNormally); }); + + test('cross-origin windows and locations can be accessed safely', () { + const url = 'https://www.google.com'; + const url2 = 'https://www.example.org'; + + void testCommon(CrossOriginWindow crossOriginWindow) { + expect(crossOriginWindow.length, 0); + expect(crossOriginWindow.closed, false); + // We can't add an event listener on a cross-origin window, so just test + // that a message can be sent without any errors. + crossOriginWindow.postMessage('hello world'.toJS); + crossOriginWindow.postMessage('hello world'.toJS, url.toJS); + crossOriginWindow.postMessage('hello world'.toJS, url.toJS, JSArray()); + crossOriginWindow.location!.replace(url2); + crossOriginWindow.location!.href = url; + crossOriginWindow.blur(); + crossOriginWindow.focus(); + crossOriginWindow.close(); + } + + final openedWindow = window.openCrossOrigin(url)!; + // Use `Object.is` to test that values can be passed to interop. + expect(_is(openedWindow.opener!.window, window), true); + expect(_is(openedWindow.top!.window, openedWindow.window), true); + expect(_is(openedWindow.parent!.window, openedWindow.window), true); + expect(_is(openedWindow.opener!.location!.location, window.location), true); + expect( + _is(openedWindow.opener!.parent?.window, + window.parentCrossOrigin?.window), + true); + expect(_is(openedWindow.opener!.top?.window, window.topCrossOrigin?.window), + true); + expect( + openedWindow.opener!.opener?.window, window.openerCrossOrigin?.window); + testCommon(openedWindow); + expect(openedWindow.closed, true); + + final iframe = HTMLIFrameElement(); + iframe.src = url; + document.body!.append(iframe); + final contentWindow = iframe.contentWindowCrossOrigin!; + expect(contentWindow.opener, null); + expect(_is(contentWindow.top?.window, window.topCrossOrigin?.window), true); + expect(_is(contentWindow.parent!.window, window), true); + expect( + _is(contentWindow.parent!.location!.location, window.location), true); + expect( + _is(contentWindow.parent!.parent?.window, + window.parentCrossOrigin?.window), + true); + expect( + _is(contentWindow.parent!.top?.window, window.topCrossOrigin?.window), + true); + expect( + _is(contentWindow.parent!.opener?.window, + window.openerCrossOrigin?.window), + true); + testCommon(contentWindow); + // `close` on a `contentWindow` does nothing. + expect(contentWindow.closed, false); + }); }