From cee867671b410513fdd1b7ba9db65ed21ff08331 Mon Sep 17 00:00:00 2001 From: Paul Bottinelli Date: Thu, 4 Jun 2026 11:32:14 -0400 Subject: [PATCH 1/2] Parse bracketed IPv6 server hosts --- src/Simplex/Messaging/ServiceScheme.hs | 4 +- src/Simplex/RemoteControl/Invitation.hs | 8 +++- tests/CoreTests/EncodingTests.hs | 16 +++++++ tests/RemoteControl.hs | 55 ++++++++++++++++++++++++- xftp-web/src/protocol/address.ts | 18 +++++++- xftp-web/test/address.node.test.ts | 21 ++++++++++ 6 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 xftp-web/test/address.node.test.ts diff --git a/src/Simplex/Messaging/ServiceScheme.hs b/src/Simplex/Messaging/ServiceScheme.hs index 4db2aff60d..81fd9f3ff7 100644 --- a/src/Simplex/Messaging/ServiceScheme.hs +++ b/src/Simplex/Messaging/ServiceScheme.hs @@ -32,7 +32,9 @@ instance StrEncoding SrvLoc where strEncode (SrvLoc host port) = B.pack $ host <> if null port then "" else ':' : port strP = SrvLoc <$> host <*> (port <|> pure "") where - host = B.unpack <$> A.takeWhile1 (A.notInClass ":#,;/ ") + host = bracketedHost <|> rawHost + bracketedHost = B.unpack <$> (A.char '[' *> A.takeWhile1 (/= ']') <* A.char ']') + rawHost = B.unpack <$> A.takeWhile1 (A.notInClass ":#,;/ ") port = show <$> (A.char ':' *> (A.decimal :: A.Parser Int)) simplexChat :: ServiceScheme diff --git a/src/Simplex/RemoteControl/Invitation.hs b/src/Simplex/RemoteControl/Invitation.hs index 90be1d5fbd..5c359e4d19 100644 --- a/src/Simplex/RemoteControl/Invitation.hs +++ b/src/Simplex/RemoteControl/Invitation.hs @@ -13,6 +13,7 @@ module Simplex.RemoteControl.Invitation , RCEncInvitation (..) ) where +import Control.Applicative ((<|>)) import qualified Data.Aeson as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString (ByteString) @@ -81,7 +82,7 @@ instance StrEncoding RCInvitation where _ <- A.string "xrcp:/" ca <- strP _ <- A.char '@' - host <- A.takeWhile (/= ':') >>= either fail pure . strDecode . urlDecode True + host <- hostP _ <- A.char ':' port <- strP _ <- A.string "#/?" @@ -94,6 +95,11 @@ instance StrEncoding RCInvitation where idkey <- requiredP q "idkey" $ parseAll strP dh <- requiredP q "dh" $ parseAll strP pure RCInvitation {ca, host, port, v, app, ts, skey, idkey, dh} + where + hostP = bracketedHostP <|> rawHostP + bracketedHostP = A.char '[' *> A.takeWhile1 (/= ']') <* A.char ']' >>= decodeHost + rawHostP = A.takeWhile (/= ':') >>= decodeHost + decodeHost = either fail pure . strDecode . urlDecode True data RCSignedInvitation = RCSignedInvitation { invitation :: RCInvitation, diff --git a/tests/CoreTests/EncodingTests.hs b/tests/CoreTests/EncodingTests.hs index dc453c4c05..15032b3b26 100644 --- a/tests/CoreTests/EncodingTests.hs +++ b/tests/CoreTests/EncodingTests.hs @@ -10,11 +10,14 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (w2c) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Time.Clock.System (SystemTime (..), getSystemTime, utcToSystemTime) import Data.Time.ISO8601 (parseISO8601) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) +import Simplex.Messaging.Protocol (ProtocolServer (..), XFTPServer) +import Simplex.Messaging.ServiceScheme (ServiceScheme (..), SrvLoc (..)) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Test.Hspec hiding (fit, it) import Test.Hspec.QuickCheck (modifyMaxSuccess) @@ -67,7 +70,20 @@ encodingTests = modifyMaxSuccess (const 1000) $ do THDomainName "192.256.0.1" #==# "192.256.0.1" THDomainName "192.168.0.-1" #==# "192.168.0.-1" shouldNotParse @TransportHost "192.168.0.0.1" "endOfInput" + describe "Encoding service locations" $ + it "should parse bracketed IPv6 host with port" $ + strDecode @ServiceScheme "https://[2001:db8::1]:8443" + `shouldBe` Right (SSAppServer $ SrvLoc "2001:db8::1" "8443") + describe "Encoding protocol servers" $ + it "should parse bracketed IPv6 server host with port" $ + case strDecode @XFTPServer "xftp://1234-w==@[2001:db8::1]:443" of + Left err -> expectationFailure err + Right (ProtocolServer _ parsedHost parsedPort _) -> do + parsedHost `shouldBe` (ipv6Host :| []) + parsedPort `shouldBe` "443" where + ipv6Host :: TransportHost + ipv6Host = either error id $ strDecode "2001:db8::1" testSystemTime :: SystemTime -> Expectation testSystemTime t = do smpEncode t `shouldBe` smpEncode (systemSeconds t) diff --git a/tests/RemoteControl.hs b/tests/RemoteControl.hs index 134b2f255f..28898f4738 100644 --- a/tests/RemoteControl.hs +++ b/tests/RemoteControl.hs @@ -1,7 +1,9 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} module RemoteControl where @@ -9,15 +11,23 @@ import AgentTests.FunctionalAPITests (runRight) import Control.Logger.Simple import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import Data.List (stripPrefix) import Data.List.NonEmpty (NonEmpty (..)) +import Data.Time.Clock.System (SystemTime (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (TSbChainKeys (..)) +import Simplex.Messaging.Transport.Client (TransportHost) import qualified Simplex.RemoteControl.Client as HC (RCHostClient (action)) import qualified Simplex.RemoteControl.Client as RC import Simplex.RemoteControl.Discovery (mkLastLocalHost, preferAddress) -import Simplex.RemoteControl.Invitation (RCSignedInvitation, verifySignedInvitation) +import Simplex.RemoteControl.Invitation + ( RCInvitation (..), + RCSignedInvitation, + verifySignedInvitation, + ) import Simplex.RemoteControl.Types import Test.Hspec hiding (fit, it) import UnliftIO @@ -27,6 +37,8 @@ import Util remoteControlTests :: Spec remoteControlTests = do describe "preferred bindings should go first" testPreferAddress + describe "Invitation parsing" $ + it "should parse bracketed IPv6 host with port" testInvitationBracketedIPv6Host describe "New controller/host pairing" $ do it "should connect to new pairing" testNewPairing it "should connect to existing pairing" testExistingPairing @@ -65,6 +77,47 @@ testPreferAddress = do addrsDups = "10.20.30.40" `on` "eth1" : addrs' ifaceDups = "10.20.30.41" `on` "eth0" : addrs' +testInvitationBracketedIPv6Host :: IO () +testInvitationBracketedIPv6Host = do + invitation <- testIPv6Invitation + let bracketedUri = + B.pack . replaceFirst "@2001:db8::1:" "@[2001:db8::1]:" . B.unpack $ + strEncode invitation + expectedHost = either error id (strDecode "2001:db8::1") :: TransportHost + case strDecode bracketedUri of + Left err -> expectationFailure err + Right RCInvitation {host, port} -> do + host `shouldBe` expectedHost + port `shouldBe` 5223 + +replaceFirst :: String -> String -> String -> String +replaceFirst needle replacement = go + where + go [] = [] + go input@(c : cs) = + case stripPrefix needle input of + Just rest -> replacement <> rest + Nothing -> c : go cs + +testIPv6Invitation :: IO RCInvitation +testIPv6Invitation = do + drg <- C.newRandom + (skey, _) <- atomically $ C.generateKeyPair @'C.Ed25519 drg + (idkey, _) <- atomically $ C.generateKeyPair @'C.Ed25519 drg + (dh, _) <- atomically $ C.generateKeyPair @'C.X25519 drg + pure + RCInvitation + { ca = C.KeyHash "test-ca", + host = either error id $ strDecode "2001:db8::1", + port = 5223, + v = supportedRCPVRange, + app = J.String "app", + ts = MkSystemTime 0 0, + skey, + idkey, + dh + } + testNewPairing :: IO () testNewPairing = do drg <- C.newRandom diff --git a/xftp-web/src/protocol/address.ts b/xftp-web/src/protocol/address.ts index e3eb27fd8e..c2897a63ed 100644 --- a/xftp-web/src/protocol/address.ts +++ b/xftp-web/src/protocol/address.ts @@ -35,6 +35,22 @@ export function parseXFTPServer(address: string): XFTPServer { const hostPart = m[2] // Take the first host (before any comma), then split port from that const firstHost = hostPart.split(',')[0] + return {keyHash, ...parseHostPort(firstHost)} +} + +function parseHostPort(firstHost: string): Pick { + if (firstHost.length === 0) throw new Error("parseXFTPServer: missing host") + if (firstHost.startsWith('[')) { + const bracketEnd = firstHost.indexOf(']') + if (bracketEnd < 0) throw new Error("parseXFTPServer: invalid bracketed host") + const host = firstHost.substring(0, bracketEnd + 1) + const rest = firstHost.substring(bracketEnd + 1) + if (rest.length === 0) return {host, port: "443"} + if (!rest.startsWith(':')) throw new Error("parseXFTPServer: invalid bracketed host") + const port = rest.substring(1) + if (port.length === 0) throw new Error("parseXFTPServer: missing port") + return {host, port} + } const colonIdx = firstHost.lastIndexOf(':') let host: string let port: string @@ -45,7 +61,7 @@ export function parseXFTPServer(address: string): XFTPServer { host = firstHost port = "443" } - return {keyHash, host, port} + return {host, port} } // Format an XFTPServer back to its URI string representation. diff --git a/xftp-web/test/address.node.test.ts b/xftp-web/test/address.node.test.ts new file mode 100644 index 0000000000..b9738b3343 --- /dev/null +++ b/xftp-web/test/address.node.test.ts @@ -0,0 +1,21 @@ +import {expect, test} from 'vitest' +import {formatXFTPServer, parseXFTPServer, serverOrigin} from '../src/protocol/address.js' + +const keyHash = 'LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=' + +test('parseXFTPServer supports bracketed IPv6 hosts with ports', () => { + const server = parseXFTPServer(`xftp://${keyHash}@[2001:db8::1]:8443,example.com`) + + expect(server.host).toBe('[2001:db8::1]') + expect(server.port).toBe('8443') + expect(serverOrigin(server)).toBe('https://[2001:db8::1]:8443') + expect(formatXFTPServer(server)).toBe(`xftp://${keyHash}@[2001:db8::1]:8443`) +}) + +test('parseXFTPServer uses the default port for bracketed IPv6 hosts', () => { + const server = parseXFTPServer(`xftp://${keyHash}@[2001:db8::1]`) + + expect(server.host).toBe('[2001:db8::1]') + expect(server.port).toBe('443') + expect(serverOrigin(server)).toBe('https://[2001:db8::1]') +}) From 15d86f9b22e0bf7a24413b1a43936822f4ff3b2f Mon Sep 17 00:00:00 2001 From: sh Date: Wed, 17 Jun 2026 07:40:12 +0000 Subject: [PATCH 2/2] lib: parse service-scheme and invitation hosts via TransportHost --- src/Simplex/Messaging/ServiceScheme.hs | 12 +++++------- src/Simplex/Messaging/Transport/Client.hs | 2 +- src/Simplex/RemoteControl/Invitation.hs | 8 +------- tests/CoreTests/EncodingTests.hs | 15 +++++++++++++-- tests/RemoteControl.hs | 13 ++++++++++++- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/Simplex/Messaging/ServiceScheme.hs b/src/Simplex/Messaging/ServiceScheme.hs index 81fd9f3ff7..d2ee7299d9 100644 --- a/src/Simplex/Messaging/ServiceScheme.hs +++ b/src/Simplex/Messaging/ServiceScheme.hs @@ -11,8 +11,9 @@ import Control.Applicative ((<|>)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) -import Network.Socket (HostName, ServiceName) +import Network.Socket (ServiceName) import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import Simplex.Messaging.Transport.Client (TransportHost) data ServiceScheme = SSSimplex | SSAppServer SrvLoc deriving (Eq, Show) @@ -25,16 +26,13 @@ instance StrEncoding ServiceScheme where "simplex:" $> SSSimplex <|> "https://" *> (SSAppServer <$> strP) -data SrvLoc = SrvLoc HostName ServiceName +data SrvLoc = SrvLoc TransportHost ServiceName deriving (Eq, Ord, Show) instance StrEncoding SrvLoc where - strEncode (SrvLoc host port) = B.pack $ host <> if null port then "" else ':' : port - strP = SrvLoc <$> host <*> (port <|> pure "") + strEncode (SrvLoc host port) = strEncode host <> B.pack (if null port then "" else ':' : port) + strP = SrvLoc <$> strP <*> (port <|> pure "") where - host = bracketedHost <|> rawHost - bracketedHost = B.unpack <$> (A.char '[' *> A.takeWhile1 (/= ']') <* A.char ']') - rawHost = B.unpack <$> A.takeWhile1 (A.notInClass ":#,;/ ") port = show <$> (A.char ':' *> (A.decimal :: A.Parser Int)) simplexChat :: ServiceScheme diff --git a/src/Simplex/Messaging/Transport/Client.hs b/src/Simplex/Messaging/Transport/Client.hs index ee08ebc936..5d85e7aab4 100644 --- a/src/Simplex/Messaging/Transport/Client.hs +++ b/src/Simplex/Messaging/Transport/Client.hs @@ -89,7 +89,7 @@ instance StrEncoding TransportHost where [ THIPv4 <$> ((,,,) <$> ipNum <*> ipNum <*> ipNum <*> A.decimal), maybe (Left "bad IPv6") (Right . THIPv6 . fromIPv6w) . readMaybe . B.unpack <$?> ipv6StrP, THOnionHost <$> ((<>) <$> A.takeWhile (\c -> isAsciiLower c || isDigit c) <*> A.string ".onion"), - THDomainName . B.unpack <$> (notOnion <$?> A.takeWhile1 (A.notInClass ":#,;/ \n\r\t")) + THDomainName . B.unpack <$> (notOnion <$?> A.takeWhile1 (A.notInClass ":#,;/ \n\r\t[]")) ] where ipNum = validIP <$?> (A.decimal <* A.char '.') diff --git a/src/Simplex/RemoteControl/Invitation.hs b/src/Simplex/RemoteControl/Invitation.hs index 5c359e4d19..480838c31a 100644 --- a/src/Simplex/RemoteControl/Invitation.hs +++ b/src/Simplex/RemoteControl/Invitation.hs @@ -13,7 +13,6 @@ module Simplex.RemoteControl.Invitation , RCEncInvitation (..) ) where -import Control.Applicative ((<|>)) import qualified Data.Aeson as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString (ByteString) @@ -82,7 +81,7 @@ instance StrEncoding RCInvitation where _ <- A.string "xrcp:/" ca <- strP _ <- A.char '@' - host <- hostP + host <- strP _ <- A.char ':' port <- strP _ <- A.string "#/?" @@ -95,11 +94,6 @@ instance StrEncoding RCInvitation where idkey <- requiredP q "idkey" $ parseAll strP dh <- requiredP q "dh" $ parseAll strP pure RCInvitation {ca, host, port, v, app, ts, skey, idkey, dh} - where - hostP = bracketedHostP <|> rawHostP - bracketedHostP = A.char '[' *> A.takeWhile1 (/= ']') <* A.char ']' >>= decodeHost - rawHostP = A.takeWhile (/= ':') >>= decodeHost - decodeHost = either fail pure . strDecode . urlDecode True data RCSignedInvitation = RCSignedInvitation { invitation :: RCInvitation, diff --git a/tests/CoreTests/EncodingTests.hs b/tests/CoreTests/EncodingTests.hs index 15032b3b26..26fab8503e 100644 --- a/tests/CoreTests/EncodingTests.hs +++ b/tests/CoreTests/EncodingTests.hs @@ -70,17 +70,24 @@ encodingTests = modifyMaxSuccess (const 1000) $ do THDomainName "192.256.0.1" #==# "192.256.0.1" THDomainName "192.168.0.-1" #==# "192.168.0.-1" shouldNotParse @TransportHost "192.168.0.0.1" "endOfInput" - describe "Encoding service locations" $ + -- brackets are reserved for IPv6 literals + shouldReject @TransportHost "[simplex.chat]" + shouldReject @TransportHost "[smp.simplex.im]" + describe "Encoding service locations" $ do it "should parse bracketed IPv6 host with port" $ strDecode @ServiceScheme "https://[2001:db8::1]:8443" `shouldBe` Right (SSAppServer $ SrvLoc "2001:db8::1" "8443") - describe "Encoding protocol servers" $ + it "should reject bracketed non-IPv6 host" $ + shouldReject @ServiceScheme "https://[simplex.chat]:8443" + describe "Encoding protocol servers" $ do it "should parse bracketed IPv6 server host with port" $ case strDecode @XFTPServer "xftp://1234-w==@[2001:db8::1]:443" of Left err -> expectationFailure err Right (ProtocolServer _ parsedHost parsedPort _) -> do parsedHost `shouldBe` (ipv6Host :| []) parsedPort `shouldBe` "443" + it "should reject bracketed non-IPv6 server host" $ + shouldReject @XFTPServer "xftp://1234-w==@[simplex.chat]:443" where ipv6Host :: TransportHost ipv6Host = either error id $ strDecode "2001:db8::1" @@ -94,3 +101,7 @@ encodingTests = modifyMaxSuccess (const 1000) $ do strDecode s `shouldBe` Right x shouldNotParse :: forall s. (StrEncoding s, Eq s, Show s) => ByteString -> String -> Expectation shouldNotParse s err = strDecode s `shouldBe` (Left err :: Either String s) + shouldReject :: forall s. (StrEncoding s, Show s) => ByteString -> Expectation + shouldReject s = case strDecode s :: Either String s of + Left _ -> pure () + Right a -> expectationFailure $ "expected parse failure, got " <> show a diff --git a/tests/RemoteControl.hs b/tests/RemoteControl.hs index 28898f4738..630e774c07 100644 --- a/tests/RemoteControl.hs +++ b/tests/RemoteControl.hs @@ -37,8 +37,9 @@ import Util remoteControlTests :: Spec remoteControlTests = do describe "preferred bindings should go first" testPreferAddress - describe "Invitation parsing" $ + describe "Invitation parsing" $ do it "should parse bracketed IPv6 host with port" testInvitationBracketedIPv6Host + it "should reject bracketed non-IPv6 host" testInvitationBracketedNonIPv6HostRejected describe "New controller/host pairing" $ do it "should connect to new pairing" testNewPairing it "should connect to existing pairing" testExistingPairing @@ -90,6 +91,16 @@ testInvitationBracketedIPv6Host = do host `shouldBe` expectedHost port `shouldBe` 5223 +testInvitationBracketedNonIPv6HostRejected :: IO () +testInvitationBracketedNonIPv6HostRejected = do + invitation <- testIPv6Invitation + let bracketedUri = + B.pack . replaceFirst "@2001:db8::1:" "@[simplex.chat]:" . B.unpack $ + strEncode invitation + case strDecode bracketedUri :: Either String RCInvitation of + Left _ -> pure () + Right _ -> expectationFailure "expected parse failure for bracketed non-IPv6 host" + replaceFirst :: String -> String -> String -> String replaceFirst needle replacement = go where