Skip to content

Commit fd1f0b6

Browse files
authored
Merge pull request #2093 from IntersectMBO/upgrade/smash-parsing-urls
Smash parsing URLs
2 parents 22334c0 + fe59480 commit fd1f0b6

3 files changed

Lines changed: 122 additions & 30 deletions

File tree

cardano-smash-server/cardano-smash-server.cabal

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ library
6666
, cardano-prelude
6767
, containers
6868
, hasql
69+
, http-client
6970
, http-conduit
7071
, iohk-monitoring
72+
, ip
73+
, network
7174
, network-uri
7275
, quiet
7376
, resource-pool

cardano-smash-server/src/Cardano/SMASH/Server/FetchPolicies.hs

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,25 @@ module Cardano.SMASH.Server.FetchPolicies (
88
) where
99

1010
import Cardano.Prelude
11-
import Cardano.SMASH.Server.Types (HealthStatus, PolicyResult (..), PoolId, SmashURL (..))
11+
import Cardano.SMASH.Server.Types (HealthStatus, PolicyResult (..), PoolId, SmashURL (..), isLocalhostHost)
1212
import Control.Monad.Trans.Except.Extra
1313
import Data.Aeson (FromJSON, parseJSON)
1414
import Data.Aeson.Types (parseEither)
15-
import Network.HTTP.Simple (
16-
Request,
17-
getResponseBody,
18-
getResponseStatusCode,
19-
httpJSONEither,
20-
parseRequestThrow,
21-
)
15+
import qualified Data.ByteString.Char8 as BS
16+
import qualified Data.Text.Encoding as Text
17+
import qualified Net.IPv4 as IPv4
18+
import qualified Net.IPv6 as IPv6
19+
import qualified Network.HTTP.Client as Http
20+
import qualified Network.HTTP.Simple as HttpSimple
21+
import qualified Network.Socket as Socket
2222

2323
-- | The possible errors for the http client.
2424
data HttpClientError
2525
= HttpClientCannotParseEndpoint !Text
2626
| HttpClientInvalidClientBody
2727
| HttpClientCannotParseJSON !Text
2828
| HttpClientStatusNotOk
29+
| HttpClientBlockedHost !Text
2930

3031
-- | Render the http client error.
3132
renderHttpClientError :: HttpClientError -> Text
@@ -46,6 +47,44 @@ renderHttpClientError = \case
4647
]
4748
HttpClientStatusNotOk ->
4849
"Http client returned status not ok. Status should be 200."
50+
HttpClientBlockedHost host ->
51+
mconcat
52+
[ "Access to host '"
53+
, host
54+
, "' is not allowed. Only public IP addresses are permitted."
55+
]
56+
57+
isRestrictedIPv6 :: IPv6.IPv6 -> Bool
58+
isRestrictedIPv6 ipv6 =
59+
let (h1, _, _, _, _, _, _, _) = IPv6.toWord16s ipv6
60+
in (ipv6 == IPv6.loopback)
61+
|| ((h1 .&. 0xfe00) == 0xfc00)
62+
|| (h1 == 0xfe80)
63+
64+
validateResolvedHost :: Http.Request -> ExceptT HttpClientError IO ()
65+
validateResolvedHost request = do
66+
let hostBS = Http.host request
67+
eAddrInfos <- liftIO $ try $ Socket.getAddrInfo Nothing (Just $ BS.unpack hostBS) Nothing
68+
addrInfos <- case eAddrInfos of
69+
Left (_ :: SomeException) -> pure []
70+
Right addrs -> pure addrs
71+
when (null addrInfos) $
72+
left $
73+
HttpClientBlockedHost (Text.decodeUtf8 hostBS)
74+
forM_ addrInfos $ \addrInfo -> do
75+
let sockAddr = Socket.addrAddress addrInfo
76+
case sockAddr of
77+
Socket.SockAddrInet _ hostAddr -> do
78+
let ipv4 = IPv4.fromTupleOctets (Socket.hostAddressToTuple hostAddr)
79+
when (IPv4.reserved ipv4) $
80+
left $
81+
HttpClientBlockedHost (Text.decodeUtf8 hostBS)
82+
Socket.SockAddrInet6 _ _ hostAddr6 _ -> do
83+
let ipv6 = IPv6.fromTupleWord32s hostAddr6
84+
when (isRestrictedIPv6 ipv6) $
85+
left $
86+
HttpClientBlockedHost (Text.decodeUtf8 hostBS)
87+
_ -> pure ()
4988

