packages feed

shikumi-tools-0.3.0.0: src/Shikumi/Tool/Web.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE RankNTypes #-}

-- | Swappable HTTP operations for the built-in web tools.
--
-- 'web_fetch' is functional with only a TLS 'Manager'. 'web_search' requires a
-- configured provider because search APIs need provider-specific credentials.
--
-- Security posture: the default fetch policy denies non-http(s) schemes and the
-- most common SSRF destinations (loopback, link-local, and RFC-1918 private host
-- literals) before opening a connection, and caps response bytes while reading.
-- It does not resolve hostnames to detect private addresses behind DNS; supply a
-- stricter 'FetchPolicy' through 'localWebClientWith' when deployments need that.
module Shikumi.Tool.Web
  ( FetchResult (..),
    SearchHit (..),
    SearchResult (..),
    SearchConfig (..),
    FetchPolicy (..),
    defaultFetchPolicy,
    checkFetchUrl,
    readCapped,
    WebClient (..),
    localWebClient,
    localWebClientWith,
    newTlsManager,
    Manager,
  )
where

import Control.Exception (SomeException, try)
import Control.Lens ((^.))
import Data.Aeson (FromJSON, ToJSON, eitherDecode)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as B8
import Data.Generics.Labels ()
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Encoding.Error (lenientDecode)
import Effectful (Eff)
import Effectful.Dispatch.Static (unsafeEff_)
import Effectful.Error.Static (throwError)
import GHC.Generics (Generic)
import Network.HTTP.Client
  ( HttpException,
    Manager,
    Request,
    brRead,
    host,
    httpLbs,
    parseRequest,
    responseBody,
    responseHeaders,
    responseStatus,
    setQueryString,
    withResponse,
  )
import Network.HTTP.Client.TLS qualified as TLS
import Network.HTTP.Types.Header (ResponseHeaders)
import Network.HTTP.Types.Status (statusCode)
import Shikumi.Error (ShikumiError (..))
import Shikumi.Tool.Env (EnvRow)
import Text.Read (readMaybe)

