Sometimes, a Billion Laughs Aren't so Funny — Improving CSS Variables in WebKit
• Tyler WilcockWiden, like many companies, makes heavy use of open-source technology, and we’ve made it a part of our mission to give back to this community that we benefit so much from. In light of this, in this post I’ll be talking about some improvements I made to the CSS variables implementation in WebKit.
What is WebKit?
WebKit is an open-source browser engine that is most famous for powering Safari. Safari is far from the only place you’ll find WebKit, however. It’s behind every browser on iOS, all web content displayed on PlayStation systems, and in various GTK-based browsers such as Gnome Web. WebKit is also deployed to millions of embedded devices via the WebKit Port for Embedded (WPE) port.
CSS has variables?
Yes! You don’t need a CSS preprocessor to use variables in your CSS. Here’s what the syntax looks like:
:root {
--gutter: 4px;
}
section {
margin: var(--gutter);
}
@media (min-width: 600px) {
:root {
--gutter: 16px;
}
}
Because CSS variables are native to the web platform, they can do things CSS preprocessor variables can’t. For example, they can be used in media queries, and can be manipulated with JavaScript.
// 4px or 16px depending on current screen-width
const gutterSize = getComputedStyle(document.documentElement)
.getPropertyValue('--gutter')
.trim()
maximizeGutterButton.onclick = () => {
document.documentElement.style.setProperty('--gutter', '32px');
})
CSS variables are also known as custom properties. For a more detailed overview of CSS variables, read more on MDN.
Improvements to WebKit’s CSS variables implementation
You can find the diff for each fixed bug by following the bugs.webkit.org sub-header link and clicking “Formatted Diff” on the patch.
Safer handling of overly long CSS variables
One useful property of CSS variables is that the value of one variable can be the expansion of another. However, this can get out of hand when used maliciously:
:root {
--prop1: lol;
--prop2: var(--prop1) var(--prop1);
--prop3: var(--prop2) var(--prop2);
--prop4: var(--prop3) var(--prop3);
/* expand to --prop30 */
}
If allowed to run unchecked, the above snippet will result in the browser attempting to create approximately one billion instances of --prop1
’s value (“lol”),
which is more than enough to run most systems out of memory. Testing this in Safari on my 2019 MackBook Pro, 32gb of RAM was consumed within 30 seconds, and Safari eventually refused to load the page.
This is an example of the billion laughs attack, and before this patch all WebKit-based browsers were susceptible to it via CSS variables. Now, any CSS variable value that requires more than 65536 expansions is treated as invalid, and the variable expansion process is ended early.
Enable CSS-wide keywords to be used as variable fallbacks
CSS variable expansions can specify fallback values in case the given variable is not available. For example, see this
snippet where the background-color
for .foo
is either --optional-theme-color
or revert
.
.foo {
/* Any other CSS value may also be used as a fallback. */
background-color: var(--optional-theme-color, revert);
}
Before this patch, CSS-wide keywords were not parsed in the context of variable fallbacks, and therefore were always considered invalid values. This
includes inherit
, initial
, revert
, and unset
.
Resolve relative-path URLs in url()
against the correct base URL when CSS variables are involved
Given this folder structure:
project/
├── index.html
└── assets/
| └── ducky.png
└── styles/
└── stylesheet.css
And these styles in stylesheet.css
:
:root {
--background-url: url('../assets/ducky.png');
--repeat-style: no-repeat;
}
body {
background: var(--background-url);
}
div {
background: url('../assets/ducky.png') var(--repeat-style);
}
Then the url()
s should be resolved relative to the directory in which they originate. In this case, this is the styles directory.
url('../assets/ducky.png');
/* Should resolve to: https://domain.com/assets/ducky.png */
However, before this patch, whenever a variable was present in a rule, url()
s in that rule would unconditionally resolve relative to the base document URL (that of index.html
here).
This means that all of the url()
s above would fail to load our ducky.
To fix this, WebKit’s representation of pending-substitution values now
tracks the base URL that should be used to resolve any url()
in the value, rather than unconditionally resolving them against the
base document URL.
Prevent :visited styles from erroneously being applied to non-visited links when CSS variables are present in the rule
Given:
:root {
--link-color: green;
--link-color-visited: red;
}
.link {
color: var(--link-color);
}
.link:visited {
color: var(--link-color-visited);
}
<a class="link" href="https://www.widen.com">Widen</a>
WebKit used to erroneously apply the :visited
styles for non-visited links when variables were part of the rule, meaning
the color of this link would always be red.
In WebKit’s style-building code, there’s a piece of state that manages where the styles it’s evaluating should be applied. This can happen one of three ways:
- Styles are applied to the set of non-visited styles
- Styles are applied to the set of
:visited
styles - Styles are applied to both non-visited and
:visited
style sets
The manifestation of the bug looked like this:
- The parsing of the
.link:visited
style begins, and style-state is updated to register:visited
styles. - A CSS variable is encountered, so work begins to expand it.
- As part of this expansion, the style-state is manipulated to various values and unconditionally reset to point at the non-visited style set.
- Variable expansion completes, but because of the reset to the non-visited style-state in the previous step,
the
:visited
styles are erroneously applied to the non-visited style set.
One solution would be to keep track of the value of this style-state before variable expansion begins and reset it back to this value in step 3. I tried this, but it isn’t the cleanest solution, as one would have to remember to reset this state any time the function returns.
Instead, any time this state is mutated during variable expansion, it’s now done so with a utility within WebKit called
SetForScope
, which automatically rolls the changed state back to its original value when the SetForScope
is destroyed.
Future work
Currently, CSS variables used in @keyframes
animations in WebKit are broken.
I haven’t got around to fixing this one yet, but hope to do so soon.
I’d like to thank Widen again for their full support in my work on this, and in general for enabling all of our development team to give back to the open-source community. If you’re interested in joining our team, check out our openings here!