diff --git a/.claude/deploiement.md b/.claude/deploiement.md index 3b686f2f..c5e8d93f 100644 --- a/.claude/deploiement.md +++ b/.claude/deploiement.md @@ -55,6 +55,10 @@ RB_USER_PASSWORD='' php bin/create-user.php admin # Le mot de passe, s'il y en a un, passe par l'environnement (jamais en argument). La connexion se fait avec l'**e-mail**. **Pas d'inscription publique** (#137) : `/register` et `POST /api/auth/register` n'existent plus. Les groupes sont créés depuis l'écran Groupes. +## Droits des fichiers du serveur (#440) + +Chaque déploiement (étape « Droits des fichiers du serveur », avant la bascule) referme `shared/` (700), `backups/` (700), tout `shared/storage/` (dossiers 700, fichiers 600) et les fichiers de `shared/` (600) ; seul `shared/well-known` (défis du certificat) reste en 755. Il CONTRÔLE ensuite : un fichier de `shared/` encore accessible au groupe ou aux autres arrête le déploiement. Une copie `config.local.php.bak*` laissée par une manipulation manuelle est signalée (elle contient les secrets) : la supprimer une fois la vérification faite. Côté application : les journaux (dossier 700, fichier 600) et les documents (600) sont créés fermés par le code. + ## Tâche planifiée : relances de la messagerie (#180, #178) `bin/send-reminders.php` envoie, **en deux temps** (relances de groupe puis relances de mention, deux lignes de bilan, puis une ligne sur l'oubli des anciennes versions de messages corrigés, #225), à l'adresse de contact d'un groupe **une relance** quand un message de l'autre côté est resté **24 h sans lecture** par ce groupe (jamais le contenu du message), seulement entre 9 h et 20 h (heure locale `app.timezone`) ; hors plage il ne fait rien et les relances dues partent le matin. Un message de plus de 7 jours n'est plus relancé. La **relance de mention** (#178) part à l'adresse du compte d'une personne taguée qui n'a pas lu la conversation 24 h après l'e-mail de mention (une seule par e-mail de mention, jamais le contenu) ; la même plage de jour s'applique. Le script s'arrête en erreur (code 1) si l'un des deux envois échoue. Idempotent : peut être relancé sans doublon. diff --git a/bin/deploy.sh b/bin/deploy.sh index 0a77cf45..7e9b2205 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -26,12 +26,12 @@ REMOTE_COMPOSER="${RB_REMOTE_COMPOSER:-/usr/local/bin/composer}" KEEP_RELEASES=3 rb_ssh() { ssh -F "$SSH_CONFIG" -o BatchMode=yes "$SSH_HOST" "$@"; } -# Étapes numérotées avec barre de progression (#277) : 12 étapes au total, celles qu'on saute comptent quand même. +# Étapes numérotées avec barre de progression (#277) : 13 étapes au total, celles qu'on saute comptent quand même. source "$(dirname "$0")/lib/progress.sh" source "$(dirname "$0")/lib/ci-status.sh" # Journal des déploiements (#396) : une ligne par déploiement dans shared/deployments.jsonl, sans jamais bloquer le déploiement. source "$(dirname "$0")/lib/deploy-log.sh" -progress_init 12 +progress_init 13 secret() { local value @@ -78,6 +78,31 @@ rb_ssh "mkdir -p \"\$HOME/$BASE/releases\" \"\$HOME/$BASE/shared/storage/group-d git archive --format=tar HEAD -- bin config database public src templates composer.json composer.lock ':(exclude)database/seed.php' \ | rb_ssh "tar -x -C \"\$HOME/$BASE/releases/$release\"" +# --- 2b. Droits des fichiers du serveur (#440) ------------------------------ +# Tout ce qui est sous shared/ (secrets, documents, journaux) et backups/ est réservé au propriétaire ; seul well-known (défis du +# certificat) reste lisible par le serveur web. Corrige l'existant à chaque déploiement, puis CONTRÔLE : un fichier de shared/ resté +# accessible au groupe ou aux autres arrête le déploiement avant la bascule. +progress_step "Droits des fichiers du serveur" +rb_ssh bash -s -- "$BASE" <<'REMOTE' +set -euo pipefail +cd "$HOME/$1" +chmod 700 shared backups +chmod -R go-rwx shared/storage backups +chmod 755 shared/well-known +find shared -maxdepth 1 -type f -exec chmod 600 {} + +leaks=$(find shared -path shared/well-known -prune -o -perm /077 -print) +if [ -n "$leaks" ]; then + echo "ÉCHEC : fichiers de shared/ accessibles au groupe ou aux autres :" >&2 + echo "$leaks" >&2 + exit 1 +fi +backups=$(find shared -maxdepth 1 -name 'config.local.php.bak*' -print) +if [ -n "$backups" ]; then + echo "ATTENTION : copie de la configuration (contient les secrets) à supprimer une fois la vérification faite :" >&2 + echo "$backups" >&2 +fi +REMOTE + # --- 3. Dépendances et liens vers les éléments partagés ------------------- progress_step "composer install --no-dev (PHP CLI explicite)" rb_ssh bash -s -- "$BASE" "$release" "$REMOTE_PHP" "$REMOTE_COMPOSER" <<'REMOTE' diff --git a/src/Group/Service/GroupDocumentService.php b/src/Group/Service/GroupDocumentService.php index 4d2dec01..ea195612 100644 --- a/src/Group/Service/GroupDocumentService.php +++ b/src/Group/Service/GroupDocumentService.php @@ -78,6 +78,8 @@ public function upload(int $groupId, int $actorUserId, string $tmpFilePath, stri throw $e; } + // Lisible du seul propriétaire (#440) : le serveur web n'a pas à servir ce fichier, seul le téléchargement contrôlé le fait. + @chmod($destinationPath, 0600); try { // « Compter puis ajouter » est ATOMIQUE : le groupe est verrouillé le temps de la transaction, donc des envois diff --git a/src/Logging/FileLogger.php b/src/Logging/FileLogger.php index f6e625b2..78a72b73 100644 --- a/src/Logging/FileLogger.php +++ b/src/Logging/FileLogger.php @@ -72,11 +72,16 @@ public function log($level, string|\Stringable $message, array $context = []): v private function write(string $line): void { $dir = dirname($this->file); - if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) { + // Dossier et fichier réservés au propriétaire (#440) : un journal peut citer des identifiants d'utilisateurs. + if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) { return; } $this->rotateIfNeeded(); + $created = !is_file($this->file); @file_put_contents($this->file, $line, \FILE_APPEND | \LOCK_EX); + if ($created) { + @chmod($this->file, 0600); + } } private function rotateIfNeeded(): void diff --git a/tests/Bin/ServerPermissionsDeployTest.php b/tests/Bin/ServerPermissionsDeployTest.php new file mode 100644 index 00000000..0d06e686 --- /dev/null +++ b/tests/Bin/ServerPermissionsDeployTest.php @@ -0,0 +1,114 @@ +home = sys_get_temp_dir() . '/rb-perms-' . bin2hex(random_bytes(4)); + mkdir($this->home . '/app/shared/storage/group-documents', 0o775, true); + mkdir($this->home . '/app/shared/storage/logs', 0o775, true); + mkdir($this->home . '/app/shared/well-known', 0o775, true); + mkdir($this->home . '/app/backups', 0o755, true); + foreach (['shared/config.local.php' => 0o664, 'shared/reminders.log' => 0o644, 'shared/storage/logs/app.log' => 0o664, 'shared/storage/group-documents/doc.pdf' => 0o644, 'backups/db.sql.gz' => 0o644] as $file => $mode) { + file_put_contents($this->home . '/app/' . $file, 'x'); + chmod($this->home . '/app/' . $file, $mode); + } + chmod($this->home . '/app/shared', 0o775); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->home)); + } + + /** Le corps du bloc distant « Droits des fichiers du serveur » de bin/deploy.sh, tel qu'il s'exécute sur le serveur. */ + private function remoteBlock(): string + { + $script = (string) file_get_contents(__DIR__ . '/../../bin/deploy.sh'); + self::assertSame(1, preg_match("/progress_step \"Droits des fichiers du serveur\"\nrb_ssh bash -s -- \"\\\$BASE\" <<'REMOTE'\n(.*?)\nREMOTE\n/s", $script, $match), 'bloc distant introuvable'); + + return $match[1]; + } + + /** @return array{int, string} code de sortie et sortie d'erreur */ + private function runBlock(): array + { + $process = proc_open(['bash', '-s', '--', 'app'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, ['HOME' => $this->home, 'PATH' => (string) getenv('PATH')]); + fwrite($pipes[0], $this->remoteBlock()); + fclose($pipes[0]); + stream_get_contents($pipes[1]); + $errors = (string) stream_get_contents($pipes[2]); + + return [proc_close($process), $errors]; + } + + private function mode(string $relative): string + { + return substr(sprintf('%o', fileperms($this->home . '/app/' . $relative)), -3); + } + + #[Test] + public function testSecretsDocumentsLogsAndBackupsAreClosedToEveryoneButTheOwner(): void + { + [$code] = $this->runBlock(); + + self::assertSame(0, $code); + foreach (['shared' => '700', 'backups' => '700', 'shared/storage' => '700', 'shared/storage/group-documents' => '700', 'shared/config.local.php' => '600', 'shared/reminders.log' => '600', 'shared/storage/logs/app.log' => '600', 'shared/storage/group-documents/doc.pdf' => '600', 'backups/db.sql.gz' => '600'] as $path => $mode) { + self::assertSame($mode, $this->mode($path), $path); + } + } + + #[Test] + public function testOnlyTheCertificateChallengeFolderStaysReadableByTheWebServer(): void + { + $this->runBlock(); + + self::assertSame('755', $this->mode('shared/well-known')); + } + + #[Test] + public function testLeftoverConfigurationCopiesAreReportedNotDeleted(): void + { + file_put_contents($this->home . '/app/shared/config.local.php.bak-20261011', 'secrets'); + + [$code, $errors] = $this->runBlock(); + + self::assertSame(0, $code, 'un avertissement, pas un échec'); + self::assertStringContainsString('config.local.php.bak-20261011', $errors); + self::assertFileExists($this->home . '/app/shared/config.local.php.bak-20261011'); + } + + #[Test] + public function testTheCheckStopsTheDeploymentWhenSomethingStaysOpen(): void + { + // Un fichier que le propriétaire ne peut pas refermer : lien vers un fichier d'un autre compte ou système de fichiers sans droits. + $script = str_replace('find shared -maxdepth 1 -type f -exec chmod 600 {} +', 'true', $this->remoteBlock()); + $process = proc_open(['bash', '-s', '--', 'app'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, ['HOME' => $this->home, 'PATH' => (string) getenv('PATH')]); + fwrite($pipes[0], $script); + fclose($pipes[0]); + stream_get_contents($pipes[1]); + $errors = (string) stream_get_contents($pipes[2]); + + self::assertSame(1, proc_close($process)); + self::assertStringContainsString('ÉCHEC', $errors); + self::assertStringContainsString('config.local.php', $errors); + } + + #[Test] + public function testTheStepRunsBeforeTheReleaseIsSwitched(): void + { + $script = (string) file_get_contents(__DIR__ . '/../../bin/deploy.sh'); + + self::assertLessThan(strpos($script, 'ln -sfn "$HOME/$base/releases/$rel" current.new'), strpos($script, 'Droits des fichiers du serveur')); + } +} diff --git a/tests/Group/Service/GroupDocumentServiceTest.php b/tests/Group/Service/GroupDocumentServiceTest.php index 9d2baf6b..a3e3f8b3 100644 --- a/tests/Group/Service/GroupDocumentServiceTest.php +++ b/tests/Group/Service/GroupDocumentServiceTest.php @@ -534,4 +534,17 @@ public function testTheTotalVolumeOfAGroupIsCappedAndARefusedFileLeavesNothingOn self::assertCount(1, $this->storedFiles()); } + + #[Test] + public function testAStoredDocumentIsReadableByTheOwnerOnly(): void + { + [$service, $groupRepository, $userRepository] = $this->makeService(); + $group = $groupRepository->save(new Group(0, 'Groupe Droits', null, null, 'contact@example.test')); + $manager = $this->createUser($userRepository, 'droits@rehearsalbox.test'); + $groupRepository->addMember($group->id(), $manager->id(), GroupUserRole::Gestionnaire); + + $document = $service->upload($group->id(), $manager->id(), $this->fakeUploadedFile('%PDF-1.4 x', 'a.pdf'), 'a.pdf', 10); + + self::assertSame('0600', substr(sprintf('%o', fileperms($service->pathOf($document))), -4)); + } } diff --git a/tests/Logging/FileLoggerTest.php b/tests/Logging/FileLoggerTest.php index 696a40be..3f71b987 100644 --- a/tests/Logging/FileLoggerTest.php +++ b/tests/Logging/FileLoggerTest.php @@ -128,6 +128,18 @@ public function testTheDirectoryIsCreatedOnFirstWriteWithPrivatePermissions(): v { $this->logger()->error('Premier message'); - self::assertSame('0750', substr(sprintf('%o', fileperms($this->dir)), -4)); + self::assertSame('0700', substr(sprintf('%o', fileperms($this->dir)), -4)); + } + + #[Test] + public function testTheLogFileAndItsDirectoryAreReadableByTheOwnerOnly(): void + { + $nested = $this->dir . '/neuf'; + (new FileLogger($nested . '/app.log', new MockClock('2026-10-07 10:00:00 UTC')))->error('Une ligne'); + + self::assertSame('0600', substr(sprintf('%o', fileperms($nested . '/app.log')), -4), 'des identifiants d\'utilisateurs peuvent figurer dans un journal'); + self::assertSame('0700', substr(sprintf('%o', fileperms($nested)), -4)); + unlink($nested . '/app.log'); + rmdir($nested); } }