packages feed

hprox-0.7.0: test/Network/HProx/ProxySpec.hs

-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2026 Bin Jin. All Rights Reserved.

module Network.HProx.ProxySpec
  ( spec
  ) where

import Control.Concurrent.Async   (withAsync)
import Control.Exception          (AsyncException(..), SomeException, bracket, throwIO, try)
import Control.Monad              (unless)
import Data.ByteString.Base64     qualified as B64
import Data.ByteString.Char8      qualified as BS8
import Data.ByteString.Lazy       qualified as LBS
import Data.ByteString.Lazy.Char8 qualified as LBS8
import Data.IORef
import Network.HTTP.Client        qualified as HC
import Network.HTTP.Types         qualified as HT
import Network.HTTP.Types.Header  qualified as HT
import Network.Socket
import Network.Socket.ByteString  qualified as SocketBS
import Network.Wai
import Network.Wai.Handler.Warp   qualified as Warp
import Network.Wai.Test
import System.Log.FastLogger      qualified as FL

import Network.HProx.Impl

import Test.Hspec

spec :: Spec
spec = do
  describe "HTTP proxy auth decisions" $ do
    it "challenges unauthorized HTTP proxy requests" $ do
      response <- runProxyApp (httpProxy authRequiredSettings) rawProxyRequest
      simpleStatus response `shouldBe` HT.status407
      lookup "Proxy-Authenticate" (simpleHeaders response) `shouldBe` Just "Basic realm=\"hprox\""

    it "accepts valid Basic credentials" $
      Warp.testWithApplication (pure observeProxyRequestApp) $ \port -> do
        response <- runProxyApp (httpProxy authorizedSettings) (proxyRequestForPort port basicAliceSecret)
        simpleStatus response `shouldBe` HT.status200

    it "rejects valid Basic credentials with the wrong password" $ do
      response <- runProxyApp (httpProxy authorizedSettings) (rawProxyRequestWithAuth $ basicCredential "alice:wrong")
      simpleStatus response `shouldBe` HT.status407

    it "rejects malformed base64 credentials even if lenient decoding would authenticate" $ do
      response <- runProxyApp (httpProxy authorizedSettings) (rawProxyRequestWithAuth "Basic !!!YWxpY2U6c2VjcmV0!!!")
      simpleStatus response `shouldBe` HT.status407

    it "rejects non-Basic credentials even when the payload is otherwise valid" $ do
      response <- runProxyApp (httpProxy authorizedSettings) (rawProxyRequestWithAuth $ "Bearer " <> B64.encode "alice:secret")
      simpleStatus response `shouldBe` HT.status407

    it "redacts proxy auth passwords in trace logs" $
      Warp.testWithApplication (pure observeProxyRequestApp) $ \port -> do
        logs <- newIORef []
        let loggingSettings = authorizedSettings
              { logger = \_ msg -> modifyIORef' logs (LBS.fromStrict (FL.fromLogStr msg) :)
              }
        response <- runProxyApp (httpProxy loggingSettings) (proxyRequestForPort port basicAliceSecret)
        simpleStatus response `shouldBe` HT.status200
        renderedLogs <- LBS.toStrict . LBS.concat . reverse <$> readIORef logs
        renderedLogs `shouldSatisfy` BS8.isInfixOf "authorized request"
        renderedLogs `shouldSatisfy` BS8.isInfixOf "alice:<redacted>"
        renderedLogs `shouldNotSatisfy` BS8.isInfixOf "secret"
        renderedLogs `shouldNotSatisfy` BS8.isInfixOf "alice:secret"

    it "falls back instead of challenging when hidden auth is enabled" $ do
      response <- runProxyApp (httpProxy hiddenAuthSettings) rawProxyRequest
      simpleStatus response `shouldBe` fallbackStatus
      simpleBody response `shouldBe` fallbackBody

  describe "CONNECT proxy auth decisions" $ do
    it "challenges unauthorized CONNECT requests" $ do
      response <- runConnectApp authRequiredSettings connectRequest
      simpleStatus response `shouldBe` HT.status407
      lookup "Proxy-Authenticate" (simpleHeaders response) `shouldBe` Just "Basic realm=\"hprox\""

    it "falls back instead of challenging CONNECT requests when hidden auth is enabled" $ do
      response <- runConnectApp hiddenAuthSettings connectRequest
      simpleStatus response `shouldBe` fallbackStatus
      simpleBody response `shouldBe` fallbackBody

    it "establishes an HTTP/1 CONNECT tunnel after upstream connection succeeds" $
      withTcpEchoServer $ \targetPort ->
        Warp.testWithApplication (pure $ httpConnectProxy authorizedSettings fallback) $ \proxyPort -> do
          response <- rawConnectRoundTrip proxyPort targetPort
          response `shouldBe` "ping"
    it "returns 502 when upstream CONNECT fails before tunnel establishment" $ do
      port <- getFreePort
      response <- runConnectApp authorizedSettings (connectRequestFor "127.0.0.1" port HT.http11)
      simpleStatus response `shouldBe` HT.status502

    it "returns 502 for HTTP/2 CONNECT when upstream connection fails" $ do
      port <- getFreePort
      response <- runConnectApp authorizedSettings (connectRequestFor "127.0.0.1" port HT.http20)
      simpleStatus response `shouldBe` HT.status502

    it "does not log upstream failure after an HTTP/2 tunnel response starts" $
      withTcpEchoServer $ \targetPort -> do
        logs <- newIORef []
        let loggingSettings = authorizedSettings
              { logger = \_ msg -> modifyIORef' logs (LBS.fromStrict (FL.fromLogStr msg) :)
              }
            tunnelRequest = connectRequestFor "127.0.0.1" targetPort HT.http20
            failAfterResponse _ = ioError (userError "client stream closed after connect")
        result <- try (httpConnectProxy loggingSettings fallback tunnelRequest failAfterResponse) :: IO (Either SomeException ResponseReceived)
        case result of
          Left _  -> return ()
          Right _ -> expectationFailure "response failure was swallowed"
        renderedLogs <- LBS.toStrict . LBS.concat . reverse <$> readIORef logs
        renderedLogs `shouldNotSatisfy` BS8.isInfixOf "CONNECT upstream failure"

    it "does not log upstream failure when HTTP/2 cancels before the tunnel response starts" $ do
      logs <- newIORef []
      let loggingSettings = authorizedSettings
            { logger = \_ msg -> modifyIORef' logs (LBS.fromStrict (FL.fromLogStr msg) :)
            }
          cancelledBeforeResponse _ _ = throwIO ThreadKilled
          respondOk _ = ioError (userError "respond should not be called")
      result <- try (httpConnectProxyWith cancelledBeforeResponse loggingSettings fallback (connectRequestFor "127.0.0.1" 443 HT.http20) respondOk) :: IO (Either SomeException ResponseReceived)
      case result of
        Left _  -> return ()
        Right _ -> expectationFailure "pre-response cancellation was swallowed"
      renderedLogs <- LBS.toStrict . LBS.concat . reverse <$> readIORef logs
      renderedLogs `shouldNotSatisfy` BS8.isInfixOf "CONNECT upstream failure"

  describe "HTTP proxy fallback decisions" $
    it "falls through for non-proxy GET requests" $ do
      response <- runProxyApp (httpProxy authRequiredSettings) defaultRequest
      simpleStatus response `shouldBe` fallbackStatus
      simpleBody response `shouldBe` fallbackBody

  describe "HTTP proxy target parsing" $ do
    it "selects raw absolute-URI proxy targets with default ports" $
      selectHttpProxyTarget rawProxyRequest
        `shouldBe` Just HttpProxyTarget
          { httpProxyTargetHost = "example.com"
          , httpProxyTargetPort = 80
          , httpProxyTargetRawPath = "/resource"
          }

    it "preserves bracketed IPv6 proxy targets without explicit ports" $
      selectHttpProxyTarget rawProxyRequest { rawPathInfo = "http://[::1]/resource" }
        `shouldBe` Just HttpProxyTarget
          { httpProxyTargetHost = "[::1]"
          , httpProxyTargetPort = 80
          , httpProxyTargetRawPath = "/resource"
          }

    it "treats HTTP/2 proxy scheme values case-insensitively" $
      selectHttpProxyTarget http2ProxyRequest { requestHeaders = [("X-Scheme", "HTTP")] }
        `shouldBe` Just HttpProxyTarget
          { httpProxyTargetHost = "example.com"
          , httpProxyTargetPort = 80
          , httpProxyTargetRawPath = "/resource"
          }

    it "rejects malformed explicit ports in raw absolute-URI requests" $
      selectHttpProxyTarget rawProxyRequest { rawPathInfo = "http://example.com:not-a-port/resource" }
        `shouldBe` Nothing

    it "rejects malformed explicit ports in Host-header proxy requests" $
      selectHttpProxyTarget hostHeaderProxyRequest { requestHeaderHost = Just "example.com:not-a-port" }
        `shouldBe` Nothing

    it "rejects HTTP/2 proxy requests without a Host header" $
      selectHttpProxyTarget http2ProxyRequest { requestHeaderHost = Nothing }
        `shouldBe` Nothing

    it "renders HTTP proxy authorities without the default port" $ do
      renderHttpProxyAuthority (HttpProxyTarget "example.com" 80 "/")
        `shouldBe` "example.com"
      renderHttpProxyAuthority (HttpProxyTarget "example.com" 443 "/")
        `shouldBe` "example.com:443"
      renderHttpProxyAuthority (HttpProxyTarget "example.com" 8080 "/")
        `shouldBe` "example.com:8080"

  describe "HTTP proxy upstream requests" $
    it "uses absolute URI authority for Host and strips proxy boundary headers" $
      Warp.testWithApplication (pure observeProxyRequestApp) $ \port -> do
        let authority = "127.0.0.1:" <> BS8.pack (show port)
            responseBody = LBS8.lines . simpleBody
            proxiedReq = defaultRequest
              { requestMethod = "GET"
              , rawPathInfo = "http://" <> authority <> "/resource"
              , requestHeaderHost = Just "evil.example"
              , requestHeaders =
                  [ (HT.hHost, "evil.example")
                  , (HT.hProxyAuthorization, "Basic " <> B64.encode "alice:secret")
                  , ("Forwarded", "for=203.0.113.7")
                  , ("X-Forwarded-For", "203.0.113.7")
                  , ("Proxy-Connection", "keep-alive")
                  , ("cf-ray", "abc123")
                  , ("X-Real-IP", "203.0.113.7")
                  , ("User-Agent", "hprox-test")
                  ]
              }
        response <- runProxyApp (httpProxy authorizedSettings) proxiedReq
        simpleStatus response `shouldBe` HT.status200
        responseBody response
          `shouldBe` [ LBS.fromStrict authority
                     , LBS.fromStrict authority
                     , "False"
                     , "False"
                     , "False"
                     , "False"
                     , "False"
                     , "True"
                     ]
