Own the application directory in the official image (#1620)

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 <noreply@anthropic.com>
This commit is contained in:
Ignacio Nelson
2026-08-14 12:20:57 -03:00
committed by GitHub
parent 9b265e3b87
commit d53bb9a2f7
4 changed files with 78 additions and 2 deletions
+28 -2
View File
@@ -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 <code>.env</code> is in place, but the user PHP runs as cannot read it — or, if it '
.'is a symlink, cannot follow it. Note that <code>root</code> is exempt from both '
.'checks, so reading the file over <code>docker exec</code> or <code>sudo</code> '
.'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',
+14
View File
@@ -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
+8
View File
@@ -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)"
+28
View File
@@ -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);