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._
## [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

View File

@ -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()). */

View File

@ -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

View File

@ -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());
}
}