runProxyApp :: (HC.Manager -> Middleware) -> Request -> IO SResponse

runProxyApp middleware req = do
  manager <- HC.newManager HC.defaultManagerSettings
  runSession (srequest $ SRequest req "") (middleware manager fallback)

runConnectApp :: ProxySettings -> Request -> IO SResponse
runConnectApp pset req = runSession (srequest $ SRequest req "") (httpConnectProxy pset fallback)

rawProxyRequest :: Request
rawProxyRequest = defaultRequest
  { requestMethod = "GET"
  , rawPathInfo = "http://example.com/resource"
  , requestHeaderHost = Just "example.com"
  }

rawProxyRequestWithAuth :: BS8.ByteString -> Request
rawProxyRequestWithAuth auth = rawProxyRequest
  { requestHeaders = [(HT.hProxyAuthorization, auth)]
  }

proxyRequestForPort :: Int -> BS8.ByteString -> Request
proxyRequestForPort port auth =
  let authority = "127.0.0.1:" <> BS8.pack (show port)
  in (rawProxyRequestWithAuth auth)
       { rawPathInfo = "http://" <> authority <> "/resource"
       , requestHeaderHost = Just authority
       }

basicAliceSecret :: BS8.ByteString
basicAliceSecret = basicCredential "alice:secret"

