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
parent
a61feb1bef
commit
95a43dcf2b
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
|
|
@ -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()). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue