From d53bb9a2f7215574810ed9e1d99d6d64de82fcb2 Mon Sep 17 00:00:00 2001 From: Ignacio Nelson Date: Fri, 14 Aug 2026 12:20:57 -0300 Subject: [PATCH] Own the application directory in the official image (#1620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base php:*-fpm image creates /var/www/html owned by its own www-data (uid 82) and mode 1777, so that an image can run as an arbitrary user. This image replaces www-data with a fixed uid 1000 and copies the release in with COPY --chown — which re-owns what it copies into the directory, never the directory itself. It was left world-writable, sticky, and owned by a uid the container no longer has. fs.protected_symlinks — on by default on Ubuntu, Debian and most current distributions — then refuses to let a non-root process follow a symlink in such a directory, and .env is exactly that: the entrypoint keeps it on the storage volume so a generated APP_KEY survives container replacement, and links it into place. So every request 503'd with "ProjectSend is not configured yet" while `docker exec ... cat .env`, run as root, printed the file back perfectly (#1615). Three changes, each independently sufficient for the reported case, and deliberately so — this failure is silent and its symptom points away from its cause: - the image owns /var/www/html as the runtime user, at mode 755; - the entrypoint owns the symlink it creates, so it stays followable even if that directory's mode ever drifts back; - preflight distinguishes "no .env" from ".env is there and cannot be read", instead of reporting the second as the first and sending the operator off to create a file they already have. Verified by building the production image before and after: every request 503s beforehand, with /var/www/html at uid 82 mode 1777 and www-data denied on the symlink while root reads it; afterwards /up answers 200, the container reports healthy, and / redirects to /setup. Co-authored-by: Claude Opus 5 --- bootstrap/preflight.php | 30 ++++++++++++++++++++++++++++-- docker/production/Dockerfile | 14 ++++++++++++++ docker/production/entrypoint.sh | 8 ++++++++ tests/Unit/PreflightTest.php | 28 ++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/bootstrap/preflight.php b/bootstrap/preflight.php index 8ef7d226..9c2b6a54 100644 --- a/bootstrap/preflight.php +++ b/bootstrap/preflight.php @@ -62,9 +62,23 @@ function projectsend_preflight_failure(string $root): ?array $envFile = $root.'/.env'; $hasEnvFile = is_file($envFile); + // A .env that is there but unreadable looks exactly like one that was + // never created — is_file() is false either way, since it has to follow + // the link and stat the target — and the two need opposite advice. + // Reported from the official Docker image, where .env is a symlink into + // storage/: the image left /var/www/html world-writable and owned by a + // uid that no longer existed, so the kernel's fs.protected_symlinks + // refused to let the php-fpm worker follow it. Every request said "no + // .env file was found" while `docker exec ... cat .env`, as root, printed + // it back perfectly — which is the most misleading pair of facts this + // guard could possibly hand somebody. + $unreadableEnv = $hasEnvFile ? ! is_readable($envFile) : is_link($envFile); + // Fall back to a cheap read of just APP_KEY from the file — we are not - // going to boot Dotenv or parse the whole thing for one value. - if ($appKey === null && $hasEnvFile) { + // going to boot Dotenv or parse the whole thing for one value. Skipped + // when the file cannot be read, or file() emits a PHP warning into the + // page this guard exists to keep clean. + if ($appKey === null && $hasEnvFile && ! $unreadableEnv) { foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { if (preg_match('/^\s*(?:export\s+)?APP_KEY\s*=\s*(.*)$/', $line, $m) === 1) { // Strip the surrounding quotes Dotenv would also strip. @@ -79,6 +93,18 @@ function projectsend_preflight_failure(string $root): ?array return null; } + if ($unreadableEnv) { + return [ + 'ProjectSend cannot read its configuration', + 'A .env is in place, but the user PHP runs as cannot read it — or, if it ' + .'is a symlink, cannot follow it. Note that root is exempt from both ' + .'checks, so reading the file over docker exec or sudo ' + .'proves nothing here.', + "Compare who owns the file with who PHP runs as, and check the directory holding it:\n\n" + ."ls -ln .env\nstat -c '%n %U %a' . .env", + ]; + } + if (! $hasEnvFile) { return [ 'ProjectSend is not configured yet', diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 9b3b5d07..f7b04d91 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -80,10 +80,24 @@ WORKDIR /var/www/html COPY --chown=www-data:www-data app/ /var/www/html/ # storage/ and bootstrap/cache must be writable by the runtime user. +# +# /var/www/html itself needs re-owning as well, and it is easy to miss: COPY +# --chown re-owns what it copies *into* the directory, never the directory, +# so it keeps what the base image gave it — uid 82 (the www-data this image +# replaced above) and mode 1777, which php:*-fpm sets so that an image can +# run as an arbitrary user. Left that way, the directory is world-writable, +# sticky, and owned by nobody the container knows about, and the kernel's +# fs.protected_symlinks (on by default on Ubuntu, Debian and most current +# distributions) then refuses to let the php-fpm worker follow the .env +# symlink the entrypoint puts there. Root is exempt, so `docker exec ... cat +# .env` reads it back perfectly while every real request 503s with +# "ProjectSend is not configured yet". RUN mkdir -p storage/app/files storage/framework/cache storage/framework/sessions \ storage/framework/views storage/logs bootstrap/cache \ && chown -R www-data:www-data storage bootstrap/cache \ && chmod -R u+rwX storage bootstrap/cache \ + && chown www-data:www-data /var/www/html \ + && chmod 755 /var/www/html \ && mkdir -p /run/nginx # The whole of storage/, not just storage/app/files. Uploaded files are the diff --git a/docker/production/entrypoint.sh b/docker/production/entrypoint.sh index 49e1b3c4..27efce96 100755 --- a/docker/production/entrypoint.sh +++ b/docker/production/entrypoint.sh @@ -40,7 +40,15 @@ if [ ! -f storage/.env ]; then cp .env.example storage/.env chown www-data:www-data storage/.env fi +# +# The symlink is owned by the runtime user, not by root who creates it: +# fs.protected_symlinks lets a process follow a symlink in a world-writable +# sticky directory only when it owns the link (or the directory). The image +# no longer leaves /var/www/html in that state, so this is the second lock +# on the same door — cheap, and it is what keeps the failure from coming +# back silently if that directory's mode ever drifts. [ -L .env ] || ln -sf storage/.env .env +chown -h www-data:www-data .env if [ -z "$APP_KEY" ] && ! grep -q '^APP_KEY=base64:' storage/.env; then echo "projectsend: generating APP_KEY (first boot, persisted to the storage volume)" diff --git a/tests/Unit/PreflightTest.php b/tests/Unit/PreflightTest.php index d2111528..77a416fa 100644 --- a/tests/Unit/PreflightTest.php +++ b/tests/Unit/PreflightTest.php @@ -68,6 +68,34 @@ it('fails when a .env exists with no APP_KEY line at all', function (): void { expect(projectsend_preflight_failure(preflightFixture(null, true)))->not->toBeNull(); }); +it('says so when .env is a symlink it cannot follow, rather than that none exists', function (): void { + // The official image symlinks .env into storage/. When that link cannot + // be followed — a missing target, or a directory whose permissions stop + // the web server user traversing it — every stat says "no such file", + // and "copy .env.example" is then exactly the wrong thing to be told. + $dir = preflightFixture(null, false); + symlink($dir.'/storage/.env', $dir.'/.env'); + + $failure = projectsend_preflight_failure($dir); + + expect($failure)->not->toBeNull() + ->and($failure[0])->toBe('ProjectSend cannot read its configuration') + ->and($failure[1])->toContain('cannot read it') + ->and(projectsend_preflight_command($failure[2]))->toContain('ls -ln .env') + ->and(projectsend_preflight_command($failure[2]))->not->toContain('cp .env.example'); +}); + +it('says so when .env exists but its permissions deny the reader', function (): void { + $dir = preflightFixture('base64:'.base64_encode(random_bytes(32)), true); + chmod($dir.'/.env', 0000); + clearstatcache(); + + $failure = projectsend_preflight_failure($dir); + + expect($failure)->not->toBeNull() + ->and($failure[0])->toBe('ProjectSend cannot read its configuration'); +})->skip(fn (): bool => function_exists('posix_geteuid') && posix_geteuid() === 0, 'root can read anything, so the mode says nothing'); + it('reads a quoted APP_KEY the way Dotenv would', function (): void { $dir = preflightFixture('"base64:'.base64_encode(random_bytes(32)).'"', true);