basicCredential :: BS8.ByteString -> BS8.ByteString
basicCredential credential = "Basic " <> B64.encode credential

hostHeaderProxyRequest :: Request
hostHeaderProxyRequest = defaultRequest
  { requestMethod = "GET"
  , rawPathInfo = "/resource"
  , requestHeaderHost = Just "example.com"
  , requestHeaders = [("Proxy-Connection", "keep-alive")]
  }

http2ProxyRequest :: Request
http2ProxyRequest = defaultRequest
  { requestMethod = "GET"
  , rawPathInfo = "/resource"
  , requestHeaderHost = Just "example.com"
  , requestHeaders = [("X-Scheme", "http")]
  , httpVersion = HT.http20
  , isSecure = True
  }

connectRequest :: Request
connectRequest = defaultRequest
  { requestMethod = "CONNECT"
  , rawPathInfo = "example.com:443"
  , requestHeaderHost = Just "example.com:443"
  }

connectRequestFor :: BS8.ByteString -> Int -> HT.HttpVersion -> Request
connectRequestFor host port version =
  let authority = host <> ":" <> BS8.pack (show port)
  in connectRequest
       { rawPathInfo = authority
       , requestHeaderHost = Just authority
       , requestHeaders = [(HT.hProxyAuthorization, basicAliceSecret)]
       , httpVersion = version
       }

