From fe01acca2166a61d96a2d3cff457bdb2ddc56def Mon Sep 17 00:00:00 2001 From: Tom Kay Date: Fri, 4 Sep 2026 10:04:02 +0100 Subject: [PATCH] Do not write a proxy's port into a forwarded https url asUrl() resolved the scheme twice, and differently. The output used isSecure(true), which honours X-Forwarded-Proto, while the port check was handed getScheme(), which does not. Behind a tls terminating proxy the two disagreed, so the url claimed https while port suppression was evaluated as http, leaving the origin's port in place: Request::create('http://www.packaged.local:443/') + X-Forwarded-Proto -> https://www.packaged.local:443 Passing the resolved scheme to _isStandardPort() alone is not enough. A proxy forwards on its own port, which is usually neither 80 nor 443 -- 8080 is the common case -- so no default-port comparison suppresses it. The port the request arrived on simply is not part of the public url. So when the scheme came from the forwarded header and no port was set explicitly, the port is now omitted. An explicit setPort() still wins, and requests that were not proxied are unaffected. Note this assumes the public url is on the scheme's default port, which is all X-Forwarded-Proto tells us. Configure trusted proxies if the public port is non-standard; getPort() then honours X-Forwarded-Port. The existing forwarded-proto assertion only passed because it used port 80, which is standard for the mis-resolved http scheme. Adds the 443 case, which fails without this change. Co-Authored-By: Claude Opus 5 (1M context) --- src/LinkBuilder/LinkBuilder.php | 9 ++++++++- tests/LinkBuilderTest.php | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/LinkBuilder/LinkBuilder.php b/src/LinkBuilder/LinkBuilder.php index 1793e50..6febfab 100644 --- a/src/LinkBuilder/LinkBuilder.php +++ b/src/LinkBuilder/LinkBuilder.php @@ -38,6 +38,13 @@ public function asUrl(): string { $scheme = $this->_scheme ?? ($this->_request->isSecure(true) ? 'https' : 'http'); $port = $this->_port ?? $this->_request->port(); + + // When the scheme comes from a proxy that terminated tls, the port the + // request arrived on is the proxy's, not the one the url is reachable on + $schemeIsForwarded = $this->_scheme === null + && $this->_port === null + && $this->_request->isSecure(true) + && !$this->_request->isSecure(); return ($scheme . '://') . implode( '.', @@ -47,7 +54,7 @@ public function asUrl(): string ($this->_tld ?? $this->_request->tld()), ] ) - . ($this->_isStandardPort($this->_scheme ?? $this->_request->getScheme(), $port) ? '' : ':' . $port) + . ($schemeIsForwarded || $this->_isStandardPort($scheme, $port) ? '' : ':' . $port) . (isset($this->_path[0]) && $this->_path[0] !== '/' ? '/' : '') . $this->_path . (!empty($this->_query) ? '?' . http_build_query($this->_query) : null) diff --git a/tests/LinkBuilderTest.php b/tests/LinkBuilderTest.php index 4932140..544d0ba 100644 --- a/tests/LinkBuilderTest.php +++ b/tests/LinkBuilderTest.php @@ -83,4 +83,21 @@ public function testAsUrl() $lb->setFragment('def'); self::assertEquals('https://secure.packaged.local#def', $lb->asUrl()); } + + /** + * Behind a TLS terminating proxy the scheme comes from X-Forwarded-Proto, so + * 443 is the standard port and should not be written into the url + */ + public function testAsUrlBehindTlsTerminatingProxy() + { + $request = Request::create('http://www.packaged.local:443/'); + $request->headers->set('X_FORWARDED_PROTO', 'https'); + + self::assertFalse($request->isSecure()); + self::assertTrue($request->isSecure(true)); + self::assertEquals('http', $request->getScheme()); + self::assertEquals(443, $request->port()); + + self::assertEquals('https://www.packaged.local', LinkBuilder::fromRequest($request)->asUrl()); + } }