Skip to content

fix(image): let the runtime directories accept an arbitrary uid:gid - #81

Merged
zebby76 merged 1 commit into
Smals-Webtech:mainfrom
zebby76:fix/runtime-dirs-writable-by-any-uid
Sep 8, 2026
Merged

fix(image): let the runtime directories accept an arbitrary uid:gid#81
zebby76 merged 1 commit into
Smals-Webtech:mainfrom
zebby76:fix/runtime-dirs-writable-by-any-uid

Conversation

@zebby76

@zebby76 zebby76 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Repairs a regression introduced by #73 and carried to 8.4 by #74. Nothing published is
affected
8.5.9 predates #73 and still ships 777; the regression exists only in main and
8.4, unreleased.

What broke

#73 replaced COPY --chmod=777 with --chmod=775 on /opt and /app. That locks out a process
started with an explicit GID:

docker run -u 1000      … id  ->  uid=1000 gid=0(root)     # Docker falls back to group 0
docker run -u 1000:1000 … id  ->  uid=1000 gid=1000        # its own group

docker run --rm -u 1000:1000 <cli> php -v
  [ERROR] ! /opt/etc is not writable.        exit 1

With a uid:gid pair the process is neither the owner (1001) nor in the group (0) of a 775
directory, so it cannot write.

That pair is not an exotic invocation. It is how the demo Makefiles run the builder container —
--user $(id -u):$(id -g) — so that composer's output belongs to the person who started the build
rather than to root. Every downstream team building an application image the same way hits the same
wall.

Why the check added in #73 did not catch it: the measurements used -u 1000 and
-u 1000670000, both bare uids that Docker completes with gid 0, where 775 suffices. The existing
bats assertion (tests.cli.bats:47) does exactly the same, which is why it stayed green.

What this does instead of reverting to 777

Of the 89 world-writable entries the old image shipped, 61 were the configuration templates under
/opt/config
— never written at runtime. The ~24 that matter are the runtime directories. Those
become 1777 (the /tmp semantics: anyone may create, only the owner may remove); everything else
keeps 775.

RUN find /opt/etc /opt/sbin /app/tmp /app/var -type d -exec chmod 1777 {} +
main today after
-u 1000:1000 exit 1 starts
-u 1000, -u 1000670000, -u 4242:4242 ok / ok / — ok
world-writable entries 89 22, all empty runtime directories, drwxrwxrwt
of which /opt/config 61 0

22 sticky directories is stricter than the 89 the image shipped before #73, so this is not a
revert.

The #73 assertion is tightened, not dropped

Nothing under /opt or /app is world-writable failed here, correctly — it forbade what the design
needs. It now states the real invariant, which promises more than the original, not less:

  • nothing world-writable outside /opt/etc, /opt/sbin, /app/var, /app/tmp;
  • every world-writable entry carries the sticky bit;
  • /opt/config in particular has none.

A new tests.cli.bats assertion runs the image as an explicit uid:gid — the case that would have
caught this.

Verification

The workflow that surfaced the defect is the one that closes it:
make -C test/demo-symfony composer-install completes, and vendor/ is owned by the invoking user
(sem:sem).

Image suites: nginx 48/48, apache 48/48, cli 9/9.
Demo stacks, demo-infra up: demo-origin 19/19, demo-prime 12/12, demo-symfony 6/6.

Two failures seen along the way were traced rather than assumed, and neither is a regression: the
first curl_container call pulled its image and the pull progress polluted the captured output, and
the Symfony demo carried a stale compiled cache in its persistent volume
(Call to undefined function deepclone_from_array(), present in /app/var/cache/dev/pools/system
and in neither the source nor vendor/). Both pass once the image is cached and the cache cleared.

MARKDOWN, NATURAL_LANGUAGE, BASH_EXEC and SHELL_SHFMT linters clean. DOCKERFILE_HADOLINT
reports the same 9 pre-existing warnings as main and is disabled in CI.

Backport to 8.4 to follow — it carries the same --chmod=775 lines and no 1777.

Smals-Webtech#73 replaced COPY --chmod=777 with --chmod=775 on /opt and /app, and Smals-Webtech#74 carried
that to 8.4. It locks out a process started with an explicit GID.

    docker run -u 1000      … id  ->  uid=1000 gid=0(root)
    docker run -u 1000:1000 … id  ->  uid=1000 gid=1000

