From 5abf984df284d5bf565d23c6727fc38256890786 Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 19 Jun 2026 21:11:20 +0200 Subject: [PATCH] fix(webauthn): enable security keys behind an external TLS proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebauthnService::available() keyed on request()->isSecure(), which is false in external-proxy mode (the forwarded https scheme isn't trusted without TRUSTED_PROXY_CIDR) — so hardware keys were locked even though the panel runs over HTTPS on the domain. Gate on being reached via the active domain instead (always HTTPS through the front door); the bare-IP recovery host stays locked. A core security feature no longer depends on an optional proxy setting. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ app/Services/WebauthnService.php | 17 +++++++++++++++-- config/clusev.php | 2 +- tests/Feature/WebauthnAvailableTest.php | 24 ++++++++++++++---------- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 969ab1a..60c5520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ 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.20] - 2026-06-19 + +### Behoben +- **Security-Keys (WebAuthn/YubiKey) waren hinter einem externen TLS-Proxy gesperrt.** Die + Verfügbarkeit hing an `request()->isSecure()` — das im Externer-Proxy-Modus `false` ist (der + weitergereichte HTTPS-Scheme wird ohne `TRUSTED_PROXY_CIDR` nicht vertraut), obwohl das Panel + über die Domain per HTTPS läuft. Security-Keys werden jetzt freigeschaltet, sobald das Panel über + die **aktive Domain** erreicht wird (immer HTTPS über die Vordertür), unabhängig vom hier + erkannten Request-Scheme. Der Bare-IP-Recovery-Pfad (kein Secure-Context) bleibt korrekt gesperrt. + Ein Kern-Sicherheitsfeature hängt damit nicht mehr an einer optionalen Proxy-Einstellung. + ## [0.9.19] - 2026-06-19 ### Behoben diff --git a/app/Services/WebauthnService.php b/app/Services/WebauthnService.php index 328a07e..0c84ec2 100644 --- a/app/Services/WebauthnService.php +++ b/app/Services/WebauthnService.php @@ -38,10 +38,23 @@ class WebauthnService public function __construct(private DeploymentService $deployment) {} - /** WebAuthn needs a secure context AND a domain rpId — never a bare IP. */ + /** + * WebAuthn needs a secure context AND a domain rpId — never a bare IP. Available when the + * panel is reached via the ACTIVE DOMAIN, which is always served over HTTPS through the front + * door (Caddy's own TLS or an external TLS proxy). Deliberately NOT keyed on + * request()->isSecure(): behind an external TLS proxy the forwarded scheme may not be trusted + * here, so the request looks like plain HTTP even though the browser context is secure — that + * would wrongly lock out hardware keys. The bare-IP recovery host (http, no secure context) + * is excluded by the host check. + */ public function available(): bool { - return $this->deployment->domain() !== null && request()->isSecure(); + $domain = $this->deployment->domain(); + $request = request(); + + return $domain !== null + && $request !== null + && strtolower($request->getHost()) === $domain; } /** The Relying-Party ID = the active domain (asserted present by available()). */ diff --git a/config/clusev.php b/config/clusev.php index c721c8a..0395f61 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.19', + 'version' => '0.9.20', // 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 diff --git a/tests/Feature/WebauthnAvailableTest.php b/tests/Feature/WebauthnAvailableTest.php index 44c2afc..be4e5cf 100644 --- a/tests/Feature/WebauthnAvailableTest.php +++ b/tests/Feature/WebauthnAvailableTest.php @@ -4,33 +4,37 @@ namespace Tests\Feature; use App\Services\DeploymentService; use App\Services\WebauthnService; +use Illuminate\Http\Request; use Tests\TestCase; class WebauthnAvailableTest extends TestCase { - private function service(?string $domain): WebauthnService + private function service(?string $domain, string $host = 'panel.example.com'): WebauthnService { $deployment = $this->mock(DeploymentService::class); $deployment->shouldReceive('domain')->andReturn($domain); + // The bound request decides the host WebAuthn availability keys on. + app()->instance('request', Request::create('http://'.$host.'/settings/security')); return app(WebauthnService::class); } - public function test_available_only_with_domain_and_https(): void + public function test_available_when_reached_via_the_active_domain_even_over_plain_http(): void { - $this->app['request']->server->set('HTTPS', 'on'); - $this->assertTrue($this->service('panel.example.com')->available()); + // External-proxy mode: the request looks like plain HTTP here, but the browser context is + // secure (the proxy serves HTTPS). Host == active domain → hardware keys are available. + $this->assertTrue($this->service('panel.example.com', 'panel.example.com')->available()); } - public function test_unavailable_without_domain(): void + public function test_unavailable_without_a_domain(): void { - $this->app['request']->server->set('HTTPS', 'on'); - $this->assertFalse($this->service(null)->available()); + $this->assertFalse($this->service(null, 'panel.example.com')->available()); } - public function test_unavailable_without_https(): void + public function test_unavailable_on_the_bare_ip_recovery_host(): void { - $this->app['request']->server->remove('HTTPS'); - $this->assertFalse($this->service('panel.example.com')->available()); + // Reached via the IP recovery path (plain HTTP, no secure context) → locked even with a + // domain configured; the rpId would not match the IP origin anyway. + $this->assertFalse($this->service('panel.example.com', '10.0.0.5')->available()); } }