fix(webauthn): enable security keys behind an external TLS proxy

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) <noreply@anthropic.com>
feat/v1-foundation v0.9.20
boban 2026-06-19 21:11:20 +02:00
parent a61feb1bef
commit 5abf984df2
4 changed files with 41 additions and 13 deletions

View File

@ -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._ _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 ## [0.9.19] - 2026-06-19
### Behoben ### Behoben

View File

@ -38,10 +38,23 @@ class WebauthnService
public function __construct(private DeploymentService $deployment) {} 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 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()). */ /** The Relying-Party ID = the active domain (asserted present by available()). */

View File

@ -2,7 +2,7 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). // 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 // 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 // image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -4,33 +4,37 @@ namespace Tests\Feature;
use App\Services\DeploymentService; use App\Services\DeploymentService;
use App\Services\WebauthnService; use App\Services\WebauthnService;
use Illuminate\Http\Request;
use Tests\TestCase; use Tests\TestCase;
class WebauthnAvailableTest extends 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 = $this->mock(DeploymentService::class);
$deployment->shouldReceive('domain')->andReturn($domain); $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); 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'); // External-proxy mode: the request looks like plain HTTP here, but the browser context is
$this->assertTrue($this->service('panel.example.com')->available()); // 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, 'panel.example.com')->available());
$this->assertFalse($this->service(null)->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'); // Reached via the IP recovery path (plain HTTP, no secure context) → locked even with a
$this->assertFalse($this->service('panel.example.com')->available()); // domain configured; the rpId would not match the IP origin anyway.
$this->assertFalse($this->service('panel.example.com', '10.0.0.5')->available());
} }
} }