One color added, all the Additional CSS gone

I wanted to add one color to the palette of this site. A one-minute job, which I left to my AI assistant. It refused: if it saved that, all the Additional CSS of the site would disappear. I did not believe it. Surely an administrator can simply change the colors of his own site?

Cover: One color added, all Additional CSS gone

He can. And still the assistant was right. We tried it on a copy of the site, without saving anything. Of the 2123 characters of Additional CSS, 0 were left.

What is going on. This site is part of a multisite: one WordPress with several sites in it. There are two kinds of administrators there. The network administrator may do everything. An ordinary site administrator may manage his own site, but may not write free code. And WordPress counts CSS as free code.

The account of my assistant is deliberately an ordinary site administrator. I am a network administrator myself, which is why I had never noticed.

Where it goes wrong. In a block theme, everything you set under Styles is kept together in one package: the color palette, the fonts and also the Additional CSS. When an ordinary site administrator saves that package, WordPress takes out everything he would not have been allowed to write himself. So the Additional CSS goes, even though he did not touch it and even though it had been there for months.

There is no message. The color is added, the page is saved, and the transparent header, the menu effect and the rest of the custom work are gone.

The test. On the copy I ran the same package through the save filter of WordPress twice, without really saving it:

  • as an ordinary site administrator: Additional CSS from 2123 to 0 characters, palette unchanged
  • as a network administrator: Additional CSS from 2123 to 2123 characters, palette unchanged

Is this a bug? No, it is intended. WordPress cannot see who once wrote the CSS, and on a multisite it trusts only the network administrator. I am not the only one who ran into it, and WordPress has turned down the request to change this:

  • WordPress ticket #58610: the request to allow site administrators on a multisite to use CSS. Closed: will not be fixed (wontfix)
  • WordPress ticket #59123: the report that the Additional CSS panel is missing for administrators on a multisite. Closed: not a bug (invalid)
  • Gutenberg #47062 (2023): the change that spares the CSS for those who do have the right, and so not for everyone else
  • Gutenberg #76650 (2026): the same behavior for CSS on individual blocks
  • Multisite Custom CSS: a plugin that gives site administrators this right

The solution is a few lines in my own plugin: whoever may customize the theme of a site may also edit the CSS. Then the same test again, now with a real save: 2123 characters before, 2123 characters after. Only then did the assistant add the color, on all three language versions.

This is a deliberate choice, though. Every administrator of a site in my network may now write free CSS. In my network I know them all. In a network with administrators you do not know, I would not do it.

Bycatch: a site that broke

To make the solution apply everywhere, I activated the plugin for the whole network. One of the other sites immediately gave an error. That site runs on an older theme, and that theme contains a function with exactly the same name as a function in my plugin. The same name twice is not allowed.

The nasty part: the front page of that site simply worked. It came from the cache. Only someone who wanted to log in got the error. I gave the functions in the plugin a prefix of their own, and after that it could be activated network-wide.

A little extra: the color that was not a palette color

The new color was meant for a button. I had already made that button blue earlier, with a loose color code. Once the color was in the palette, the editor neatly showed it as selected for the button. Done, I thought.

Not so. The editor only compares the color code. If that happens to match a color from the palette, that palette color gets the check mark. What was saved was still the loose code. You only notice the difference when you change the palette color later: the button does not change with it.

You can only see it in the code of the block. That is also where the assistant converted it.

What I learn from it

“Surely an administrator can simply do that” was true, and still no reason to go ahead. The action was allowed. The side effect was the problem.

If you let someone with fewer rights work in the Site Editor, a colleague, a client or an assistant, try out on a copy what happens when they save. Not whether it works, but what is still there afterwards.

And whether a site works is not something you measure on the front page.

For the techies: what exactly happens

Environment: WordPress 7.1.2 multisite (subdomains), Ollie theme, a user with the Administrator role on the site who is not a super admin.

1. The capability. In WordPress, edit_css is tied to unfiltered_html. In map_meta_cap() (wp-includes/capabilities.php):

case 'edit_css':
case 'unfiltered_html':
    if ( defined( 'DISALLOW_UNFILTERED_HTML' ) && DISALLOW_UNFILTERED_HTML ) {
        $caps[] = 'do_not_allow';
    } elseif ( is_multisite() && ! is_super_admin( $user_id ) ) {
        $caps[] = 'do_not_allow';
    } else {
        $caps[] = 'unfiltered_html';
    }

2. The filter. For users without unfiltered_html, WordPress hooks wp_filter_global_styles_post() onto content_save_pre when saving. That calls WP_Theme_JSON::remove_insecure_properties(), which contains:

// The global styles custom CSS is not sanitized, but can only be edited by users with 'edit_css' capability.
if ( isset( $input['css'] ) && current_user_can( 'edit_css' ) ) {
    $output = $input;
} else {
    $output = static::remove_insecure_styles( $input );
}

So without the capability, styles.css is left behind. It does not matter whether the request itself contained the CSS: the filter runs over the whole content of the post of type wp_global_styles.

3. Measuring without writing. Request the global styles through the REST API with context=edit and look in _links. If wp:action-edit-css is not there, this user does not have the capability and should not save.

4. The test. With WP-CLI, wp_set_current_user() per user and then:

$post = get_post( $id_van_de_global_styles );
$na   = json_decode( wp_unslash( wp_filter_global_styles_post( wp_slash( $post->post_content ) ) ), true );
echo strlen( $na['styles']['css'] ?? '' );

5. The solution (in a plugin of my own):

function webtaurus_sitebeheerder_mag_css( $caps, $cap ) {
    if ( 'edit_css' !== $cap || ! is_multisite() ) {
        return $caps;
    }
    if ( defined( 'DISALLOW_UNFILTERED_HTML' ) && DISALLOW_UNFILTERED_HTML ) {
        return $caps;
    }
    return array( 'edit_theme_options' );
}
add_filter( 'map_meta_cap', 'webtaurus_sitebeheerder_mag_css', 10, 2 );

This only affects edit_css. Unfiltered HTML remains reserved for the super admin; I checked that after the change.

The warning that does exist. For CSS on individual blocks the block editor does show a warning: “If you save this post, the custom CSS will be removed.” Whether the Site Editor also warns under Styles, I have not seen: the assistant works through the REST API and does not see a screen.

The collision. Cannot redeclare unregister_default_wp_widgets(): the same function name in the functions.php of the theme and in the plugin. A plugin that is active per site, on sites with a different theme, does not notice this. Network-wide it does. Measure something like this on /wp-json/, not on the front page, if a page cache is running.

The color. What was saved was "style":{"color":{"background":"#084b77"}}. A palette color looks like this: "backgroundColor":"bg-blauw", with the class has-bg-blauw-background-color on the button instead of a background-color in the style attribute.