From 8e59daa4cd661ddd93a1cbcb66488006aafe5266 Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 16 Mar 2026 11:48:26 +0100 Subject: [PATCH] Working state but no uploads --- .env.example | 8 +- CONTRIBUTING.md | 2 +- README.md | 18 ++- bin/provision.php | 4 - config/container.php | 2 + database/.provision.lock | 0 docker-compose.yml | 5 +- docker/php/Dockerfile | 2 +- docs/GUIDE.md | 16 +-- public/index.php | 10 +- src/Auth/PasswordResetController.php | 11 +- src/Shared/Bootstrap.php | 55 ++++---- .../DatabaseNotProvisionedException.php | 8 ++ src/Shared/Database/DatabaseReadiness.php | 51 +++++++ src/Shared/Database/Seeder.php | 4 +- src/Shared/Http/ClientIpResolver.php | 32 +---- src/Shared/Http/RequestContext.php | 127 ++++++++++++++++++ src/Shared/Http/SessionManager.php | 4 +- tests/Auth/PasswordResetControllerTest.php | 35 ++++- tests/Shared/BootstrapTest.php | 23 +--- tests/Shared/RequestContextTest.php | 55 ++++++++ 21 files changed, 353 insertions(+), 119 deletions(-) create mode 100644 database/.provision.lock create mode 100644 src/Shared/Database/DatabaseNotProvisionedException.php create mode 100644 src/Shared/Database/DatabaseReadiness.php create mode 100644 src/Shared/Http/RequestContext.php create mode 100644 tests/Shared/RequestContextTest.php diff --git a/.env.example b/.env.example index e58c8eb..cd2112f 100644 --- a/.env.example +++ b/.env.example @@ -16,11 +16,17 @@ APP_URL=http://localhost:8080 # Fuseau horaire TIMEZONE=Europe/Paris +# Proxies de confiance pour interpréter X-Forwarded-For / X-Forwarded-Proto. +# Laisser vide en développement local sans proxy. +# En Docker derrière le Nginx fourni, docker-compose définit par défaut `*`. +# Exemples : TRUSTED_PROXIES=127.0.0.1,::1 ou TRUSTED_PROXIES=* +TRUSTED_PROXIES= + # ============================================================================= # Administration # ============================================================================= -# Compte administrateur (créé automatiquement au premier démarrage) +# Compte administrateur (créé lors du provisionnement initial) ADMIN_USERNAME=admin ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=changeme123 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25a1b6e..209e3a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Les mêmes que pour le développement (voir [README](README.md)), plus : -- PHP 8.1+ avec l'extension `dom` (requise par HTMLPurifier et PHPUnit) +- PHP 8.4.1+ avec l'extension `dom` (requise par HTMLPurifier et PHPUnit) ## Lancer les tests diff --git a/README.md b/README.md index b53abc9..fcc2480 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Slim Blog -![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4?logo=php&logoColor=white) +![PHP](https://img.shields.io/badge/PHP-8.4.1%2B-777BB4?logo=php&logoColor=white) ![Slim](https://img.shields.io/badge/Slim-4-74a045) ![PHPStan](https://img.shields.io/badge/PHPStan-niveau%208-blue) ![Tests](https://img.shields.io/badge/tests-355%20passing-brightgreen) @@ -38,7 +38,7 @@ projets (boutique, portfolio…). ## Développement -**Prérequis :** PHP 8.1+ avec `pdo_sqlite`, `intl`, `fileinfo`, `gd` (WebP), `xml` (dom), Composer, Node.js 18+ +**Prérequis :** PHP 8.4.1+ avec `pdo_sqlite`, `intl`, `fileinfo`, `gd` (WebP), `xml` (dom), Composer, Node.js 18+ ```bash git clone https://git.netig.net/netig/slim-blog @@ -46,6 +46,7 @@ cd slim-blog composer install npm install && npm run build cp .env.example .env +php bin/provision.php php -S localhost:8080 -t public ``` @@ -76,6 +77,7 @@ cd slim-blog cp .env.example .env # Définir APP_ENV=production, APP_URL, ADMIN_PASSWORD et la configuration SMTP docker compose up -d --build +docker compose exec app php bin/provision.php ``` > Le démarrage en production avec `ADMIN_PASSWORD=changeme123` est bloqué intentionnellement. @@ -126,6 +128,8 @@ process principal et n'apparaissent pas dans les logs Docker. Nginx écoute sur `127.0.0.1:8888` (câblé dans `docker-compose.yml`). +Configurer aussi `TRUSTED_PROXIES` si vous déployez l'application derrière un autre proxy que le Nginx Docker fourni. En stack Docker par défaut, `docker-compose.yml` force `TRUSTED_PROXIES=*` côté conteneur PHP-FPM pour faire confiance au Nginx interne. + Exemple de configuration Caddy : ```caddy @@ -143,6 +147,7 @@ https://blog.exemple.com { | `APP_URL` | URL de base (liens emails, flux RSS) — inclure le port en développement | `http://localhost:8080` | | `APP_NAME` | Nom du blog (flux RSS, emails) | `Slim Blog` | | `TIMEZONE` | Fuseau horaire PHP | `Europe/Paris` | +| `TRUSTED_PROXIES` | Proxies autorisés à fournir `X-Forwarded-For` / `X-Forwarded-Proto` | `127.0.0.1,::1` ou `*` | | `ADMIN_USERNAME` | Nom d'utilisateur du compte admin | `admin` | | `ADMIN_EMAIL` | Email du compte admin | `admin@example.com` | | `ADMIN_PASSWORD` | Mot de passe admin (obligatoire en production) | *(à changer)* | @@ -172,6 +177,9 @@ Le contenu du blog (articles publiés) est soumis à [CC BY-SA 4.0](https://crea ## Provisioning -Le provisionnement (migrations + seed admin) peut etre execute explicitement via `php bin/provision.php`. -En developpement, il reste activable automatiquement via `APP_AUTO_PROVISION=true`. -En production, il est recommande de le lancer separement du runtime HTTP. +Le provisionnement (migrations + seed admin) s'execute explicitement via `php bin/provision.php`. + +- Developpement local : executer `php bin/provision.php` apres `cp .env.example .env` +- Docker / production : executer `docker compose exec app php bin/provision.php` apres le demarrage du conteneur + +Le runtime HTTP ne provisionne plus automatiquement la base. Si le schema n'est pas present, l'application echoue avec un message explicite demandant d'executer la commande de provisionnement. diff --git a/bin/provision.php b/bin/provision.php index ef5d40e..1b02f39 100644 --- a/bin/provision.php +++ b/bin/provision.php @@ -8,10 +8,6 @@ require __DIR__ . '/../vendor/autoload.php'; use App\Shared\Bootstrap; use App\Shared\Database\Provisioner; -$_ENV['APP_AUTO_PROVISION'] = '0'; - -session_status() === PHP_SESSION_ACTIVE || session_start(); - $bootstrap = Bootstrap::create(); $container = $bootstrap->initializeInfrastructure(); Provisioner::run($container->get(\PDO::class)); diff --git a/config/container.php b/config/container.php index efd45cb..97dd73c 100644 --- a/config/container.php +++ b/config/container.php @@ -172,12 +172,14 @@ return [ PasswordResetServiceInterface $passwordResetService, AuthServiceInterface $authService, FlashServiceInterface $flash, + ClientIpResolver $clientIpResolver, ): PasswordResetController { return new PasswordResetController( $twig, $passwordResetService, $authService, $flash, + $clientIpResolver, rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'), ); } diff --git a/database/.provision.lock b/database/.provision.lock new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 51f878d..23be5ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,10 @@ services: env_file: .env # Vérifie que PHP-FPM écoute sur le port 9000 avant de déclarer le service sain. - # bash /dev/tcp est disponible sur l'image Debian php:8.3-fpm sans dépendance + environment: + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-*} + + # bash /dev/tcp est disponible sur l'image Debian php:8.4-fpm sans dépendance # supplémentaire. start_period laisse le temps à entrypoint.sh de terminer # (sync public/, migrations, seed) avant que les échecs ne comptent. healthcheck: diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index ff018c3..82b6d2f 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -11,7 +11,7 @@ COPY assets/ assets/ RUN npm run build # ── Stage 2 : image PHP de production ─────────────────────────────────────── -FROM php:8.3-fpm +FROM php:8.4-fpm # Extensions système et PHP dans un seul layer RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f468a99..e043e71 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -924,13 +924,8 @@ EditorMiddleware.php — redirige si rôle != editor ni admin `AuthController::login()` orchestre trois responsabilités dans l'ordre : vérifier le rate limit, authentifier, ouvrir la session. ```php -// 0. Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx) -// En production Docker, REMOTE_ADDR retourne l'IP interne du proxy. -// X-Forwarded-For contient l'IP d'origine du client — on lit le premier -// segment, qui est injecté par Nginx/Caddy et ne peut pas être forgé. -$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? '')); -$ip = $forwarded !== '' ? trim(explode(',', $forwarded)[0]) - : ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0'); +// 0. Résolution de l'IP réelle derrière un reverse proxy approuvé +$ip = $this->clientIpResolver->resolve($req); // 1. Vérification du rate limit (avant toute authentification) $remainingMinutes = $this->authService->checkRateLimit($ip); @@ -952,7 +947,7 @@ $this->authService->login($user); // écrit userId/username/role en session > 💡 `AuthService::authenticate()` ne gère pas le rate limiting — c'est `AuthController` qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment. > -> ⚠️ L'IP lue depuis `REMOTE_ADDR` derrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. La logique ci-dessus lit `HTTP_X_FORWARDED_FOR` en priorité, ce qui est sûr dans un contexte Docker où seul Nginx/Caddy contrôle cet en-tête. +> ⚠️ L'IP lue depuis `REMOTE_ADDR` derrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. Le projet centralise désormais cette logique dans `ClientIpResolver` / `RequestContext` et ne fait confiance aux en-têtes `X-Forwarded-*` que pour les proxies explicitement approuvés via `TRUSTED_PROXIES`. #### Réinitialisation de mot de passe @@ -1138,7 +1133,7 @@ Le provisionnement des données initiales (compte admin) est géré séparément ### 7.1 Développement local (sans Docker) -Prérequis : PHP 8.1+ avec les extensions `pdo_sqlite`, `mbstring`, `fileinfo`, `gd` (WebP), `dom`. Composer. Node.js 18+. +Prérequis : PHP 8.4.1+ avec les extensions `pdo_sqlite`, `mbstring`, `fileinfo`, `gd` (WebP), `dom`. Composer. Node.js 18+. ```bash git clone https://git.netig.net/netig/slim-blog @@ -1206,7 +1201,7 @@ L'application est accessible sur `http://localhost:8888` une fois le service `ap Le conteneur nginx écoute sur `127.0.0.1:8888` — uniquement sur l'interface loopback de la machine hôte. Il n'est **pas** accessible depuis Internet sans un reverse proxy configuré sur le serveur. Ce choix est délibéré : c'est le reverse proxy qui prend en charge le TLS et redirige le trafic HTTPS vers le conteneur. -La config Nginx interne transmet déjà les en-têtes nécessaires à PHP (`X-Forwarded-For`, `X-Forwarded-Proto`) pour que l'application connaisse l'IP réelle du client et sache si la connexion est HTTPS. +La config Nginx interne transmet déjà les en-têtes nécessaires à PHP (`X-Forwarded-For`, `X-Forwarded-Proto`) pour que l'application connaisse l'IP réelle du client et sache si la connexion est HTTPS. Le conteneur `app` accepte ces en-têtes uniquement depuis les proxies listés dans `TRUSTED_PROXIES` (par défaut `*` dans `docker-compose.yml`, car PHP-FPM n'est joignable qu'à travers le Nginx interne). Exemple minimal avec **Caddy** (`/etc/caddy/Caddyfile`) : @@ -1226,6 +1221,7 @@ Caddy gère automatiquement l'obtention et le renouvellement du certificat TLS. | APP_NAME | Nom du blog (flux RSS, e-mails) | `Slim Blog` | | APP_URL | URL publique (liens e-mails, flux RSS) | `https://blog.exemple.com` | | TIMEZONE | Fuseau horaire PHP | `Europe/Paris` | +| TRUSTED_PROXIES | Proxies autorisés à fournir `X-Forwarded-For` / `X-Forwarded-Proto` | `127.0.0.1,::1` ou `*` | | ADMIN_USERNAME | Nom d'utilisateur du compte admin | `admin` | | ADMIN_EMAIL | E-mail du compte admin | `admin@example.com` | | ADMIN_PASSWORD | Mot de passe admin (obligatoire en production) | *(à changer)* | diff --git a/public/index.php b/public/index.php index d865943..de868e2 100644 --- a/public/index.php +++ b/public/index.php @@ -4,12 +4,18 @@ declare(strict_types=1); require __DIR__.'/../vendor/autoload.php'; use App\Shared\Bootstrap; +use App\Shared\Http\RequestContext; + +$bootstrap = Bootstrap::create(); +$bootstrap->initializeInfrastructure(); + +$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER); session_start([ - 'cookie_secure' => !empty($_SERVER['HTTPS']), + 'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies), 'cookie_httponly' => true, 'cookie_samesite' => 'Lax', ]); -$app = Bootstrap::create()->initialize(); +$app = $bootstrap->createHttpApp(); $app->run(); diff --git a/src/Auth/PasswordResetController.php b/src/Auth/PasswordResetController.php index 762b09f..fc77c6c 100644 --- a/src/Auth/PasswordResetController.php +++ b/src/Auth/PasswordResetController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Auth; use App\Auth\Exception\InvalidResetTokenException; +use App\Shared\Http\ClientIpResolver; use App\Shared\Http\FlashServiceInterface; use App\User\Exception\WeakPasswordException; use Psr\Http\Message\ResponseInterface as Response; @@ -34,6 +35,7 @@ final class PasswordResetController * @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation * @param AuthServiceInterface $authService Service d'authentification (rate limiting) * @param FlashServiceInterface $flash Service de messages flash + * @param ClientIpResolver $clientIpResolver Résout l'IP réelle derrière un proxy approuvé * @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env) */ public function __construct( @@ -41,6 +43,7 @@ final class PasswordResetController private readonly PasswordResetServiceInterface $passwordResetService, private readonly AuthServiceInterface $authService, private readonly FlashServiceInterface $flash, + private readonly ClientIpResolver $clientIpResolver, private readonly string $baseUrl, ) { } @@ -80,13 +83,7 @@ final class PasswordResetController */ public function forgot(Request $req, Response $res): Response { - // Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx). - // Même logique que AuthController::login() — voir son commentaire pour le détail. - $serverParams = $req->getServerParams(); - $forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? '')); - $ip = $forwarded !== '' && $forwarded !== '0.0.0.0' - ? trim(explode(',', $forwarded)[0]) - : ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0'); + $ip = $this->clientIpResolver->resolve($req); // Vérification du rate limit avant tout traitement $remainingMinutes = $this->authService->checkRateLimit($ip); diff --git a/src/Shared/Bootstrap.php b/src/Shared/Bootstrap.php index b29db16..974d347 100644 --- a/src/Shared/Bootstrap.php +++ b/src/Shared/Bootstrap.php @@ -4,7 +4,8 @@ declare(strict_types=1); namespace App\Shared; use App\Post\PostExtension; -use App\Shared\Database\Provisioner; +use App\Shared\Database\DatabaseNotProvisionedException; +use App\Shared\Database\DatabaseReadiness; use App\Shared\Extension\AppExtension; use App\Shared\Extension\CsrfExtension; use App\Shared\Extension\SessionExtension; @@ -14,6 +15,7 @@ use PDO; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Slim\App; use Slim\Csrf\Guard; @@ -40,7 +42,6 @@ final class Bootstrap public function initialize(): App { $this->initializeInfrastructure(); - $this->runAutoProvisioningIfEnabled(); return $this->createHttpApp(); } @@ -138,21 +139,6 @@ final class Bootstrap } } - private function runAutoProvisioningIfEnabled(): void - { - $flag = strtolower(trim((string) ($_ENV['APP_AUTO_PROVISION'] ?? ''))); - $isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development'; - $enabled = $flag !== '' - ? in_array($flag, ['1', 'true', 'yes', 'on'], true) - : $isDev; - - if (!$enabled) { - return; - } - - Provisioner::run($this->container->get(PDO::class)); - } - private function registerMiddlewares(): void { $this->app->addBodyParsingMiddleware(); @@ -168,6 +154,17 @@ final class Bootstrap $guard->setPersistentTokenMode(true); $twig->addExtension(new CsrfExtension($guard)); $this->app->add($guard); + + $container = $this->container; + + $this->app->add(function ( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ) use ($container): ResponseInterface { + DatabaseReadiness::assertProvisioned($container->get(PDO::class)); + + return $handler->handle($request); + }); } private function registerRoutes(): void @@ -180,6 +177,8 @@ final class Bootstrap $isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development'; $logger = $this->container->get(LoggerInterface::class); $errorHandler = $this->app->addErrorMiddleware($isDev, true, true, $logger); + $app = $this->app; + $container = $this->container; $errorHandler->setDefaultErrorHandler( function ( @@ -188,24 +187,30 @@ final class Bootstrap bool $displayErrorDetails, bool $logErrors, bool $logErrorDetails, - ) use ($isDev): ResponseInterface { - if ($isDev) { + ) use ($isDev, $app, $container): ResponseInterface { + if ($isDev && !$exception instanceof DatabaseNotProvisionedException) { throw $exception; } $statusCode = 500; - if ($exception instanceof HttpException) { + $message = 'Une erreur inattendue s\'est produite.'; + + if ($exception instanceof DatabaseNotProvisionedException) { + $statusCode = 503; + $message = $exception->getMessage(); + } elseif ($exception instanceof HttpException) { $statusCode = $exception->getCode() ?: 500; + $message = $statusCode === 404 + ? 'La page demandée est introuvable.' + : $message; } - $response = $this->app->getResponseFactory()->createResponse($statusCode); - $twig = $this->container->get(Twig::class); + $response = $app->getResponseFactory()->createResponse($statusCode); + $twig = $container->get(Twig::class); return $twig->render($response, 'pages/error.twig', [ 'status' => $statusCode, - 'message' => $statusCode === 404 - ? 'La page demandée est introuvable.' - : 'Une erreur inattendue s\'est produite.', + 'message' => $message, ]); } ); diff --git a/src/Shared/Database/DatabaseNotProvisionedException.php b/src/Shared/Database/DatabaseNotProvisionedException.php new file mode 100644 index 0000000..6a91345 --- /dev/null +++ b/src/Shared/Database/DatabaseNotProvisionedException.php @@ -0,0 +1,8 @@ +query("SELECT name FROM sqlite_master WHERE type = 'table'"); + $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : []; + + return array_values(array_filter(array_map( + static fn (mixed $value): string => (string) $value, + $rows + ))); + } +} diff --git a/src/Shared/Database/Seeder.php b/src/Shared/Database/Seeder.php index 1952d53..d28cebf 100644 --- a/src/Shared/Database/Seeder.php +++ b/src/Shared/Database/Seeder.php @@ -24,8 +24,8 @@ final class Seeder /** * Exécute toutes les opérations de provisionnement. * - * Appelé dans Bootstrap::initialize() après Migrator::run(), une fois - * que le schéma est garanti à jour. + * Appelé par le script de provisionnement après Migrator::run(), + * une fois que le schéma est garanti à jour. * * @param PDO $db L'instance de connexion à la base de données */ diff --git a/src/Shared/Http/ClientIpResolver.php b/src/Shared/Http/ClientIpResolver.php index d01af8a..4553641 100644 --- a/src/Shared/Http/ClientIpResolver.php +++ b/src/Shared/Http/ClientIpResolver.php @@ -23,36 +23,6 @@ final class ClientIpResolver public function resolve(ServerRequestInterface $request): string { - $serverParams = $request->getServerParams(); - $remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? '')); - - if ($remoteAddr === '') { - return '0.0.0.0'; - } - - if (!$this->isTrustedProxy($remoteAddr)) { - return $remoteAddr; - } - - $forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? '')); - - if ($forwarded === '') { - return $remoteAddr; - } - - $candidate = trim(explode(',', $forwarded)[0]); - - return filter_var($candidate, FILTER_VALIDATE_IP) ? $candidate : $remoteAddr; - } - - private function isTrustedProxy(string $remoteAddr): bool - { - foreach ($this->trustedProxies as $proxy) { - if ($proxy === '*' || $proxy === $remoteAddr) { - return true; - } - } - - return false; + return RequestContext::resolveClientIp($request->getServerParams(), $this->trustedProxies); } } diff --git a/src/Shared/Http/RequestContext.php b/src/Shared/Http/RequestContext.php new file mode 100644 index 0000000..2dfe0d1 --- /dev/null +++ b/src/Shared/Http/RequestContext.php @@ -0,0 +1,127 @@ + $serverParams + * @param string[] $trustedProxies + */ + public static function resolveClientIp(array $serverParams, array $trustedProxies = []): string + { + $remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? '')); + + if ($remoteAddr === '') { + return '0.0.0.0'; + } + + if (!self::isTrustedProxy($remoteAddr, $trustedProxies)) { + return $remoteAddr; + } + + $forwarded = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_FOR'); + + return $forwarded !== null && filter_var($forwarded, FILTER_VALIDATE_IP) + ? $forwarded + : $remoteAddr; + } + + /** + * @param array $serverParams + * @param string[] $trustedProxies + */ + public static function isHttps(array $serverParams, array $trustedProxies = []): bool + { + $https = strtolower(trim((string) ($serverParams['HTTPS'] ?? ''))); + + if ($https !== '' && $https !== 'off' && $https !== '0') { + return true; + } + + $scheme = strtolower(trim((string) ($serverParams['REQUEST_SCHEME'] ?? ''))); + if ($scheme === 'https') { + return true; + } + + if ((string) ($serverParams['SERVER_PORT'] ?? '') === '443') { + return true; + } + + $remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? '')); + if ($remoteAddr === '' || !self::isTrustedProxy($remoteAddr, $trustedProxies)) { + return false; + } + + $forwardedProto = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_PROTO'); + + return $forwardedProto === 'https'; + } + + /** + * @param array $environment + * @param array $serverParams + * @return string[] + */ + public static function trustedProxiesFromEnvironment(array $environment = [], array $serverParams = []): array + { + $candidates = [ + $environment['TRUSTED_PROXIES'] ?? null, + $serverParams['TRUSTED_PROXIES'] ?? null, + getenv('TRUSTED_PROXIES') ?: null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === null) { + continue; + } + + $raw = trim((string) $candidate); + if ($raw === '') { + continue; + } + + return array_values(array_filter(array_map( + static fn (string $value): string => trim($value), + explode(',', $raw) + ), static fn (string $value): bool => $value !== '')); + } + + return []; + } + + /** + * @param string[] $trustedProxies + */ + private static function isTrustedProxy(string $remoteAddr, array $trustedProxies): bool + { + foreach ($trustedProxies as $proxy) { + if ($proxy === '*' || $proxy === $remoteAddr) { + return true; + } + } + + return false; + } + + /** + * @param array $serverParams + */ + private static function firstForwardedValue(array $serverParams, string $header): ?string + { + $raw = trim((string) ($serverParams[$header] ?? '')); + + if ($raw === '') { + return null; + } + + return strtolower(trim(explode(',', $raw)[0])); + } +} diff --git a/src/Shared/Http/SessionManager.php b/src/Shared/Http/SessionManager.php index fd5ce91..543a23c 100644 --- a/src/Shared/Http/SessionManager.php +++ b/src/Shared/Http/SessionManager.php @@ -95,10 +95,12 @@ final class SessionManager implements SessionManagerInterface $sessionName = session_name(); if ($sessionName !== false) { + $trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER); + setcookie($sessionName, '', [ 'expires' => time() - 3600, 'path' => '/', - 'secure' => !empty($_SERVER['HTTPS']), + 'secure' => RequestContext::isHttps($_SERVER, $trustedProxies), 'httponly' => true, 'samesite' => 'Lax', ]); diff --git a/tests/Auth/PasswordResetControllerTest.php b/tests/Auth/PasswordResetControllerTest.php index b0b25f5..f8e3dd1 100644 --- a/tests/Auth/PasswordResetControllerTest.php +++ b/tests/Auth/PasswordResetControllerTest.php @@ -7,6 +7,7 @@ use App\Auth\AuthServiceInterface; use App\Auth\PasswordResetController; use App\Auth\Exception\InvalidResetTokenException; use App\Auth\PasswordResetServiceInterface; +use App\Shared\Http\ClientIpResolver; use App\Shared\Http\FlashServiceInterface; use App\User\Exception\WeakPasswordException; use App\User\User; @@ -42,6 +43,7 @@ final class PasswordResetControllerTest extends ControllerTestCase /** @var FlashServiceInterface&MockObject */ private FlashServiceInterface $flash; + private ClientIpResolver $clientIpResolver; private PasswordResetController $controller; private const BASE_URL = 'https://example.com'; @@ -52,6 +54,7 @@ final class PasswordResetControllerTest extends ControllerTestCase $this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class); $this->authService = $this->createMock(AuthServiceInterface::class); $this->flash = $this->createMock(FlashServiceInterface::class); + $this->clientIpResolver = new ClientIpResolver(['*']); // Par défaut : IP non verrouillée $this->authService->method('checkRateLimit')->willReturn(0); @@ -61,6 +64,7 @@ final class PasswordResetControllerTest extends ControllerTestCase $this->passwordResetService, $this->authService, $this->flash, + $this->clientIpResolver, self::BASE_URL, ); } @@ -97,20 +101,27 @@ final class PasswordResetControllerTest extends ControllerTestCase public function testForgotRedirectsWhenRateLimited(): void { $authService = $this->createMock(AuthServiceInterface::class); - $authService->method('checkRateLimit')->willReturn(10); + $authService->expects($this->once()) + ->method('checkRateLimit') + ->with('203.0.113.5') + ->willReturn(10); $controller = new PasswordResetController( $this->view, $this->passwordResetService, $authService, $this->flash, + $this->clientIpResolver, self::BASE_URL, ); $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('Trop de demandes')); - $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', + ]); $res = $controller->forgot($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/forgot'); @@ -122,19 +133,26 @@ final class PasswordResetControllerTest extends ControllerTestCase public function testForgotDoesNotCallServiceWhenRateLimited(): void { $authService = $this->createMock(AuthServiceInterface::class); - $authService->method('checkRateLimit')->willReturn(5); + $authService->expects($this->once()) + ->method('checkRateLimit') + ->with('203.0.113.5') + ->willReturn(5); $controller = new PasswordResetController( $this->view, $this->passwordResetService, $authService, $this->flash, + $this->clientIpResolver, self::BASE_URL, ); $this->passwordResetService->expects($this->never())->method('requestReset'); - $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', + ]); $controller->forgot($req, $this->makeResponse()); } @@ -146,9 +164,14 @@ final class PasswordResetControllerTest extends ControllerTestCase */ public function testForgotAlwaysRecordsFailure(): void { - $this->authService->expects($this->once())->method('recordFailure'); + $this->authService->expects($this->once()) + ->method('recordFailure') + ->with('203.0.113.5'); - $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', + ]); $this->controller->forgot($req, $this->makeResponse()); } diff --git a/tests/Shared/BootstrapTest.php b/tests/Shared/BootstrapTest.php index db7db21..e216b6d 100644 --- a/tests/Shared/BootstrapTest.php +++ b/tests/Shared/BootstrapTest.php @@ -14,25 +14,6 @@ use Slim\App; final class BootstrapTest extends TestCase { - private array $envBackup = []; - - protected function setUp(): void - { - $this->envBackup = [ - 'APP_AUTO_PROVISION' => $_ENV['APP_AUTO_PROVISION'] ?? null, - ]; - } - - protected function tearDown(): void - { - foreach ($this->envBackup as $key => $value) { - if ($value === null) { - unset($_ENV[$key]); - } else { - $_ENV[$key] = $value; - } - } - } public function testInitializeInfrastructureReturnsPreloadedContainer(): void { @@ -55,10 +36,8 @@ final class BootstrapTest extends TestCase self::assertSame($app, $bootstrap->createHttpApp()); } - public function testInitializeReturnsPreloadedAppWhenAutoProvisionIsDisabled(): void + public function testInitializeReturnsPreloadedApp(): void { - $_ENV['APP_AUTO_PROVISION'] = '0'; - $bootstrap = Bootstrap::create(); $container = $this->createStub(ContainerInterface::class); $app = AppFactory::create(); diff --git a/tests/Shared/RequestContextTest.php b/tests/Shared/RequestContextTest.php new file mode 100644 index 0000000..8c0fa68 --- /dev/null +++ b/tests/Shared/RequestContextTest.php @@ -0,0 +1,55 @@ + 'on', + ])); + } + + public function testIsHttpsReturnsTrueWhenTrustedProxyForwardsHttps(): void + { + self::assertTrue(RequestContext::isHttps([ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_PROTO' => 'https, http', + ], ['127.0.0.1'])); + } + + public function testIsHttpsIgnoresForwardedProtoWhenProxyIsNotTrusted(): void + { + self::assertFalse(RequestContext::isHttps([ + 'REMOTE_ADDR' => '10.0.0.5', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ], ['127.0.0.1'])); + } + + public function testTrustedProxiesFromEnvironmentTrimsValues(): void + { + self::assertSame(['127.0.0.1', '::1'], RequestContext::trustedProxiesFromEnvironment([ + 'TRUSTED_PROXIES' => ' 127.0.0.1 , ::1 ', + ])); + } + + public function testTrustedProxiesFromEnvironmentFallsBackToProcessEnvWhenDotenvValueIsBlank(): void + { + putenv('TRUSTED_PROXIES=*'); + + try { + self::assertSame(['*'], RequestContext::trustedProxiesFromEnvironment([ + 'TRUSTED_PROXIES' => '', + ])); + } finally { + putenv('TRUSTED_PROXIES'); + } + } +} +