link-canonical-0.1.0.0: src/Link/Canonical/Redirect.hs
module Link.Canonical.Redirect
( -- * Redirect resolution
resolveFinalUri,
resolveFinalUriWithChain,
-- * Private IP detection
isPrivateIP,
-- * Helpers
isRedirectStatus,
isSuccessStatus,
isSchemeDowngrade,
)
where
import Data.Set qualified as Set
import Data.Text qualified as T
import Link.Canonical.Error (HttpClientError, RedirectError (..))
import Link.Canonical.Http (HttpClient (..))
import Link.Canonical.Prelude
import Link.Canonical.Types (RedirectConfig (..))
import Network.HTTP.Types (Status, statusCode)
import Text.URI qualified as URI
-- | Resolve the final URI after following redirects
resolveFinalUri ::
(HttpClient m) =>
RedirectConfig ->
URI ->
m (Either RedirectError URI)
resolveFinalUri config uri = do
result <- resolveFinalUriWithChain config uri
pure $ fmap fst result
-- | Resolve the final URI and return the redirect chain
resolveFinalUriWithChain ::
(HttpClient m) =>
RedirectConfig ->
URI ->
m (Either RedirectError (URI, [URI]))
resolveFinalUriWithChain config uri = go Set.empty [] 0 uri
where
go visited chain depth current
| depth > config ^. #maxRedirects =
pure $ Left $ TooManyRedirects depth
| current `Set.member` visited =
pure $ Left $ RedirectLoop (reverse $ current : chain)
| checkPrivateIP config current =
pure $ Left $ PrivateIPBlocked (URI.render current)
| otherwise = do
response <- headRequest current
case response of
Left err -> pure $ Left $ ConnectionFailed $ showHttpError err
Right hr ->
let status = hr ^. #status
in if isRedirectStatus status
then case hr ^. #location of
Nothing ->
pure $ Left $ InvalidLocation "Missing Location header"
Just loc -> do
-- Resolve relative redirects
let next = resolveRelative current loc
if checkDowngrade config current next
then pure $ Left $ SchemeDowngrade (URI.render current) (URI.render next)
else go (Set.insert current visited) (current : chain) (depth + 1) next
else
if isSuccessStatus status
then pure $ Right (current, reverse chain)
else pure $ Left $ HttpError status
checkPrivateIP cfg u =
(cfg ^. #blockPrivateIPs) && isPrivateIP u
checkDowngrade cfg fromUri toUri =
not (cfg ^. #allowDowngrade) && isSchemeDowngrade fromUri toUri
-- | Check if a status code is a redirect (3xx)
isRedirectStatus :: Status -> Bool
isRedirectStatus s =
let code = statusCode s
in code >= 300 && code < 400
-- | Check if a status code is a success (2xx)
isSuccessStatus :: Status -> Bool
isSuccessStatus s =
let code = statusCode s
in code >= 200 && code < 300
-- | Check if going from one URI to another is a scheme downgrade
isSchemeDowngrade :: URI -> URI -> Bool
isSchemeDowngrade fromUri toUri =
let fromScheme = fmap (T.toLower . URI.unRText) $ URI.uriScheme fromUri
toScheme = fmap (T.toLower . URI.unRText) $ URI.uriScheme toUri
in fromScheme == Just "https" && toScheme == Just "http"
-- | Check if a URI points to a private IP address
--
-- Blocks common private IP patterns:
-- - 127.x.x.x (loopback)
-- - 10.x.x.x (private)
-- - 172.16-31.x.x (private)
-- - 192.168.x.x (private)
-- - 169.254.x.x (link-local)
-- - localhost
isPrivateIP :: URI -> Bool
isPrivateIP uri =
case getHostText uri of
Nothing -> False
Just hostText ->
let host = T.toLower hostText
in host == "localhost"
|| "127." `T.isPrefixOf` host
|| "10." `T.isPrefixOf` host
|| "192.168." `T.isPrefixOf` host
|| "169.254." `T.isPrefixOf` host
|| isPrivate172 host
|| host == "::1"
|| "fc" `T.isPrefixOf` host
|| "fd" `T.isPrefixOf` host
|| "fe80:" `T.isPrefixOf` host
-- | Check for 172.16.0.0/12 range
isPrivate172 :: Text -> Bool
isPrivate172 host
| "172." `T.isPrefixOf` host =
case T.splitOn "." host of
(_ : secondOctet : _) ->
case reads (T.unpack secondOctet) :: [(Int, String)] of
[(n, "")] -> n >= 16 && n <= 31
_ -> False
_ -> False
| otherwise = False
-- | Get host text from URI
getHostText :: URI -> Maybe Text
getHostText uri =
case URI.uriAuthority uri of
Left _ -> Nothing -- No authority
Right auth -> Just $ URI.unRText $ URI.authHost auth
-- | Resolve a relative URI against a base URI
resolveRelative :: URI -> URI -> URI
resolveRelative base relative =
-- If relative has a scheme, it's absolute
case URI.uriScheme relative of
Just _ -> relative
Nothing ->
-- Copy scheme from base
let withScheme = relative {URI.uriScheme = URI.uriScheme base}
in case URI.uriAuthority relative of
Right _ -> withScheme -- Has authority, use as-is
_ ->
-- Copy authority from base
withScheme {URI.uriAuthority = URI.uriAuthority base}
-- | Show HTTP error as text
showHttpError :: HttpClientError -> Text
showHttpError err = T.pack $ show err