getFreePort :: IO Int
getFreePort = bracket open close socketPortInt
  where
    open = do
      sock <- socket AF_INET Stream defaultProtocol
      setSocketOption sock ReuseAddr 1
      bind sock (SockAddrInet 0 loopbackAddress)
      return sock

    socketPortInt sock = fromIntegral <$> socketPort sock

withTcpEchoServer :: (Int -> IO a) -> IO a
withTcpEchoServer action = bracket open close run
  where
    open = do
      sock <- socket AF_INET Stream defaultProtocol
      setSocketOption sock ReuseAddr 1
      bind sock (SockAddrInet 0 loopbackAddress)
      listen sock 1
      return sock

    run sock =
      withAsync (bracket (fst <$> accept sock) close echoLoop) $ \_ ->
        action . fromIntegral =<< socketPort sock

    echoLoop conn = do
      bs <- SocketBS.recv conn 4096
      unless (BS8.null bs) $ do
        SocketBS.sendAll conn bs
        echoLoop conn

rawConnectRoundTrip :: Int -> Int -> IO BS8.ByteString
rawConnectRoundTrip proxyPort targetPort = bracket open close $ \sock -> do
  SocketBS.sendAll sock $ BS8.concat
    [ "CONNECT 127.0.0.1:"
    , BS8.pack (show targetPort)
    , " HTTP/1.1\r\nHost: 127.0.0.1:"
    , BS8.pack (show targetPort)
    , "\r\nProxy-Authorization: "
    , basicAliceSecret
    , "\r\n\r\n"
    ]
  response <- SocketBS.recv sock 4096
  response `shouldSatisfy` BS8.isPrefixOf "HTTP/1.1 200"
  SocketBS.sendAll sock "ping"
  SocketBS.recv sock 4096
  where
    open = do
      sock <- socket AF_INET Stream defaultProtocol
      connect sock (SockAddrInet (fromIntegral proxyPort) loopbackAddress)
      return sock

loopbackAddress :: HostAddress
loopbackAddress = tupleToHostAddress (127, 0, 0, 1)

fallback :: Application
fallback _req respond = respond $ responseLBS fallbackStatus [("Content-Type", "text/plain")] fallbackBody

fallbackStatus :: HT.Status
fallbackStatus = HT.mkStatus 418 "fallback"

fallbackBody :: LBS.ByteString
fallbackBody = "fallback"

authRequiredSettings :: ProxySettings
authRequiredSettings = baseSettings
  { proxyAuth = Just (const False)
  }

hiddenAuthSettings :: ProxySettings
hiddenAuthSettings = authRequiredSettings
  { hideProxyAuth = True
  }

authorizedSettings :: ProxySettings
authorizedSettings = baseSettings
  { proxyAuth = Just (== "alice:secret")
  }

baseSettings :: ProxySettings
baseSettings = ProxySettings
  { proxyAuth      = Nothing
  , passPrompt     = Just "hprox"
  , wsRemote       = Nothing
  , revRemoteMap   = []
  , hideProxyAuth  = False
  , naivePadding   = False
  , acmeThumbprint = Nothing
  , logger         = \_ _ -> return ()
  }

observeProxyRequestApp :: Application
observeProxyRequestApp req respond = respond $ responseLBS
  HT.status200
  [("Content-Type", "text/plain")]
  (LBS8.unlines
     [ LBS.fromStrict $ maybe "" id $ requestHeaderHost req
     , LBS.fromStrict $ maybe "" id $ lookup HT.hHost (requestHeaders req)
     , LBS8.pack $ show $ hasHeader HT.hProxyAuthorization
     , LBS8.pack $ show $ hasHeader "Forwarded"
     , LBS8.pack $ show $ hasHeader "X-Forwarded-For"
     , LBS8.pack $ show $ hasHeader "Proxy-Connection"
     , LBS8.pack $ show $ hasHeader "cf-ray"
     , LBS8.pack $ show $ hasHeader "User-Agent"
     ])
  where
    hasHeader name = lookup name (requestHeaders req) /= Nothing