-
Notifications
You must be signed in to change notification settings - Fork 32
Add cross-origin window and location wrappers #291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1e17c5a
fd199ec
c4f1f8f
4351aa3
56d78b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
// 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; | ||
|
||
// 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<JSObject> 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); | ||
} | ||
|
||
/// A safe wrapper for 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 the cross-origin window. To safely | ||
/// interact with the cross-origin window, use this wrapper instead. | ||
/// | ||
/// The `dart:html` equivalent is `_DOMWindowCrossFrame`. | ||
/// | ||
/// Only includes allowed APIs from the W3 spec located here: | ||
/// https://html.spec.whatwg.org/multipage/nav-history-apis.html#crossoriginproperties-(-o-) | ||
/// Some browsers may provide more access. | ||
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<JSObject>? 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 <code>[JSAny]?</code>. | ||
/// | ||
/// > [!NOTE] | ||
/// > This is only intended to be passed to an interop member that expects a | ||
/// > <code>[JSAny]?</code>. Safety for any other operations is not | ||
/// > guaranteed. | ||
JSAny? get unsafeWindow => _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 the cross-origin location. To safely | ||
/// interact with the cross-origin location, use this wrapper instead. | ||
/// | ||
/// The `dart:html` equivalent is `_LocationCrossFrame`. | ||
/// | ||
/// Only includes allowed APIs from the W3 spec located here: | ||
/// https://html.spec.whatwg.org/multipage/nav-history-apis.html#crossoriginproperties-(-o-) | ||
/// Some browsers may provide more access. | ||
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 <code>[JSAny]?</code>. | ||
/// | ||
/// > [!NOTE] | ||
/// > This is only intended to be passed to an interop member that expects a | ||
/// > <code>[JSAny]?</code>. Safety for any other operations is not | ||
/// > guaranteed. | ||
JSAny? get unsafeLocation => _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); | ||
} | ||
|
||
/// Safe alternatives to common [Window] members that can return cross-origin | ||
/// windows. | ||
/// | ||
/// By default, the Dart web compilers are not sensitive to cross-origin | ||
/// objects, and therefore same-origin policy errors may be triggered when | ||
/// type-checking. Use these members instead to safely interact with such | ||
/// objects. | ||
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); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -55,4 +58,80 @@ void main() { | |
), | ||
); | ||
}); | ||
|
||
test('cross-origin windows and locations can be accessed safely', () { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (optional) I wonder if it would be worth to also add test coverage of the unwrapped behavior? That is, something that verifies that cross-origin windows/locations fail if you tried to access them directly? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed it'd be useful to verify. In the process of adding this, however, I noticed There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think both the test runner and |
||
// TODO(https://github.com/dart-lang/test/issues/2282): For some reason, | ||
// running `dart test` doesn't flag violations of same-origin policy, | ||
// allowing any unsafe accesses. When tested with `--pause-after-load` and | ||
// single stepped, however, the test correctly flags violations. Figure out | ||
// why and make this test always respect same-origin policy. Add some tests | ||
// to ensure that violations are being handled properly. | ||
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!.unsafeWindow, window), true); | ||
expect( | ||
_is(openedWindow.top!.unsafeWindow, openedWindow.unsafeWindow), true); | ||
expect(_is(openedWindow.parent!.unsafeWindow, openedWindow.unsafeWindow), | ||
true); | ||
expect(_is(openedWindow.opener!.location!.unsafeLocation, window.location), | ||
true); | ||
expect( | ||
_is(openedWindow.opener!.parent?.unsafeWindow, | ||
window.parentCrossOrigin?.unsafeWindow), | ||
true); | ||
expect( | ||
_is(openedWindow.opener!.top?.unsafeWindow, | ||
window.topCrossOrigin?.unsafeWindow), | ||
true); | ||
expect(openedWindow.opener!.opener?.unsafeWindow, | ||
window.openerCrossOrigin?.unsafeWindow); | ||
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?.unsafeWindow, | ||
window.topCrossOrigin?.unsafeWindow), | ||
true); | ||
expect(_is(contentWindow.parent!.unsafeWindow, window), true); | ||
expect(_is(contentWindow.parent!.location!.unsafeLocation, window.location), | ||
true); | ||
expect( | ||
_is(contentWindow.parent!.parent?.unsafeWindow, | ||
window.parentCrossOrigin?.unsafeWindow), | ||
true); | ||
expect( | ||
_is(contentWindow.parent!.top?.unsafeWindow, | ||
window.topCrossOrigin?.unsafeWindow), | ||
true); | ||
expect( | ||
_is(contentWindow.parent!.opener?.unsafeWindow, | ||
window.openerCrossOrigin?.unsafeWindow), | ||
true); | ||
testCommon(contentWindow); | ||
// `close` on a `contentWindow` does nothing. | ||
expect(contentWindow.closed, false); | ||
}); | ||
} |
Uh oh!
There was an error while loading. Please reload this page.