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
5abf984df2
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._
|
_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
|
||||||
|
|
|
||||||
|
|
@ -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()). */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue