packages feed

hprox-0.7.2: 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              (forkIO)
import Control.Concurrent.Async        (withAsync)
import Control.Concurrent.MVar         (newEmptyMVar, putMVar, takeMVar)
import Control.Exception
    (AsyncException(..), IOException, SomeException, bracket, bracketOnError, finally, throwIO, try)
import Control.Monad                   (unless)
import Data.ByteString                 qualified as BS
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.CaseInsensitive            qualified as CI
import Data.IORef
import Data.Maybe                      (fromMaybe, mapMaybe)
import Data.Streaming.Network          qualified as CN
import Data.Streaming.Network.Internal (AppData(..))
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.Internal            (Response(..), ResponseReceived(..))
import Network.Wai.Test
import System.Log.FastLogger           qualified as FL
import System.Timeout                  (timeout)

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" $
            withRawHttpServer (const "ok") $ \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" $
            withRawHttpServer (const "ok") $ \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" $ do
            response <- rawConnectRoundTrip
            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" $
            withRawHttpServer observeRawProxyRequestBody $ \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"
                    ]

-- Keep proxy upstream tests independent of Warp's Windows accept loop.
withRawHttpServer :: (BS.ByteString -> LBS.ByteString) -> (Int -> IO a) -> IO a
withRawHttpServer responseBody action =
    bracket open cleanup run
  where
    open = do
        accepted <- newIORef Nothing
        sock <- openTcpServerSocket
        return (sock, accepted)

    cleanup (sock, accepted) = do
        closeIgnoringErrors sock
        readIORef accepted >>= mapM_ closeIgnoringErrors

    run server@(sock, _) = do
        done <- newEmptyMVar
        _ <- forkIO $ try (acceptAndRespond server) >>= putMVar done
        result <- action . fromIntegral =<< socketPort sock
        serverResult <- timeout 2000000 (takeMVar done)
        case serverResult of
            Just (Left ex) -> throwIO (ex :: SomeException)
            _              -> return result

    acceptAndRespond (sock, accepted) =
        bracket (acceptClient sock accepted) closeIgnoringErrors $ \conn -> do
            requestBytes <- recvHttpRequest conn
            sendHttpResponse conn $ responseBody requestBytes

    acceptClient sock accepted = do
        (conn, _) <- accept sock
        writeIORef accepted (Just conn)
        return conn

openTcpServerSocket :: IO Socket
openTcpServerSocket = bracketOnError open closeIgnoringErrors $ \sock -> do
    setSocketOption sock ReuseAddr 1
    bind sock (SockAddrInet 0 loopbackAddress)
    listen sock (max 2048 maxListenQueue)
    return sock
  where
    open = socket AF_INET Stream defaultProtocol

recvHttpRequest :: Socket -> IO BS.ByteString
recvHttpRequest conn = go BS.empty
  where
    go received
        | "\r\n\r\n" `BS.isInfixOf` received = return received
        | BS.length received > 65536         = ioError $ userError "upstream HTTP request headers exceeded test limit"
        | otherwise                          = do
            chunk <- recvWithTimeout conn
            if BS.null chunk
            then ioError $ userError "upstream HTTP request ended before headers completed"
            else go (received <> chunk)

sendHttpResponse :: Socket -> LBS.ByteString -> IO ()
sendHttpResponse conn body =
    let strictBody = LBS.toStrict body
    in SocketBS.sendAll conn $ BS8.concat
        [ "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: "
        , BS8.pack (show $ BS.length strictBody)
        , "\r\nConnection: close\r\n\r\n"
        , strictBody
        ]

closeIgnoringErrors :: Socket -> IO ()
closeIgnoringErrors sock = do
    closed <- try (close sock) :: IO (Either IOException ())
    case closed of
        Left _   -> return ()
        Right () -> return ()

