Skip to content
Open
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
50 changes: 47 additions & 3 deletions lib/core/utils/parse-sameorigin-stylesheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ function parseSameOriginStylesheet(
const cssImportUrlsNotAlreadyImported = cssImportRules
// ensure rule has a href
.filter(rule => rule.href)
// extract href from object
.map(rule => rule.href)
// extract href from object and resolve it
.map(rule => resolveImportUrl(rule.href, sheet))
// only href that are not already imported
.filter(url => !importedUrls.includes(url));

Expand All @@ -61,7 +61,7 @@ function parseSameOriginStylesheet(
const promises = cssImportUrlsNotAlreadyImported.map(
(importUrl, cssRuleIndex) => {
const newPriority = [...priority, cssRuleIndex];
const isCrossOriginRequest = /^https?:\/\/|^\/\//i.test(importUrl);
const isCrossOriginRequest = isCrossOriginUrl(importUrl);

return parseCrossOriginStylesheet(
importUrl,
Expand Down Expand Up @@ -97,4 +97,48 @@ function parseSameOriginStylesheet(
return Promise.all(promises);
}

/**
* Resolve the url of an `@import` against the stylesheet that contains it
*
* `CSSImportRule.href` returns the url as it is written in the stylesheet, so it
* is usually relative. CSS resolves such a url against the url of the stylesheet
* that contains the `@import`, while `XMLHttpRequest` resolves it against the
* url of the document. Those two are not the same when the stylesheet does not
* sit next to the document.
*
* @param {String} url url of the `@import`, as it is written in the stylesheet
* @param {Object} sheet CSSStylesheet object that contains the `@import`
* @returns {String}
*/
function resolveImportUrl(url, sheet) {
// `sheet.href` is null for an inline `<style>` element, where the url of the
// document is the correct base
const base = sheet.href || document.baseURI;

try {
return new window.URL(url, base).href;
} catch {
// IE11 has no URL constructor, so the url is left as it was written
return url;
}
}

/**
* Check if a given url points to another origin
*
* @param {String} url url of the `@import`
* @returns {Boolean}
*/
function isCrossOriginUrl(url) {
try {
return (
new window.URL(url, window.location.href).origin !==
window.location.origin
);
} catch {
// IE11 has no URL constructor, so the url is matched as a string
return /^https?:\/\/|^\/\//i.test(url);
}
}

export default parseSameOriginStylesheet;
105 changes: 105 additions & 0 deletions test/core/utils/parse-sameorigin-stylesheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,111 @@ describe('axe.utils.parseSameOriginStylesheet', () => {
});
});

it('resolves a relative @import url against the url of the stylesheet', done => {
// no stylesheet is added to the page, the sheet below is passed directly
stylesForPage = [];

const cssomDir = '/test/integration/full/preload-cssom/';
// only `href` and `cssRules` are used by `parseSameOriginStylesheet`.
// the sheet is in another directory than the document, so resolving the
// `@import` against the document url would request the wrong file.
const sheet = {
href: new URL(cssomDir + 'style-with-import.css', location.origin).href,
cssRules: [{ type: 3, href: 'base.css' }]
};
const options = {
rootNode: document,
shadowId: undefined,
convertDataToStylesheet: convertDataToStylesheet
};

const importedUrls = [];
axe.utils
.parseSameOriginStylesheet(sheet, options, [1, 0], importedUrls, false)
.then(data => {
assert.deepEqual(importedUrls, [
new URL(cssomDir + 'base.css', location.origin).href
]);
axe.testUtils.assertStylesheet(
data[0].sheet,
'.style-from-base-css',
'.style-from-base-css {font-size: 100%; }'
);
done();
})
.catch(done);
});

it('resolves a relative @import url against the document url for an inline style', done => {
stylesForPage = [];

// `href` is null for an inline `<style>` element
const sheet = {
href: null,
cssRules: [
{ type: 3, href: '../integration/full/preload-cssom/base.css' }
]
};
const options = {
rootNode: document,
shadowId: undefined,
convertDataToStylesheet: convertDataToStylesheet
};

const importedUrls = [];
axe.utils
.parseSameOriginStylesheet(sheet, options, [1, 0], importedUrls, false)
.then(() => {
assert.deepEqual(importedUrls, [
new URL(
'../integration/full/preload-cssom/base.css',
document.baseURI
).href
]);
done();
})
.catch(done);
});

it('leaves the @import url as written when there is no URL constructor', done => {
stylesForPage = [];

const importHref = '../integration/full/preload-cssom/base.css';
const sheet = {
href: null,
cssRules: [{ type: 3, href: importHref }]
};
const options = {
rootNode: document,
shadowId: undefined,
convertDataToStylesheet: convertDataToStylesheet
};

// IE11 has `window.URL`, but it cannot be used as a constructor
const nativeUrl = window.URL;
window.URL = {};

const importedUrls = [];
// the url is resolved while `parseSameOriginStylesheet` runs, so `URL` can
// be put back as soon as the call returns
const promise = axe.utils.parseSameOriginStylesheet(
sheet,
options,
[1, 0],
importedUrls,
false
);
window.URL = nativeUrl;

promise
.then(data => {
assert.deepEqual(importedUrls, [importHref]);
assert.isFalse(data[0].isCrossOrigin);
done();
})
.catch(done);
});

it('returns inline style specified in the stylesheet', done => {
// add style that has @import style
stylesForPage = [styleSheets.inlineStyle];
Expand Down