diff --git a/.env.docker.example b/.env.docker.example index deec9a46..6cd5e2a9 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -2,6 +2,9 @@ # Copy to .env next to docker-compose.yml and adjust. APP_NAME="b10cks CMS" +# The scheme here decides the scheme of every generated URL (assets, redirects, +# mail links). Use https:// as soon as a TLS reverse proxy sits in front, even +# though the container itself keeps speaking plain HTTP. APP_URL=http://localhost:8000 APP_PORT=8000 # Leave empty: the entrypoint generates a key on first boot and persists it on diff --git a/app/Http/Middleware/EnsureRevision.php b/app/Http/Middleware/EnsureRevision.php index 39602909..56365f93 100644 --- a/app/Http/Middleware/EnsureRevision.php +++ b/app/Http/Middleware/EnsureRevision.php @@ -17,10 +17,13 @@ public function handle(Request $request, Closure $next) $params['rv'] = $space->content_updated_at?->timestamp ?? $space->updated_at->timestamp; $url = $request->path() . '?' . http_build_query($params); + // No $secure argument: it is an absolute override, not a hint, so + // passing one here pins the redirect to a scheme the instance may + // not serve. Leaving it out follows APP_URL (see AppServiceProvider). return redirect($url, 301, [ 'cache-control' => 'public, max-age=60, s-maxage=10', 'x-b10cks-version' => config('app.version'), - ], app()->environment('production')); + ]); } return $next($request); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 18b512ce..3a0d5377 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -16,13 +16,21 @@ class AppServiceProvider extends ServiceProvider { /** * Register any application services. + * + * APP_URL, not APP_ENV, decides the scheme generated URLs get. The + * self-hosted Docker stack runs APP_ENV=production over plain HTTP on + * localhost, so keying this on the environment pointed every asset URL at + * an https port nothing listens on. Behind a TLS terminating proxy the + * operator sets an https APP_URL and the forcing still applies, which also + * covers an unset TRUSTED_PROXIES, where X-Forwarded-Proto is not believed. + * + * Proxy trust itself is configured in config/trustedproxy.php and applied + * by the TrustProxies middleware; setting it here has no effect, since + * that middleware resets it on every request. */ public function register(): void { - if (app()->environment('production')) { - // Proxy trust is configured in config/trustedproxy.php and applied - // by the TrustProxies middleware; setting it here has no effect, - // since that middleware resets it on every request. + if (str_starts_with(strtolower((string) config('app.url')), 'https://')) { \URL::forceScheme('https'); } } diff --git a/docs/self-hosting/configuration.md b/docs/self-hosting/configuration.md index a3103cd1..dd3d02dd 100644 --- a/docs/self-hosting/configuration.md +++ b/docs/self-hosting/configuration.md @@ -56,6 +56,8 @@ APP_ENV=production APP_DEBUG=false ``` +The scheme in `APP_URL` decides the scheme every generated URL gets — assets, redirects, signed links, mail. Put `https://` there as soon as anything terminates TLS in front of the app, even when the app itself is reached over plain HTTP inside the network. Leave it `http://` for a local stack that speaks plain HTTP end to end, which is what the Docker default does. + ## Sign-in ```bash diff --git a/tests/Unit/Http/Middleware/EnsureRevisionTest.php b/tests/Unit/Http/Middleware/EnsureRevisionTest.php new file mode 100644 index 00000000..619f5d5c --- /dev/null +++ b/tests/Unit/Http/Middleware/EnsureRevisionTest.php @@ -0,0 +1,65 @@ +middleware = new EnsureRevision; + + app()->instance('currentSpace', (new Space)->forceFill([ + 'content_updated_at' => now(), + 'updated_at' => now(), + ])); + } + + private function redirectLocation(string $env, string $appUrl): string + { + $this->app['env'] = $env; + config(['app.url' => $appUrl]); + + URL::forceScheme(null); + URL::setRequest(Request::create('http://localhost:8000/')); + $this->app->register(AppServiceProvider::class, force: true); + + $response = $this->middleware->handle( + Request::create('http://localhost:8000/contents/home'), + fn () => response('unreachable'), + ); + + $this->assertSame(301, $response->getStatusCode()); + + return $response->getTargetUrl(); + } + + #[Test] + public function it_redirects_over_http_when_app_url_is_http(): void + { + $this->assertStringStartsWith('http://', $this->redirectLocation('production', 'http://localhost:8000')); + } + + #[Test] + public function it_redirects_over_https_when_app_url_is_https(): void + { + $this->assertStringStartsWith('https://', $this->redirectLocation('local', 'https://cms.example.com')); + } +} diff --git a/tests/Unit/Providers/UrlSchemeTest.php b/tests/Unit/Providers/UrlSchemeTest.php new file mode 100644 index 00000000..9c59e2f0 --- /dev/null +++ b/tests/Unit/Providers/UrlSchemeTest.php @@ -0,0 +1,45 @@ +app['env'] = $env; + config(['app.url' => $appUrl]); + + URL::forceScheme(null); + URL::setRequest(Request::create('http://localhost:8000/')); + + $this->app->register(AppServiceProvider::class, force: true); + } + + public function test_http_app_url_keeps_http_in_production(): void + { + $this->bootWith('production', 'http://localhost:8000'); + + $this->assertStringStartsWith('http://', asset('build/app.js')); + $this->assertStringStartsWith('http://', url('/login')); + } + + public function test_https_app_url_forces_https_behind_a_terminating_proxy(): void + { + $this->bootWith('local', 'https://cms.example.com'); + + $this->assertStringStartsWith('https://', asset('build/app.js')); + $this->assertStringStartsWith('https://', url('/login')); + } +}