observeRawProxyRequestBody :: BS.ByteString -> LBS.ByteString
observeRawProxyRequestBody requestBytes =
    LBS8.unlines
        [ LBS.fromStrict $ headerValue HT.hHost
        , LBS.fromStrict $ headerValue HT.hHost
        , 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
    headers = mapMaybe parseHeader $ takeWhile (not . BS.null) $ drop 1 $ map trimCR $ BS8.lines requestBytes
    normalizedHeaders = [(CI.mk name, value) | (name, value) <- headers]

    headerValue name = fromMaybe "" $ lookup name normalizedHeaders
    hasHeader name = lookup name normalizedHeaders /= Nothing

    parseHeader line =
        let (name, rest) = BS8.break (== ':') line
        in if BS.null rest
           then Nothing
           else Just (name, BS8.dropWhile (== ' ') $ BS.drop 1 rest)

    trimCR line
        | "\r" `BS.isSuffixOf` line = BS.init line
        | otherwise                 = line

runProxyApp :: (HC.Manager -> Middleware) -> Request -> IO SResponse
runProxyApp middleware req = do
    manager <- HC.newManager $
        HC.managerSetProxy HC.noProxy HC.defaultManagerSettings
            { HC.managerIdleConnectionCount = 0
            , HC.managerResponseTimeout     = HC.responseTimeoutMicro 2000000
            }
    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 = do
    accepted <- newIORef Nothing
    bracket (open accepted) cleanup run
  where
    open accepted = do
        sock <- openTcpServerSocket
        return (sock, accepted)

    cleanup (sock, accepted) = do
        closeIgnoringErrors sock
        readIORef accepted >>= mapM_ closeIgnoringErrors

    run server@(sock, _) =
        withAsync (acceptAndEcho server) $ \_ ->
            (action . fromIntegral =<< socketPort sock) `finally` cleanup server

    acceptAndEcho (sock, accepted) =
        bracket (acceptClient sock accepted) closeIgnoringErrors echoLoop

    acceptClient sock accepted = do
        (conn, _) <- accept sock
        writeIORef accepted (Just conn)
        return conn

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

-- Exercise the HTTP/1 raw response path without running a nested Warp server.
rawConnectRoundTrip :: IO BS8.ByteString
rawConnectRoundTrip = do
    outbound <- newIORef []
    clientChunks <- newIORef ["ping", ""]
    let targetHost = "127.0.0.1"
        targetPort = 18443
        runTCPClient settings app = do
            CN.getHost settings `shouldBe` targetHost
            CN.getPort settings `shouldBe` targetPort
            inbound <- newEmptyMVar
            app AppData
                { appRead'            = takeMVar inbound
                , appWrite'           = \bytes -> do
                    putMVar inbound bytes
                    putMVar inbound BS.empty
                , appSockAddr'        = SockAddrInet 0 loopbackAddress
                , appLocalAddr'       = Nothing
                , appCloseConnection' = return ()
                , appRawSocket'       = Nothing
                }
        nextClientChunk =
            atomicModifyIORef' clientChunks $ \chunks ->
                case chunks of
                    []     -> ([], BS.empty)
                    x : xs -> (xs, x)
        respondRaw (ResponseRaw raw _) = do
            raw nextClientChunk (\bytes -> modifyIORef' outbound (bytes :))
            return ResponseReceived
        respondRaw _ = expectationFailure "expected HTTP/1 raw CONNECT response" >> return ResponseReceived
    _ <- httpConnectProxyWith runTCPClient authorizedSettings fallback (connectRequestFor targetHost targetPort HT.http11) respondRaw
    sent <- BS.concat . reverse <$> readIORef outbound
    sent `shouldSatisfy` BS8.isPrefixOf "HTTP/1.1 200"
    case BS.breakSubstring "\r\n\r\n" sent of
        (_, body) | not (BS.null body) -> return $ BS.drop 4 body
        _                             -> expectationFailure "raw CONNECT response did not contain an HTTP header terminator" >> return BS.empty

recvWithTimeout :: Socket -> IO BS8.ByteString
recvWithTimeout sock = do
    received <- timeout 2000000 (SocketBS.recv sock 4096)
    case received of
        Just bytes -> return bytes
        Nothing    -> ioError $ userError "timed out waiting for proxy socket response"

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 ()
    }