chmod 2775 doesn’t help when the group is root

Title card: chmod 2775 doesn't help when the group is root

I’d like to say this is the last post about file permissions on migrated WordPress boxes. I said something like that last time.

Quick recap if you missed the earlier two. In August a forms plugin couldn’t write under uploads/, so I wrote a repair script for uploads/. In September a different plugin couldn’t overwrite its own file nowhere near uploads/, and the real cause turned out to be the admin user’s umask, so the fix moved up to provisioning and the repair script widened to the whole of wp-content. Setgid on directories, group-write on everything, and a guarantee in the script header that it only ever adds bits and never changes ownership.

That guarantee is what this post is about.

The update that wouldn’t

A site on a box that had already been through the full repair. I went to update the Redis object-cache plugin from wp-admin, and the update failed on the step where it deletes the old version. Permission denied.

WordPress updates a plugin by removing the old directory and unpacking the new one in its place, running as the web server user, and the web server user wasn’t allowed to remove it.

My first thought was that the repair hadn’t run on that box. It had. The logs said so, and so did the modes:

$ ls -ld wp-content/plugins/redis-cache wp-content/object-cache.php
drwxrwsr-x ... root root ... wp-content/plugins/redis-cache
-rw-rw-r-- ... root root ... wp-content/object-cache.php

The modes were exactly what I’d asked for. 2775 on the directory, group-writable file. Look one column to the right though.

Group-writable, by the wrong group

Mode bits only mean something relative to the owner and group they’re attached to. drwxrwsr-x root root says: root can write, members of group root can write, everyone else can read and traverse. PHP-FPM runs as www-data, which isn’t root and isn’t in group root, so it’s “everyone else”. Read-only.

The earlier fixes all assumed the group was already www-data and the only problem was missing bits. For almost everything under wp-content that’s true, because provisioning chowns the tree to admin:www-data and setgid keeps new directories in that group.

So how did a plugin end up root:root?

Because provisioning installed it as root. The tuning stage that sets up the object cache runs something like this:

sudo wp --allow-root plugin install redis-cache
sudo wp --allow-root redis enable

The first line unpacks the plugin as root, and the second writes the object-cache.php drop-in into wp-content, also as root. Why setgid didn’t carry the group across, I’m not completely sure. The box may have been tuned before setgid was on those directories, or the installer may unpack into a temp directory and move the result into place, and a move keeps whatever group it started with. Either way, by the time the repair script got there, root was sitting in both columns.

The flag is literally called --allow-root. It’s wp-cli warning you in advance. I had it in a script, so I’d agreed to it once and then forgotten about it.

And the repair script couldn’t touch it, because of the very rule I’d been pleased with: bit-adds only, never chown. That rule was there to protect a mixed-ownership tree — cache directories and session files the web server itself creates and owns. It also made the script blind to anything owned by the wrong account entirely.

Chown, but only the root-owned bits

The fix for boxes already out there is a fourth sweep in the repair script. It finds anything under wp-content owned by root and hands it to admin:www-data, and it touches nothing else:

root_owned=$(find "$WPCONT" -user root 2>/dev/null | wc -l)
log "  entries currently owned by user root: $root_owned"

if [ "$root_owned" -gt 0 ]; then
    if [ "$DRY_RUN" = 1 ]; then
        find "$WPCONT" -user root -printf '%M %u:%g  %p\n' | head -10
    else
        find "$WPCONT" -user root -print0 | xargs -0 -r chown admin:www-data
    fi
fi

The -user root filter is doing the careful part. Files that belong to www-data because the site wrote them at runtime stay exactly as they are. Files that belong to admin were already fine. Only root-owned entries move, and there’s no good reason for anything under wp-content to be owned by root in the first place.

The dry-run prints a sample rather than the lot, because on a box where something went badly wrong I’d rather see ten lines and decide than scroll through four thousand.

And stop making them

The prevention side is small. Right after the object-cache install, the provisioning scripts now chown what they just created:

for TARGET in /var/www/html/wp-content/plugins/redis-cache \
              /var/www/html/wp-content/object-cache.php; do
    if [ -e "$TARGET" ]; then
        sudo chown -R admin:www-data "$TARGET"
    fi
done

It’s specific to that one install, which I’m aware is the August mistake again, scoped to where I happened to notice it. That’s why the general sweep exists as well. The specific chown means new boxes come up right; the sweep catches the next thing some script installs as root that I haven’t thought of yet.

A small confession while I’m here. The PowerShell wrapper that pushes the repair script out to every box had been in use across the whole fleet for a while, and had never been committed. It went into git in the same commit as this fix. Nothing bad came of it. I’d still rather it was the other way round.

What I’d take from it

Every repair script has a safety rule, and the safety rule is also a list of things the script can’t fix. “Never chowns” was the right call for the problem I was solving at the time. It just meant the script’s guarantee and its blind spot were the same sentence.

And when you’re checking permissions, read the whole ls -l line. I spent the first two rounds of this staring at the mode column. The problem this time was in the two columns next to it.

Leave a Reply

Your email address will not be published. Required fields are marked *