createMock(ICacheFactory::class); $cacheFactory ->method('createLocal') ->willReturn(new NullCache()); $ipAddressClassifier = new IpAddressClassifier(); $negativeDnsCache = new NegativeDnsCache($cacheFactory); $this->dnsPinMiddleware = $this->getMockBuilder(DnsPinMiddleware::class) ->setConstructorArgs([$negativeDnsCache, $ipAddressClassifier]) ->onlyMethods(['dnsGetRecord']) ->getMock(); } public function testPopulateDnsCacheIPv4(): void { $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options) { self::arrayHasKey('curl', $options); self::arrayHasKey(CURLOPT_RESOLVE, $options['curl']); self::assertEquals([ 'www.example.com:80:1.1.1.1', 'www.example.com:443:1.1.1.1' ], $options['curl'][CURLOPT_RESOLVE]); return new Response(200); }, ]); $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function (string $hostname, int $type) { // example.com SOA if ($hostname === 'example.com') { return match ($type) { DNS_SOA => [ [ 'host' => 'example.com', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], }; } // example.com A, AAAA, CNAME if ($hostname === 'www.example.com') { return match ($type) { DNS_A => [], DNS_AAAA => [], DNS_CNAME => [ [ 'host' => 'www.example.com', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'target' => 'www.example.net' ] ], }; } // example.net SOA if ($hostname === 'example.net') { return match ($type) { DNS_SOA => [ [ 'host' => 'example.net', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], }; } // example.net A, AAAA, CNAME if ($hostname === 'www.example.net') { return match ($type) { DNS_A => [ [ 'host' => 'www.example.net', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '1.1.1.1' ] ], DNS_AAAA => [], DNS_CNAME => [], }; } return false; }); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://www.example.com'), ['nextcloud' => ['allow_local_address' => false]] ); } public function testPopulateDnsCacheIPv6(): void { $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options) { self::arrayHasKey('curl', $options); self::arrayHasKey(CURLOPT_RESOLVE, $options['curl']); self::assertEquals([ 'www.example.com:80:1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001', 'www.example.com:443:1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001' ], $options['curl'][CURLOPT_RESOLVE]); return new Response(200); }, ]); $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function (string $hostname, int $type) { // example.com SOA if ($hostname === 'example.com') { return match ($type) { DNS_SOA => [ [ 'host' => 'example.com', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], }; } // example.com A, AAAA, CNAME if ($hostname === 'www.example.com') { return match ($type) { DNS_A => [], DNS_AAAA => [], DNS_CNAME => [ [ 'host' => 'www.example.com', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'target' => 'www.example.net' ] ], }; } // example.net SOA if ($hostname === 'example.net') { return match ($type) { DNS_SOA => [ [ 'host' => 'example.net', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], }; } // example.net A, AAAA, CNAME if ($hostname === 'www.example.net') { return match ($type) { DNS_A => [ [ 'host' => 'www.example.net', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '1.1.1.1' ], [ 'host' => 'www.example.net', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '1.0.0.1' ], ], DNS_AAAA => [ [ 'host' => 'www.example.net', 'class' => 'IN', 'ttl' => 1800, 'type' => 'AAAA', 'ip' => '2606:4700:4700::1111' ], [ 'host' => 'www.example.net', 'class' => 'IN', 'ttl' => 1800, 'type' => 'AAAA', 'ip' => '2606:4700:4700::1001' ], ], DNS_CNAME => [], }; } return false; }); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://www.example.com'), ['nextcloud' => ['allow_local_address' => false]] ); } public function testAllowLocalAddress(): void { $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options) { self::assertArrayNotHasKey('curl', $options); return new Response(200); }, ]); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://www.example.com'), ['nextcloud' => ['allow_local_address' => true]] ); } public function testRejectIPv4(): void { $this->expectException(LocalServerException::class); $this->expectExceptionMessage('violates local access rules'); $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options): void { // The handler should not be called }, ]); $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function (string $hostname, int $type) { return match ($type) { DNS_SOA => [ [ 'host' => 'example.com', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], DNS_A => [ [ 'host' => 'example.com', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '192.168.0.1' ] ], DNS_AAAA => [], DNS_CNAME => [], }; }); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://www.example.com'), ['nextcloud' => ['allow_local_address' => false]] ); } public function testRejectIPv6(): void { $this->expectException(LocalServerException::class); $this->expectExceptionMessage('violates local access rules'); $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options): void { // The handler should not be called }, ]); $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function (string $hostname, int $type) { return match ($type) { DNS_SOA => [ [ 'host' => 'example.com', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], DNS_A => [], DNS_AAAA => [ [ 'host' => 'ipv6.example.com', 'class' => 'IN', 'ttl' => 1800, 'type' => 'AAAA', 'ipv6' => 'fd12:3456:789a:1::1' ] ], DNS_CNAME => [], }; }); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://ipv6.example.com'), ['nextcloud' => ['allow_local_address' => false]] ); } public function testRejectCanonicalName(): void { $this->expectException(LocalServerException::class); $this->expectExceptionMessage('violates local access rules'); $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options): void { // The handler should not be called }, ]); $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function (string $hostname, int $type) { // example.com SOA if ($hostname === 'example.com') { return match ($type) { DNS_SOA => [ [ 'host' => 'example.com', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], }; } // example.com A, AAAA, CNAME if ($hostname === 'www.example.com') { return match ($type) { DNS_A => [], DNS_AAAA => [], DNS_CNAME => [ [ 'host' => 'www.example.com', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'target' => 'www.example.net' ] ], }; } // example.net SOA if ($hostname === 'example.net') { return match ($type) { DNS_SOA => [ [ 'host' => 'example.net', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], }; } // example.net A, AAAA, CNAME if ($hostname === 'www.example.net') { return match ($type) { DNS_A => [ [ 'host' => 'www.example.net', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '192.168.0.2' ] ], DNS_AAAA => [], DNS_CNAME => [], }; } return false; }); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://www.example.com'), ['nextcloud' => ['allow_local_address' => false]] ); } public function testRejectFaultyResponse(): void { $this->expectException(LocalServerException::class); $this->expectExceptionMessage('No DNS record found for www.example.com'); $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options): void { // The handler should not be called }, ]); $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function (string $hostname, int $type) { return false; }); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://www.example.com'), ['nextcloud' => ['allow_local_address' => false]] ); } public function testIgnoreSubdomainForSoaQuery(): void { $mockHandler = new MockHandler([ static function (RequestInterface $request, array $options): void { // The handler should not be called }, ]); $dnsQueries = []; $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function (string $hostname, int $type) use (&$dnsQueries) { // log query $dnsQueries[] = $hostname . $type; // example.com SOA if ($hostname === 'example.com') { return match ($type) { DNS_SOA => [ [ 'host' => 'example.com', 'class' => 'IN', 'ttl' => 7079, 'type' => 'SOA', 'minimum-ttl' => 3600, ] ], }; } // example.net A, AAAA, CNAME if ($hostname === 'subsubdomain.subdomain.example.com') { return match ($type) { DNS_A => [ [ 'host' => 'subsubdomain.subdomain.example.com', 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '1.1.1.1' ] ], DNS_AAAA => [], DNS_CNAME => [], }; } return false; }); $stack = new HandlerStack($mockHandler); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', 'https://subsubdomain.subdomain.example.com'), ['nextcloud' => ['allow_local_address' => false]] ); $this->assertCount(3, $dnsQueries); $this->assertContains('example.com' . DNS_SOA, $dnsQueries); $this->assertContains('subsubdomain.subdomain.example.com' . DNS_A, $dnsQueries); $this->assertContains('subsubdomain.subdomain.example.com' . DNS_AAAA, $dnsQueries); // CNAME should not be queried if A or AAAA succeeded already $this->assertNotContains('subsubdomain.subdomain.example.com' . DNS_CNAME, $dnsQueries); } public function testDnsGetRecordCalledWithPunycode() { // Unicode hostname with umlaut (IDN) $unicodeHost = 'bücher.com'; $punycodeHost = idn_to_ascii($unicodeHost, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); // We expect that the middleware will call dnsGetRecord with the Punycode (not Unicode) $this->dnsPinMiddleware ->expects($this->atLeastOnce()) ->method('dnsGetRecord') ->with( $this->callback(function ($hostname) use ($punycodeHost) { // Should never be raw Unicode here! $this->assertEquals($punycodeHost, $hostname, "dnsGetRecord should be called with Punycode ASCII host"); return true; }), $this->anything() ) ->willReturn([ [ 'host' => $punycodeHost, 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '203.0.113.5' ] ]); $stack = new HandlerStack(new MockHandler([ static fn () => new Response(200) ])); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', "https://$unicodeHost"), ['nextcloud' => ['allow_local_address' => false]] ); } public function testDnsGetRecordWithRawUnicodeFailsGracefully() { // Simulate a middleware bug where Unicode is passed to dns_get_record $unicodeHost = 'bücher.com'; $this->dnsPinMiddleware ->method('dnsGetRecord') ->willReturnCallback(function ($hostname, $type) use ($unicodeHost) { if ($hostname === $unicodeHost) { // Simulate real dns_get_record failure (returns false) return false; } return [ [ 'host' => $hostname, 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '203.0.113.5' ] ]; }); $stack = new HandlerStack(new MockHandler([ static fn () => new Response(200) ])); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $this->expectException(LocalServerException::class); $this->expectExceptionMessage('No DNS record found for ' . $unicodeHost); $handler( new Request('GET', "https://$unicodeHost"), ['nextcloud' => ['allow_local_address' => false]] ); } public function testDnsPinMiddlewareAcceptsPunycodeDirectly() { $punycodeHost = 'xn--bcher-kva.com'; $this->dnsPinMiddleware ->expects($this->atLeastOnce()) ->method('dnsGetRecord') ->with( $punycodeHost, $this->anything() ) ->willReturn([ [ 'host' => $punycodeHost, 'class' => 'IN', 'ttl' => 1800, 'type' => 'A', 'ip' => '203.0.113.80' ] ]); $stack = new HandlerStack(new MockHandler([ static fn () => new Response(200) ])); $stack->push($this->dnsPinMiddleware->addDnsPinning()); $handler = $stack->resolve(); $handler( new Request('GET', "https://$punycodeHost"), ['nextcloud' => ['allow_local_address' => false]] ); } }