From 1b15ba34a8fc96d8e1f62257271cabfe4ed51824 Mon Sep 17 00:00:00 2001 From: Diana Scharf Date: Wed, 24 Jun 2026 16:13:26 +0200 Subject: [PATCH] Validate on-demand TLS against active tunnels Add a local /expose/can-issue-certificate endpoint that Caddy's on-demand TLS asks before issuing a certificate. It returns 200 only when a live tunnel exists for the requested host (using the same connection lookup as request routing), 404 otherwise. Gated to the loopback interface so it can't shadow tunnelled paths or leak active-tunnel info externally. Stops the on-demand cert pool from growing for hosts nobody is tunnelling: the current platform ask approves any subdomain of a registered domain, causing large cert fan-out (259 registered domains -> ~17k stored certs on eu-1) that Caddy then loads/parses under handshake floods, spiking memory and CPU. --- app/Factory.php | 14 +++++++ .../CanIssueCertificateController.php | 41 +++++++++++++++++++ tests/Feature/Server/TunnelTest.php | 28 +++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 app/Http/Controllers/CanIssueCertificateController.php diff --git a/app/Factory.php b/app/Factory.php index 58bddb0..7c5949e 100644 --- a/app/Factory.php +++ b/app/Factory.php @@ -33,6 +33,7 @@ use Expose\Server\Http\Controllers\Admin\StoreSubdomainController; use Expose\Server\Http\Controllers\Admin\StoreUsersController; use Expose\Server\Http\Controllers\Admin\UpdateDomainController; +use Expose\Server\Http\Controllers\CanIssueCertificateController; use Expose\Server\Http\Controllers\ControlMessageController; use Expose\Server\Http\Controllers\HealthController; use Expose\Server\Http\Controllers\TunnelMessageController; @@ -137,6 +138,17 @@ protected function addControlConnectionRoute(): WsServer return $wsServer; } + protected function addCertificateAuthorizationRoute() + { + // Caddy's on-demand TLS asks this endpoint before issuing a certificate. + // Gated to the loopback interface so it can only be reached by the local + // Caddy process - this keeps it from shadowing a tunnelled app's path and + // from leaking which tunnels are live to external callers. + $loopbackCondition = 'request.headers.get("Host") in ["127.0.0.1", "127.0.0.1:'.$this->port.'"]'; + + $this->router->get('/expose/can-issue-certificate', CanIssueCertificateController::class, $loopbackCondition); + } + protected function addAdminRoutes() { $adminCondition = 'request.headers.get("Host") matches "/^'.config('expose-server.subdomain').'\\\\./i"'; @@ -219,6 +231,8 @@ public function createServer() $controlConnection = $this->addControlConnectionRoute(); + $this->addCertificateAuthorizationRoute(); + $this->addTunnelRoute(); $urlMatcher = new UrlMatcher($this->router->getRoutes(), new RequestContext); diff --git a/app/Http/Controllers/CanIssueCertificateController.php b/app/Http/Controllers/CanIssueCertificateController.php new file mode 100644 index 0000000..d31a8ac --- /dev/null +++ b/app/Http/Controllers/CanIssueCertificateController.php @@ -0,0 +1,41 @@ +get('domain'); + + if (blank($domain)) { + $httpConnection->send(respond_html('Missing domain', 400)); + + return; + } + + // Parse the host the same way TunnelMessageController routes a request, + // so the certificate decision matches whether a request for this host + // would actually be served by a live tunnel. + $serverHost = Str::before(Str::after($domain, '.'), ':'); + $subdomain = Str::before($domain, '.'.$serverHost); + + /** @var ConnectionManager $connectionManager */ + $connectionManager = app(ConnectionManager::class); + + $hasActiveTunnel = $subdomain !== $domain + && $connectionManager->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost) !== null; + + // Caddy on-demand TLS treats any 2xx as "issue", anything else as "refuse". + $httpConnection->send(respond_html( + $hasActiveTunnel ? 'OK' : 'No active tunnel', + $hasActiveTunnel ? 200 : 404 + )); + } +} diff --git a/tests/Feature/Server/TunnelTest.php b/tests/Feature/Server/TunnelTest.php index 6650ad1..b92eed6 100644 --- a/tests/Feature/Server/TunnelTest.php +++ b/tests/Feature/Server/TunnelTest.php @@ -181,6 +181,34 @@ public function it_can_update_404_pages_for_custom_domains() } } + /** @test */ + public function it_refuses_certificate_issuance_for_a_host_without_an_active_tunnel() + { + $this->expectException(ResponseException::class); + $this->expectExceptionMessage(404); + + $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=tunnel.localhost', [ + 'Host' => '127.0.0.1:8080', + ])); + } + + /** @test */ + public function it_allows_certificate_issuance_for_a_host_with_an_active_tunnel() + { + $this->app['config']['expose-server.validate_auth_tokens'] = false; + + $this->createTestHttpServer(); + + $client = $this->createClient(); + $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel')); + + $response = $this->await($this->browser->get('http://127.0.0.1:8080/expose/can-issue-certificate?domain=tunnel.localhost', [ + 'Host' => '127.0.0.1:8080', + ])); + + $this->assertSame(200, $response->getStatusCode()); + } + /** @test */ public function it_sends_incoming_requests_to_the_connected_client() {