hprox-0.7.1: 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 ()
}