dequelabs / dequelabs/axe-core
`@import` stylesheets are fetched from a wrong URL, which causes a 404 and a silently wrong `css-orientation-lock` result
- Dominant language
- JavaScript
- Stars
- 7.5k
- Forks
- 933
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 29
Description
### Product
axe-core
### Product Version
4.12.1
### Latest Version
- [x] I have tested the issue with the latest version of the product
### Issue Description
#### Expectation
In CSS, a relative URL inside a stylesheet is resolved against the URL of that stylesheet. So when `/css/app.css` contains `@import url("fonts/fonts.css")`, I expect axe-core to fetch `/css/fonts/fonts.css`.
#### Actual
axe-core fetches `/fonts/fonts.css` instead, and the server answers 404.
`lib/core/utils/parse-sameorigin-stylesheet.js` collects the URLs with `.map(rule => rule.href)`, and `parse-crossorigin-stylesheet.js` passes the value to `request.open('GET', url)`. `CSSImportRule.href` returns the URL just as it is written in the stylesheet, so it is usually relative. `XMLHttpRequest` resolves it against the document base URL, not against the stylesheet URL. The browser follows the CSS rule, so the page looks fine. Only axe-core asks for a file that does not exist.
What happens after the 404 depends on the body of the response, because `parseCrossOriginStylesheet` never looks at `request.status`. It only checks `event.loaded && request.responseText`.
- When the 404 body is empty, the promise rejects. The preload is wrapped in `Promise.all`, so the whole preload fails. axe-core only warns with `console.warn('Couldn\'t load preload assets: ', err)`, and `css-orientation-lock` becomes incomplete.
- When the 404 body is not empty, and many servers return an HTML error page, that HTML is parsed as CSS. The preload looks successful, nothing is logged, and `css-orientation-lock` returns passes even though the imported stylesheet was never read.
The second case worries me the most. There is no warning at all, so the rule quietly stops working and the user has no way to notice it.
#### How to Reproduce
See the attached zip. It has three static files, with no framework and no build step.
[📦axe-core-relative-import-repro.zip](https://github.com/user-attachments/files/30545448/axe-core-relative-import-repro.zip)
1. Extract the zip.
2. Serve the `repro` folder as the site root, for example with `npx serve@14 .` in that folder.
3. Open the page with DevTools open.
The Network panel shows two requests for the same `@import`.
| requested by | URL | status |
| -------------- | ---------------------- | ------ |
| browser (CSS) | `/css/fonts/fonts.css` | 200 |
| axe-core (XHR) | `/fonts/fonts.css` | 404 |
The Console shows `axe-core 4.12.1: css-orientation-lock => passes`, although the imported stylesheet was never read. If your server answers 404 with an empty body, you will see the `Couldn't load preload assets:` warning and `incomplete` instead.
index.html
```html
axe-core relative @import repro
Open the DevTools Network and Console panels.
// css-orientation-lock is the rule that needs the CSSOM preload.
axe.run({ runOnly: ['css-orientation-lock'] }).then(results => {
const outcome = ['violations', 'passes', 'incomplete', 'inapplicable'].find(key =>
results[key].some(rule => rule.id === 'css-orientation-lock')
);
console.log(`axe-core ${axe.version}: css-orientation-lock => ${outcome}`);
});
```
css/app.css
```css
/* This file is served from /css/app.css, so this @import refers to
/css/fonts/fonts.css. */
@import url('fonts/fonts.css');
```
css/fonts/fonts.css
```css
/* Served from /css/fonts/fonts.css. This file exists; only axe-core fails to
find it, because it looks under /fonts/fonts.css. */
body {
color: #333;
}
```
#### Additional context
A possible fix is to make the URL absolute before the request, in the `.map(rule => rule.href)` step of `parse-sameorigin-stylesheet.js`.
```js
new URL(rule.href, sheet.href || document.baseURI).href;
```
`sheet.href` is null for an inline `` element. In that case the document base URL is the correct base, so it works as a fallback.
Two things I noticed while looking at this.
- The URL should become absolute in that `.map` step. `importedUrls` in `parseCrossOriginStylesheet` stores the raw string and is used to find already imported stylesheets, so a change in a different place may break the detection of a circular `@import`.
- `isCrossOriginRequest = /^https?:\/\/|^\/\//i.test(importUrl)` will always be true once the URL is absolute. As far as I can see in 4.12.1, that flag is only stored in the asset object and no rule reads it, but I may have missed something.
If this behavior is intentional, I am sorry for taking your time. I could not find an existing issue about it, so I decided to report it. I am happy to open a pull request if this direction sounds good to you.
Contributor guide
Research direction
Start by serving the attached repro and confirming the differing browser and axe-core requests. Read lib/core/utils/parse-sameorigin-stylesheet.js and parse-crossorigin-stylesheet.js, especially the imported URL collection and request handling. Done means relative @import URLs resolve against the stylesheet, while the preload still handles failed requests correctly without breaking circular-import detection.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- accessibility
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100