diff --git a/app/Services/Freenode/Codec.php b/app/Services/Freenode/Codec.php index effdde84..74f295ac 100644 --- a/app/Services/Freenode/Codec.php +++ b/app/Services/Freenode/Codec.php @@ -78,7 +78,8 @@ final class Codec throw new InvalidArgumentException('missing_auth'); } // Rebuild only authority. Keep query bytes intact: do not decode the whole URI. - $authority = ($url['user'] ?? '').(isset($url['pass']) ? ':'.$url['pass'] : ''); + // Percent-encode decoded userinfo once; raw brackets belong to credentials, not IPv6. + $authority = rawurlencode($user).(isset($url['pass']) ? ':'.rawurlencode($password) : ''); $authority = ($authority !== '' ? $authority.'@' : '').(str_contains($host, ':') ? '['.$host.']' : $host).':'.$port; $connection = $authority.($url['path'] ?? '').(isset($url['query']) ? '?'.$url['query'] : ''); return self::node($type, ['host' => $host, 'port' => $port, 'user' => $user, 'password' => $password, 'query' => $query, 'connection' => $connection], rawurldecode($name)); diff --git a/tests/Unit/Freenode/CodecTest.php b/tests/Unit/Freenode/CodecTest.php index 09e420cd..5088bb7f 100644 --- a/tests/Unit/Freenode/CodecTest.php +++ b/tests/Unit/Freenode/CodecTest.php @@ -41,6 +41,32 @@ final class CodecTest extends TestCase self::assertSame('a+b@c#d', Codec::singbox($node, 'x')['password']); } + public function testRawPasswordBracketsAreEscapedInPublishedUri(): void + { + $node = Codec::parse('trojan://pass[word]@example.invalid:443?security=tls#test'); + $uri = Codec::uri($node, 'test'); + self::assertStringStartsWith('trojan://pass%5Bword%5D@example.invalid:443?', $uri); + self::assertSame('pass[word]', Codec::singbox($node, 'test')['password']); + self::assertSame($node['id'], Codec::parse($uri)['id']); + } + + public function testEscapedUserinfoKeepsExactDecodedPassword(): void + { + $password = 'a[b]+c@d#e:f%2F'; + $node = Codec::parse('trojan://'.rawurlencode($password).'@example.invalid:443?security=tls#test'); + $uri = Codec::uri($node, 'test'); + self::assertStringStartsWith('trojan://'.rawurlencode($password).'@', $uri); + self::assertSame($password, Codec::singbox(Codec::parse($uri), 'test')['password']); + self::assertSame($node['id'], Codec::parse($uri)['id']); + } + + public function testPasswordBracketsDoNotChangeIpv6Host(): void + { + $node = Codec::parse('trojan://a[b]@[2001:db8::1]:443?security=tls#test'); + self::assertStringStartsWith('trojan://a%5Bb%5D@[2001:db8::1]:443?', Codec::uri($node, 'test')); + self::assertSame('2001:db8::1', Codec::singbox($node, 'test')['server']); + } + public function testSsBase64UrlAndPlugin(): void { $auth = rtrim(strtr(base64_encode('aes-128-gcm:a+b'), '+/', '-_'), '=');