5089
-- | Fetch the remote SMASH server policies.
5190
httpClientFetchPolicies :: SmashURL -> IO (Either HttpClientError PolicyResult)
@@ -83,14 +122,14 @@ httpClientFetchPolicies smashURL = runExceptT $ do
83122
pure policyResult
84123

85124
-- | A simple HTTP call for remote server.
86-
httpApiCall :: forall a. FromJSON a => Request -> ExceptT HttpClientError IO a
125+
httpApiCall :: forall a. FromJSON a => Http.Request -> ExceptT HttpClientError IO a
87126
httpApiCall request = do
88-
httpResult <- httpJSONEither request
89-
let httpResponseBody = getResponseBody httpResult
127+
httpResult <- HttpSimple.httpJSONEither request
128+
let httpResponseBody = HttpSimple.getResponseBody httpResult
90129

91130
httpResponse <- firstExceptT (const HttpClientInvalidClientBody) $ hoistEither httpResponseBody
92131

93-
let httpStatusCode = getResponseStatusCode httpResult
132+
let httpStatusCode = HttpSimple.getResponseStatusCode httpResult
94133

95134
when (httpStatusCode /= 200) $
96135
left HttpClientStatusNotOk
@@ -100,7 +139,10 @@ httpApiCall request = do
100139
Right value -> right value
101140

102141
-- | When any exception occurs, we simply map it to an http client error.
103-
parseRequestEither :: Text -> ExceptT HttpClientError IO Request
142+
parseRequestEither :: Text -> ExceptT HttpClientError IO Http.Request
104143
parseRequestEither requestEndpoint = do
105-
let parsedRequest = newExceptT (try $ parseRequestThrow (toS requestEndpoint) :: IO (Either SomeException Request))
106-
firstExceptT (\_ -> HttpClientCannotParseEndpoint requestEndpoint) parsedRequest
144+
let parsedRequest = newExceptT (try $ HttpSimple.parseRequestThrow (toS requestEndpoint) :: IO (Either SomeException Http.Request))
145+
request <- firstExceptT (\_ -> HttpClientCannotParseEndpoint requestEndpoint) parsedRequest
146+
unless (isLocalhostHost $ Text.decodeUtf8 $ Http.host request) $
147+
validateResolvedHost request
148+
pure request

cardano-smash-server/src/Cardano/SMASH/Server/Types.hs

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module Cardano.SMASH.Server.Types (
2222
UniqueTicker (..),
2323
User (..),
2424
UserValidity (..),
25+
isLocalhostHost,
2526
) where
2627

