fix(tls): force https URL generation for the active domain (external-proxy mode)

Behind an external TLS proxy, Clusev's own Caddy only honors the upstream's
X-Forwarded-Proto when TRUSTED_PROXY_CIDR is set. Without it the app saw http,
asset() emitted http:// URLs, and the HTTPS page's CSP ('self') blocked them —
the panel loaded with no CSS/JS over the domain. PanelScheme now forces the URL
root + scheme to https://<domain> whenever the active domain is being served,
regardless of the (possibly untrusted) request scheme. The bare-IP recovery
path returns earlier and stays on HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.9.17
boban 2026-06-19 20:35:06 +02:00
parent 7d35f3abac
commit 4bcdafbeff
4 changed files with 38 additions and 1 deletions

View File

@ -13,6 +13,19 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.17] - 2026-06-19
### Behoben
- **Panel über die Domain hinter einem externen TLS-Proxy war unbenutzbar (CSS/JS per CSP
blockiert).** Im Externer-Proxy-Modus terminiert der Upstream HTTPS und reicht das Schema per
`X-Forwarded-Proto` weiter; Clusevs eigener Caddy vertraut dem aber nur, wenn `TRUSTED_PROXY_CIDR`
gesetzt ist. War es das nicht, sah die App `http`, erzeugte `http://`-Asset-URLs — und die über
HTTPS ausgelieferte Seite (CSP `'self'`) blockierte diese → kein CSS/JS, kein Login. Bei aktiver
Domain wird die URL-Erzeugung (Assets, Routen) jetzt fest auf `https://<domain>` gezwungen
(`URL::forceRootUrl`), unabhängig vom (evtl. nicht vertrauten) Request-Schema. Der Bare-IP-
Recovery-Pfad (HTTP) bleibt unberührt. Hinweis: für korrekte `Secure`-Cookies im Externer-Proxy-
Modus weiterhin `TRUSTED_PROXY_CIDR` auf die Proxy-Adresse setzen.
## [0.9.16] - 2026-06-19
### Behoben

View File

@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\Services\DeploymentService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
/**
@ -76,6 +77,14 @@ class PanelScheme
return redirect()->to('https://'.$domain.$request->getRequestUri(), 301);
}
// Serving the active domain over HTTPS: force generated URLs (assets, routes) to
// https://<domain> so they MATCH the page scheme. Critical in external-proxy mode, where
// the upstream terminates TLS and the forwarded scheme may not be trusted here — without
// this asset() emits http:// URLs that the HTTPS page's CSP ('self') blocks, leaving the
// panel with no CSS/JS. The bare-IP recovery path returned earlier and stays on HTTP.
URL::forceRootUrl('https://'.$domain);
URL::forceScheme('https');
return $next($request);
}
}

View File

@ -2,7 +2,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.16',
'version' => '0.9.17',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -99,4 +99,19 @@ class ExternalTlsModeTest extends TestCase
$this->expectException(HttpException::class);
$this->runPanelScheme(Request::create('http://evil.example.com/dashboard'));
}
public function test_external_mode_forces_https_url_generation_for_the_active_domain(): void
{
Setting::put('panel_domain', 'panel.example.com');
app(DeploymentService::class)->setTlsMode('external');
// External proxy terminates TLS; the app itself sees a plain-HTTP request here.
$response = $this->runPanelScheme(Request::create('http://panel.example.com/dashboard'));
$this->assertSame(200, $response->getStatusCode());
// Generated URLs (assets, routes) must be https://<domain> so the HTTPS page's CSP
// ('self') does not block them — the bug that left the panel with no CSS/JS.
$this->assertSame('https://panel.example.com/build/app.css', url('build/app.css'));
$this->assertStringStartsWith('https://panel.example.com', route('login'));
}
}