dequelabs / dequelabs/axe-core

`@import` stylesheets are fetched from a wrong URL, which causes a 404 and a silently wrong `css-orientation-lock` result

Open
#5,268 0 comments 0 reactions 0 assignees View on GitHub
ungroomed
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.