2728
import Cardano.Api (
@@ -44,13 +45,16 @@ import qualified Data.ByteString.Builder as BSB
4445
import qualified Data.ByteString.Char8 as BS
4546
import qualified Data.ByteString.Lazy as LBS
4647
import Data.Swagger (NamedSchema (..), ToParamSchema (..), ToSchema (..))
48+
import qualified Data.Text as Text
4749
import Data.Time.Clock (UTCTime)
4850
import qualified Data.Time.Clock.POSIX as Time
4951
import Data.Time.Format (defaultTimeLocale, formatTime, parseTimeM)
50-
import Network.URI (URI, parseURI)
52+
import qualified Net.IPv4 as IPv4
53+
import qualified Net.IPv6 as IPv6
54+
import Network.URI (URI (..), parseURI, uriAuthority, uriRegName, uriScheme)
5155
import Quiet (Quiet (..))
5256
import Servant (FromHttpApiData (..), MimeUnrender (..), OctetStream)
53-
import qualified Text.Show as Text
57+
import qualified Text.Show as TShow
5458

5559
-- | The stake pool identifier. It is the hash of the stake pool operator's
5660
-- vkey.
@@ -224,6 +228,52 @@ instance ToSchema PoolFetchError
224228
formatTimeToNormal :: Time.POSIXTime -> Text
225229
formatTimeToNormal = toS . formatTime defaultTimeLocale "%d.%m.%Y. %T" . Time.posixSecondsToUTCTime
226230

231+
isLocalhostHost :: Text -> Bool
232+
isLocalhostHost host =
233+
host == "localhost" || host == "127.0.0.1" || host == "::1"
234+
235+
isRestrictedIPv6 :: IPv6.IPv6 -> Bool
236+
isRestrictedIPv6 ipv6 =
237+
let (h1, _, _, _, _, _, _, _) = IPv6.toWord16s ipv6
238+
in (ipv6 == IPv6.loopback)
239+
|| ((h1 .&. 0xfe00) == 0xfc00)
240+
|| (h1 == 0xfe80)
241+
242+
validateSmashURL :: Text -> Maybe SmashURL
243+
validateSmashURL urlText =
244+
case parseURI (Text.unpack urlText) of
245+
Nothing -> Nothing
246+
Just uri' -> do
247+
let scheme = uriScheme uri'
248+
guard (Text.isPrefixOf "https://" urlText || (Text.isPrefixOf "http://" urlText && isLocalhostUrl urlText))
249+
guard (scheme == "http:" || scheme == "https:")
250+
authority <- uriAuthority uri'
251+
let host = Text.pack (uriRegName authority)
252+
guard (not (isBlockedHost host))
253+
Just (SmashURL uri')
254+
where
255+
isLocalhostUrl :: Text -> Bool
256+
isLocalhostUrl u =
257+
"http://localhost" `Text.isPrefixOf` u
258+
|| "http://127.0.0.1" `Text.isPrefixOf` u
259+
|| "http://[::1]" `Text.isPrefixOf` u
260+
261+
isBlockedHost :: Text -> Bool
262+
isBlockedHost host
263+
| isLocalhostHost host = False
264+
| otherwise =
265+
case readMaybe (Text.unpack host) :: Maybe IPv4.IPv4 of
266+
Just ipv4 | IPv4.reserved ipv4 -> True
267+
_ ->
268+
let hostStr = Text.unpack host
269+
hostStrClean =
270+
if Text.isPrefixOf "[" host && Text.isSuffixOf "]" host
271+
then Text.unpack (Text.init (Text.tail host))
272+
else hostStr
273+
in case readMaybe hostStrClean :: Maybe IPv6.IPv6 of
274+
Just ipv6 | isRestrictedIPv6 ipv6 -> True
275+
_ -> False
276+
227277
-- | The Smash @URI@ containing remote filtering data.
228278
newtype SmashURL = SmashURL {getSmashURL :: URI}
229279
deriving (Eq, Show, Generic)
@@ -237,12 +287,9 @@ instance ToJSON SmashURL where
237287
instance FromJSON SmashURL where
238288
parseJSON = withObject "SmashURL" $ \o -> do
239289
uri <- o .: "smashURL"
240-
241-
let parsedURI = parseURI uri
242-
243-
case parsedURI of
244-
Nothing -> fail "Not a valid URI for SMASH server."
245-
Just uri' -> pure (SmashURL uri')
290+
case validateSmashURL uri of
291+
Nothing -> fail "Invalid or disallowed SMASH URL. Must use HTTPS (or HTTP for localhost)."
292+
Just smashUrl -> pure smashUrl
246293

247294
instance ToSchema SmashURL where
248295
declareNamedSchema _ =
@@ -391,16 +438,16 @@ instance Exception DBFail
391438
instance Show DBFail where
392439
show =
393440
\case
394-
UnknownError err -> "Unknown error. Context: " <> Text.show err
441+
UnknownError err -> "Unknown error. Context: " <> TShow.show err
395442
DbInsertError err ->
396-
"The database got an error while trying to insert a record. Error: " <> Text.show err
443+
"The database got an error while trying to insert a record. Error: " <> TShow.show err
397444
DbLookupPoolMetadataHash poolId poolMDHash ->
398-
"The metadata with hash " <> Text.show poolMDHash <> " for pool " <> Text.show poolId <> " is missing from the DB."
399-
TickerAlreadyReserved ticker -> "Ticker name " <> Text.show (getTickerName ticker) <> " is already reserved"
445+
"The metadata with hash " <> TShow.show poolMDHash <> " for pool " <> TShow.show poolId <> " is missing from the DB."
446+
TickerAlreadyReserved ticker -> "Ticker name " <> TShow.show (getTickerName ticker) <> " is already reserved"
400447
RecordDoesNotExist -> "The requested record does not exist."
401-
DBFail lookupFail -> Text.show lookupFail
402-
PoolDataLayerError err -> Text.show err
403-
ConfigError err -> "Config Error: " <> Text.show err
448+
DBFail lookupFail -> TShow.show lookupFail
449+
PoolDataLayerError err -> TShow.show err
450+
ConfigError err -> "Config Error: " <> TShow.show err
404451

405452
{-
406453

0 commit comments

Comments
 (0)