Skip to content

Commit

Permalink
feat: add axe.frameMessenger with configurable allowedOrigins (#2880)
Browse files Browse the repository at this point in the history
* feat(axe.frameMessenger): api to allow custom iframe communication

* reset doc

* fixes

* fix

* tests

* chore(wip): refactor messageHandler

* add allowed origins

* rename directory

* fix ie11 window.origin

* fix build

* fix origin

* Update test/core/base/audit.js

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>

* resolve comments

* WIP: chanages

* chore: fix up couple more minor points

* docs(api): add frameMessenger & allowedOrigins

* chore(types): add frameMessenger & allowedOrigins

* fix(allowedOrigins): consistently use http

* chore: resolve comments

* docs(API): document use with file://

* chore: minor improvements

* docs(API): recommendation not to test on file://

Co-authored-by: Steven Lambert <steven.lambert@deque.com>
Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>
  • Loading branch information
3 people committed Apr 20, 2021
1 parent 33428d8 commit b27bab3
Show file tree
Hide file tree
Showing 30 changed files with 16,050 additions and 2,522 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ module.exports = {
globals: {
assert: true,
helpers: true,
checks: true
checks: true,
sinon: true
},
plugins: ['mocha-no-only'],
rules: {
Expand Down
26 changes: 26 additions & 0 deletions axe.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ declare namespace axe {
disableOtherRules?: boolean;
axeVersion?: string;
noHtml?: boolean;
allowedOrigins?: string[];
// Deprecated - do not use.
ver?: string;
}
Expand Down Expand Up @@ -306,6 +307,31 @@ declare namespace axe {
* Function to clean up plugin configuration in document and its subframes
*/
function cleanup(): void;

/**
* Set up alternative frame communication
*/
function frameMessenger(frameMessenger: FrameMessenger): void;

// axe.frameMessenger
type FrameMessenger = {
open: (topicHandler: TopicHandler) => Close | void;
post: (
frameWindow: Window,
data: TopicData | ReplyData,
replyHandler: ReplyHandler
) => void;
};
type Close = Function;
type TopicHandler = (data: TopicData, responder?: Responder) => void;
type ReplyHandler = (data: ReplyData, responder?: Responder) => void;
type Responder = (
message: any,
keepalive: boolean,
replyHandler: ReplyHandler
) => void;
type TopicData = { topic: String } & ReplyData;
type ReplyData = { channelId: String; message: any; keepAlive: Boolean };
}

export = axe;
22 changes: 21 additions & 1 deletion doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
1. [API Name: axe.registerPlugin](#api-name-axeregisterplugin)
1. [API Name: axe.cleanup](#api-name-axecleanup)
1. [API Name: axe.setup](#api-name-axesetup)
1. [API Name: axe.teardown](#api-name-teardown)
1. [API Name: axe.teardown](#api-name-axeteardown)
1. [API Name: axe.frameMessenger](#api-name-axeframemessenger)
1. [Virtual DOM Utilities](#virtual-dom-utilities)
1. [API Name: axe.utils.querySelectorAll](#api-name-axeutilsqueryselectorall)
1. [API Name: axe.utils.getRule](#api-name-axeutilsgetrule)
Expand Down Expand Up @@ -237,6 +238,7 @@ axe.configure({
- `locale` - A locale object to apply (at runtime) to all rules and checks, in the same shape as `/locales/*.json`.
- `axeVersion` - Set the compatible version of a custom rule with the current axe version. Compatible versions are all patch and minor updates that are the same as, or newer than those of the `axeVersion` property.
- `noHtml` - Disables the HTML output of nodes from rules.
- `allowedOrigins` - Set which origins (URL domains) will communicate test data with. See [allowedOrigins](#allowedorigins).

**Returns:** Nothing

Expand All @@ -250,6 +252,20 @@ Page level rules raise violations on the entire document and not on individual n
- [lib/checks/navigation/heading-order-evaluate.js](https://github.com/dequelabs/axe-core/blob/master/lib/checks/navigation/heading-order-evaluate.js)
- [lib/checks/navigation/heading-order-after.js](https://github.com/dequelabs/axe-core/blob/master/lib/checks/navigation/heading-order-after.js)

##### allowedOrigins

Axe-core will only communicate results to frames of the same origin (the URL domain). To configure axe so that it exchanges results across different origins, you can configure allowedOrigins. This configuration must happen in **every frame**. For example:

```js
axe.configure({
allowedOrigins: ['<same_origin>', 'https://deque.com']
});
```

The `allowedOrigins` option has two wildcard options. `<same_origin>` always corresponds to the current domain. If you want to block all frame communication, set `allowedOrigins` to `[]`. To configure axe-core to communicate to all origins, use `<unsafe_all_origins>`. **This is not recommended**. Because this is the only way to test iframes on `file://`, it is recommended to use a localhost server such as [http-server](https://www.npmjs.com/package/http-server) instead.

Use of `allowedOrigins` is not necessary if an alternative [frameMessenger](#api-name-axeframemessenger) is used.

### API Name: axe.reset

#### Purpose
Expand Down Expand Up @@ -818,6 +834,10 @@ The signature is:
axe.teardown();
```

### API Name: axe.frameMessenger

Set up a alternative communication channel between parent and child frames. By default, axe-core uses `window.postMessage()`. See [frame-messenger.md](frame-messenger.md) for details.

### Virtual DOM Utilities

Note: If you’re writing rules or checks, you’ll have both the `node` and `virtualNode` passed in.
Expand Down
43 changes: 43 additions & 0 deletions doc/frame-messenger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Frame Messenger

Axe frameMessenger can be used to configure how axe-core communicates information between frames. By default, axe-core uses `window.postMessage()`. Since other scripts on the page may also use `window.postMessage`, axe-core's use of it can sometimes disrupt page functionality. This can be avoided by providing `axe.frameMessenger()` a way to communicate to frames that does not use `window.postMessage`.

Tools like browser extensions and testing environments often have different channels through which information can be communicated. `axe.frameMessenger` must be set up in **every frame** axe-core is included.

```js
axe.frameMessenger({
// Called to initialize message handling
open(topicHandler) {
// Start listening for "axe-core" events
const unsubscribe = bridge.subscribe('axe-core', data => {
topicHandler(data);
});
// Tell axe how to close the connection if it needs to
return unsubscribe;
},

// Called when axe needs to send a message to another frame
async post(frameWindow, data, replyHandler) {
// Send a message to another frame for "axe-core"
const replies = bridge.send(frameWindow, 'axe-core', data);
// Async handling replies as they come back
for await (let data of replies) {
replyHandler(data);
}
}
});
```

## axe.frameMessenger({ open })

`open` is a function that should setup the communication channel with iframes. It is passed a `topicHandler` function, which must be called when a message is received from another frame.

The `topicHandler` function takes two arguments: the `data` object and a callback function that is called when the subscribed listener completes. The `data` object is exclusively passed data that can be serialized with `JSON.stringify()`, which depending on the system may need to be used.

The `open` function can `return` an optional cleanup function, which is called when another frameMessenger is registered.

## axe.frameMessenger({ post })

`post` is a function that dictates how axe-core communicates with frames. It is passed three arguments: `frameWindow`, which is the frames `contentWindow`, the `data` object, and a `replyHandler` that must be called when responses are received.

**note**: Currently, axe-core will only call `replyHandler` once, so promises can also be used here. This may change in the future, so it is preferable to make it possible for `replyHandler` to be called multiple times.
41 changes: 41 additions & 0 deletions lib/core/base/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ import constants from '../constants';

const dotRegex = /\{\{.+?\}\}/g;

function getDefaultOrigin() {
// @see https://html.spec.whatwg.org/multipage/webappapis.html#dom-origin-dev
// window.origin does not exist in ie11
if (window.origin) {
return window.origin;
}
// window.location does not exist in node when we run the build
if (window.location && window.location.origin) {
return window.location.origin;
}
}

/*eslint no-unused-vars: 0*/
function getDefaultConfiguration(audit) {
var config;
Expand All @@ -31,6 +43,12 @@ function getDefaultConfiguration(audit) {

config.reporter = config.reporter || null;
config.noHtml = config.noHtml || false;

if (!config.allowedOrigins) {
const defaultOrigin = getDefaultOrigin();
config.allowedOrigins = defaultOrigin ? [defaultOrigin] : [];
}

config.rules = config.rules || [];
config.checks = config.checks || [];
config.data = { checks: {}, rules: {}, ...config.data };
Expand Down Expand Up @@ -286,6 +304,28 @@ class Audit {
this.lang = locale.lang;
}
}
/**
* Set the normalized allowed origins.
*
* @param {String[]}
*/
setAllowedOrigins(allowedOrigins) {
const defaultOrigin = getDefaultOrigin();

this.allowedOrigins = [];
for (const origin of allowedOrigins) {
if (origin === constants.allOrigins) {
// No other origins needed. Set '*' and exit
this.allowedOrigins = ['*'];
return;
} else if (origin !== constants.sameOrigin) {
this.allowedOrigins.push(origin);
} else if (defaultOrigin) {
// sameOrigin, only if the default is known
this.allowedOrigins.push(defaultOrigin);
}
}
}
/**
* Initializes the rules and checks
*/
Expand All @@ -300,6 +340,7 @@ class Audit {
this.application = 'axeAPI';
this.tagExclude = ['experimental'];
this.noHtml = audit.noHtml;
this.allowedOrigins = audit.allowedOrigins;
unpackToObject(audit.rules, this, 'addRule');
unpackToObject(audit.checks, this, 'addCheck');
this.data = {};
Expand Down
4 changes: 3 additions & 1 deletion lib/core/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ const constants = {
* timeout value when resolving preload(able) assets
*/
timeout: 10000
})
}),
allOrigins: '<unsafe_all_origins>',
sameOrigin: '<same_origin>'
};

definitions.forEach(definition => {
Expand Down
2 changes: 2 additions & 0 deletions lib/core/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as imports from './imports';

import cleanup from './public/cleanup';
import configure from './public/configure';
import frameMessenger from './public/frame-messenger';
import getRules from './public/get-rules';
import load from './public/load';
import registerPlugin from './public/plugins';
Expand Down Expand Up @@ -64,6 +65,7 @@ axe.imports = imports;

axe.cleanup = cleanup;
axe.configure = configure;
axe.frameMessenger = frameMessenger;
axe.getRules = getRules;
axe._load = load;
axe.plugins = {};
Expand Down
15 changes: 15 additions & 0 deletions lib/core/public/configure.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { hasReporter } from './reporter';
import { configureStandards } from '../../standards';
import constants from '../constants';

function configure(spec) {
var audit;
Expand Down Expand Up @@ -114,6 +115,20 @@ function configure(spec) {
if (spec.noHtml) {
audit.noHtml = true;
}

if (spec.allowedOrigins) {
if (!Array.isArray(spec.allowedOrigins)) {
throw new TypeError('Allowed origins property must be an array');
}

if (spec.allowedOrigins.includes('*')) {
throw new Error(
`"*" is not allowed. Use "${constants.allOrigins}" instead`
);
}

audit.setAllowedOrigins(spec.allowedOrigins);
}
}

export default configure;
5 changes: 5 additions & 0 deletions lib/core/public/frame-messenger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { respondable } from '../utils';

export default function frameMessenger(frameHandler) {
respondable.updateMessenger(frameHandler);
}
40 changes: 40 additions & 0 deletions lib/core/utils/frame-messenger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { postMessage } from './frame-messenger/post-message';
import { messageHandler } from './frame-messenger/message-handler';

/**
* Setup default axe frame messenger (make a function so we can
* call it during tests to reset respondable to default state).
* @param {Object} respondable
*/
export const frameMessenger = {
open(topicHandler) {
if (typeof window.addEventListener !== 'function') {
return;
}

const handler = function(messageEvent) {
messageHandler(messageEvent, topicHandler);
};
window.addEventListener('message', handler, false);

return () => {
window.removeEventListener('message', handler, false);
};
},

post(win, data, replyHandler) {
if (typeof window.addEventListener !== 'function') {
return false;
}
return postMessage(win, data, false, replyHandler);
}
};

/**
* Setup default axe frame messenger (make a function so we can
* call it during tests to reset respondable to default state).
* @param {Object} respondable
*/
export function setDefaultFrameMessenger(respondable) {
respondable.updateMessenger(frameMessenger);
}
File renamed without changes.
23 changes: 23 additions & 0 deletions lib/core/utils/frame-messenger/channel-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from '../assert';

const channels = {};

export function storeReplyHandler(
channelId,
replyHandler,
sendToParent = true
) {
assert(
!channels[channelId],
`A replyHandler already exists for this message channel.`
);
channels[channelId] = { replyHandler, sendToParent };
}

export function getReplyHandler(topic) {
return channels[topic];
}

export function deleteReplyHandler(channelId) {
delete channels[channelId];
}
15 changes: 15 additions & 0 deletions lib/core/utils/frame-messenger/create-responder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { postMessage } from './post-message';

/**
* Helper closure to create a function that may be used to respond to a message
* @private
* @param {Window} win The window from which the message originated
* @param {String} channelId The "unique" ID of the original message
* @return {Function} A function that may be invoked to respond to the message
*/
export function createResponder(win, channelId, sendToParent = true) {
return function respond(message, keepalive, replyHandler) {
const data = { channelId, message, keepalive };
postMessage(win, data, sendToParent, replyHandler);
};
}
Loading

0 comments on commit b27bab3

Please sign in to comment.