From 3e2f2922c43452b5c117d257c4194e6db447e6f9 Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Wed, 26 Aug 2026 13:37:46 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Derive=20the=20URL=20scheme=20fr?= =?UTF-8?q?om=20APP=5FURL,=20not=20APP=5FENV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosted Docker stack sets APP_ENV=production and serves plain HTTP on localhost, so forcing https on any production environment pointed every asset URL at a port nothing listens on. A default install rendered its admin shell fine and then failed to load a single script or stylesheet, which the browser reports as a CORS error. APP_URL already states how the instance is reached, so the scheme comes from there now. Behind a TLS terminating proxy the operator sets an https APP_URL and the forcing still applies, including when TRUSTED_PROXIES is unset and X-Forwarded-Proto is therefore not believed. EnsureRevision had the same defect: it passed the environment as redirect()'s $secure argument, which is an absolute override rather than a hint, so the delivery API's rv redirect went to https on a plain HTTP self-host and to plain http under TLS whenever APP_ENV was not production. Verified against a locally built image on the repo's own compose stack: all 52 asset URLs on the served page flip from https to http, and an https APP_URL still yields https over a plain HTTP hop. Fixes #26 Co-Authored-By: Claude Opus 5 (1M context) --- .env.docker.example | 3 + app/Http/Middleware/EnsureRevision.php | 5 +- app/Providers/AppServiceProvider.php | 16 +++-- docs/self-hosting/configuration.md | 2 + .../Http/Middleware/EnsureRevisionTest.php | 65 +++++++++++++++++++ tests/Unit/Providers/UrlSchemeTest.php | 45 +++++++++++++ 6 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Http/Middleware/EnsureRevisionTest.php create mode 100644 tests/Unit/Providers/UrlSchemeTest.php 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')); + } +}