Skip to content
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

Add cross-origin window and location wrappers #291

Merged
merged 5 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
`switch`ed over.
- Add an extension `responseHeaders` to `XMLHttpRequest`.
- Correctly namespace `WebAssembly` types.
- 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

Expand Down
1 change: 1 addition & 0 deletions web/lib/src/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
203 changes: 203 additions & 0 deletions web/lib/src/helpers/cross_origin.dart
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 {
srujzs marked this conversation as resolved.
Show resolved Hide resolved
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);
}
3 changes: 3 additions & 0 deletions web/lib/src/helpers/extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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;
Expand Down
79 changes: 79 additions & 0 deletions web/test/helpers_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,4 +58,80 @@ void main() {
),
);
});

test('cross-origin windows and locations can be accessed safely', () {
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 package:test doesn't quite work as expected. Running a known same-origin policy violation e.g. accessing navigator on an opened window from a different origin only fails if you --pause-after-load and single-step through the test. Without that, opened windows' origins appears to be the same as the opening window's origin (localhost). I know there's some setup to load these tests in an iframe to run them, but I can't tell how it results in the above behavior. The current test passes regardless of if you single-step, so for now, I put a TODO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think both the test runner and package:test have this issue. After a good amount of debugging, I can't tell how to enable same-origin policy. Filed dart-lang/test#2282 for now and single-stepped to verify that there are no cross-origin issues with the current test.

// 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);
});
}