data FetchResult = FetchResult
  { status :: !Int,
    contentType :: !Text,
    body :: !Text,
    truncated :: !Bool
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (FromJSON, ToJSON)

data SearchHit = SearchHit
  { title :: !Text,
    url :: !Text,
    snippet :: !Text
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (FromJSON, ToJSON)

newtype SearchResult = SearchResult {hits :: [SearchHit]}
  deriving stock (Generic, Show, Eq)
  deriving anyclass (FromJSON, ToJSON)

data SearchConfig = SearchConfig
  { baseUrl :: !Text,
    apiKey :: !Text
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data FetchPolicy = FetchPolicy
  { maxResponseBytes :: !Int,
    checkUrl :: !(Text -> Either Text ())
  }

defaultFetchPolicy :: FetchPolicy
defaultFetchPolicy =
  FetchPolicy
    { maxResponseBytes = 5 * 1024 * 1024,
      checkUrl = defaultCheckUrl
    }

data WebClient = WebClient
  { webFetch :: forall es. (EnvRow es) => Text -> Maybe Int -> Eff es FetchResult,
    webSearch :: forall es. (EnvRow es) => Text -> Maybe Int -> Eff es SearchResult
  }

localWebClient :: Manager -> Maybe SearchConfig -> WebClient
localWebClient = localWebClientWith defaultFetchPolicy

localWebClientWith :: FetchPolicy -> Manager -> Maybe SearchConfig -> WebClient
localWebClientWith policy manager searchConfig =
  WebClient
    { webFetch = \url maxBytes -> localFetch policy manager url maxBytes,
      webSearch = \query maxResults -> localSearch manager searchConfig query maxResults
    }

newTlsManager :: IO Manager
newTlsManager = TLS.newTlsManager

localFetch :: (EnvRow es) => FetchPolicy -> Manager -> Text -> Maybe Int -> Eff es FetchResult
localFetch policy manager url maxBytes =
  case checkFetchUrl policy url of
    Left reason ->
      throwError (ValidationFailure ("web_fetch: refused by fetch policy: " <> reason))
    Right () -> do
      let maxBodyBytes = min (maxResponseBytes policy) (max 0 (maybe 100000 id maxBytes))
      (st, ct, bodyBytes, wasTruncated) <- httpIO "web_fetch" $ do
        request <- parseRequest (T.unpack url)
        withResponse request manager $ \response -> do
          (bytes, cut) <- readCapped maxBodyBytes (brRead (responseBody response))
          pure (statusCode (responseStatus response), contentTypeOf (responseHeaders response), bytes, cut)
      let decodedBody = TE.decodeUtf8With lenientDecode bodyBytes
      pure
        FetchResult
          { status = st,
            contentType = ct,
            body = decodedBody,
            truncated = wasTruncated
          }

checkFetchUrl :: FetchPolicy -> Text -> Either Text ()
checkFetchUrl policy = checkUrl policy

defaultCheckUrl :: Text -> Either Text ()
defaultCheckUrl url
  | not ("http://" `T.isPrefixOf` lowerUrl || "https://" `T.isPrefixOf` lowerUrl) =
      Left "scheme must be http or https"
  | otherwise =
      case parseRequest (T.unpack url) :: Either SomeException Request of
        Left err -> Left ("invalid URL: " <> T.pack (show err))
        Right request ->
          let hostText = T.toLower (TE.decodeUtf8With lenientDecode (host request))
           in if deniedHost hostText
                then Left ("host is not allowed by default fetch policy: " <> hostText)
                else Right ()
  where
    lowerUrl = T.toLower url

deniedHost :: Text -> Bool
deniedHost h =
  h
    `elem` [ "localhost",
             "localhost.",
             "0.0.0.0",
             "::1",
             "[::1]"
           ]
    || "127." `T.isPrefixOf` h
    || "169.254." `T.isPrefixOf` h
    || "10." `T.isPrefixOf` h
    || "192.168." `T.isPrefixOf` h
    || is172Private h

is172Private :: Text -> Bool
is172Private h =
  case traverse readInt (T.splitOn "." h) of
    Just [first, second, _, _] -> first == 172 && second >= 16 && second <= 31
    _ -> False
  where
    readInt :: Text -> Maybe Int
    readInt = readMaybe . T.unpack

readCapped :: Int -> IO BS.ByteString -> IO (BS.ByteString, Bool)
readCapped cap next = go 0 []
  where
    go n acc = do
      chunk <- next
      if BS.null chunk
        then pure (BS.concat (reverse acc), False)
        else do
          let n' = n + BS.length chunk
          if n' >= cap
            then do
              let keep = BS.take (cap - n) chunk
              more <- if n' > cap then pure True else (not . BS.null) <$> next
              pure (BS.concat (reverse (keep : acc)), more)
            else go n' (chunk : acc)

localSearch :: (EnvRow es) => Manager -> Maybe SearchConfig -> Text -> Maybe Int -> Eff es SearchResult
localSearch _ Nothing _ _ =
  throwError (ProviderFailure "web_search: no search provider configured")
localSearch manager (Just config) query maxResults = do
  response <- httpIO "web_search" $ do
    request0 <- parseRequest (T.unpack (config ^. #baseUrl))
    let request =
          setQueryString
            [ ("q", Just (TE.encodeUtf8 query)),
              ("key", Just (TE.encodeUtf8 (config ^. #apiKey))),
              ("limit", Just (B8.pack (show (maybe 10 id maxResults))))
            ]
            request0
    httpLbs request manager
  case eitherDecode (responseBody response) of
    Right result -> pure result
    Left err -> throwError (ProviderFailure ("web_search: could not decode search response: " <> T.pack err))

httpIO :: (EnvRow es) => Text -> IO a -> Eff es a
httpIO label action = do
  result <- unsafeEff_ (try action)
  case result of
    Right value -> pure value
    Left (err :: HttpException) ->
      throwError (ProviderFailure (label <> ": " <> T.pack (show err)))

contentTypeOf :: ResponseHeaders -> Text
contentTypeOf headers =
  maybe "" (TE.decodeUtf8With lenientDecode) (lookup "Content-Type" headers)