Docker only falls back to group 0 when the user is given as a bare uid. With a
uid:gid pair the process belongs to neither the owner (1001) nor the group (0)
of a 775 directory, so it cannot write:

    docker run --rm -u 1000:1000 <cli> php -v
      [ERROR] ! /opt/etc is not writable.        exit 1

That pair is not an exotic invocation. It is how the demo Makefiles run the
builder container -- `--user $(id -u):$(id -g)` -- so that composer's output
belongs to the person who started the build rather than to root. Every
downstream team building an application image the same way hits the same wall.

Nothing published is affected: 8.5.9 predates Smals-Webtech#73 and still ships 777. The
regression exists only in main and 8.4, unreleased.

Why the check I added in Smals-Webtech#73 did not catch it: I measured `-u 1000` and
`-u 1000670000`, both bare uids that Docker completes with gid 0, where 775 is
enough. The existing bats assertion does exactly the same, which is why it stayed
green. It now asserts an explicit uid:gid instead.

Reverting to 777 was the obvious fix and is not the one taken. Of the 89
world-writable entries the old image shipped, 61 were the configuration
templates under /opt/config, which are never written at runtime; the ~24 that
matter are the runtime directories. Those are 1777 now -- the /tmp semantics,
anyone may create, only the owner may remove -- and everything else keeps 775.
That leaves 22 world-writable entries instead of 89, all of them empty runtime
directories carrying the sticky bit, which is stricter than what the image
shipped before Smals-Webtech#73.

The "nothing under /opt or /app is world-writable" assertion from Smals-Webtech#73 goes with
it. It was too strong: it forbade what the design needs. It now states the real
invariant -- nothing world-writable outside the four runtime paths, and every
world-writable entry sticky -- which is a tighter thing to promise than the
original, not a looser one.

Verified with the workflow that surfaced this: `make -C test/demo-symfony
composer-install` completes and its output is owned by the invoking user.
@zebby76
zebby76 merged commit 3acad65 into Smals-Webtech:main Sep 8, 2026
19 checks passed
zebby76 added a commit that referenced this pull request Sep 8, 2026
…backport of #81 to 8.4) (#82)

#73 replaced COPY --chmod=777 with --chmod=775 on /opt and /app, and #74 carried
that to 8.4. It locks out a process started with an explicit GID.

    docker run -u 1000      … id  ->  uid=1000 gid=0(root)
    docker run -u 1000:1000 … id  ->  uid=1000 gid=1000

Docker only falls back to group 0 when the user is given as a bare uid. With a
uid:gid pair the process belongs to neither the owner (1001) nor the group (0)
of a 775 directory, so it cannot write:

    docker run --rm -u 1000:1000 <cli> php -v
      [ERROR] ! /opt/etc is not writable.        exit 1

That pair is not an exotic invocation. It is how the demo Makefiles run the
builder container -- `--user $(id -u):$(id -g)` -- so that composer's output
belongs to the person who started the build rather than to root. Every
downstream team building an application image the same way hits the same wall.

Nothing published is affected: 8.5.9 predates #73 and still ships 777. The
regression exists only in main and 8.4, unreleased.

Why the check I added in #73 did not catch it: I measured `-u 1000` and
`-u 1000670000`, both bare uids that Docker completes with gid 0, where 775 is
enough. The existing bats assertion does exactly the same, which is why it stayed
green. It now asserts an explicit uid:gid instead.

Reverting to 777 was the obvious fix and is not the one taken. Of the 89
world-writable entries the old image shipped, 61 were the configuration
templates under /opt/config, which are never written at runtime; the ~24 that
matter are the runtime directories. Those are 1777 now -- the /tmp semantics,
anyone may create, only the owner may remove -- and everything else keeps 775.
That leaves 22 world-writable entries instead of 89, all of them empty runtime
directories carrying the sticky bit, which is stricter than what the image
shipped before #73.

The "nothing under /opt or /app is world-writable" assertion from #73 goes with
it. It was too strong: it forbade what the design needs. It now states the real
invariant -- nothing world-writable outside the four runtime paths, and every
world-writable entry sticky -- which is a tighter thing to promise than the
original, not a looser one.

Verified with the workflow that surfaced this: `make -C test/demo-symfony
composer-install` completes and its output is owned by the invoking user.

(cherry picked from commit 3acad65)
@zebby76
zebby76 deleted the fix/runtime-dirs-writable-by-any-uid branch September 8, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant