fix: close audit-pass-1 CRITICAL findings (Findings 1-6)

Closes the six CRITICAL findings from the 2026-04-29 audit:

- Tunnel.hs (Findings 1+2+4):
  - connectToBackend now returns Either ConnectError Socket with
    proper handling of resolution failures, connect timeouts, and
    connect errors. Replaces the partial `error` call.
  - parseUpgradeStatus uses safe pattern matching on BS8.lines
    rather than `head $ BS8.lines` (closes the -Wx-partial warning).
  - receiveUpgradeResponse is bounded at 16 KB and uses a tight
    inner loop with strict accumulator (closes the unbounded-read
    DoS). Adds a 30s timeout via System.Timeout.timeout.
  - Adds attemptConnect with bracketOnError to ensure socket is
    closed on partial-failure paths.
  - File header + remove inline comments.

- TLS.hs (Findings 1+15):
  - loadCredentials replaced with credentialsOrDefault that
    returns empty credentials and logs to stderr on failure
    rather than crashing the SNI handler with `error`.
  - Cipher names updated to non-deprecated forms
    (cipher13_AES_128_GCM_SHA256 etc.) to clear -Wdeprecations.
  - File header + remove inline comments.

- Connection.hs (supporting Finding 3):
  - File header + add microsPerSecond and httpOkStatusCode named
    constants. Add tcUpstreamReadSeconds (default 30) to
    TimeoutConfig.

- Proxy.hs (Findings 1+3):
  - All five `error` calls replaced with hPutStrLn stderr +
    exitFailure (matching Main.hs's pattern).
  - forwardRequest wraps withResponse in System.Timeout.timeout
    sized off Connection.tcUpstreamReadSeconds. Returns 504 when
    upstream does not respond within budget.

- Main.hs (supporting Finding 3):
  - HTTP client Manager now configured with managerResponseTimeout
    set to tcUpstreamReadSeconds * microsPerSecond. Belt-and-
    suspenders with the application-layer timeout in Proxy.hs.

- ML/Loader.hs (Finding 5):
  - Added maxNumLeaves (4096) and maxNumTrees (10000) constants.
  - finalizeTree rejects num_leaves <= 0 and num_leaves > maxNumLeaves
    BEFORE allocating SoA arrays. Closes a crafted-model-file DoS.
  - runTrees threads a tree counter and rejects when the parsed
    count would exceed maxNumTrees.

- WAF/Rule.hs (Finding 6):
  - CompiledRegex changed from newtype to data with an extra
    !ByteString field carrying the original pattern.
  - Eq instance now compares by stored pattern bytes; reflexivity
    holds (x == x is True). Show instance now reveals the pattern
    for debugging.

- test/Spec.hs:
  - +2 WAF tests verifying Eq CompiledRegex reflexivity and
    pattern-distinguishing.
  - +2 ML.Loader tests verifying num_leaves bound rejection
    (above maxNumLeaves and at zero).

358 total examples passing, 0 failures, 0 GHC warnings on touched
modules. Audit findings 7-32 (MAJOR systemic + remaining MAJOR +
MINOR) are deferred to subsequent passes per the audit plan.
This commit is contained in:
CarterPerez-dev 2026-04-29 01:59:36 -04:00
parent d3dd5f2dcb
commit 2c7e743f57
8 changed files with 446 additions and 234 deletions

View File

@ -3,8 +3,18 @@
module Main (main) where module Main (main) where
import Aenebris.Config import Aenebris.Config
import Aenebris.Connection
( defaultTimeoutConfig
, microsPerSecond
, tcUpstreamReadSeconds
)
import Aenebris.Proxy import Aenebris.Proxy
import Network.HTTP.Client (newManager, defaultManagerSettings) import Network.HTTP.Client
( ManagerSettings(..)
, defaultManagerSettings
, newManager
, responseTimeoutMicro
)
import System.Environment (getArgs) import System.Environment (getArgs)
import System.Exit (exitFailure) import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr) import System.IO (hPutStrLn, stderr)
@ -37,8 +47,12 @@ main = do
Right () -> do Right () -> do
putStrLn "Configuration loaded and validated successfully" putStrLn "Configuration loaded and validated successfully"
-- Create HTTP client manager with connection pooling let upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig
manager <- newManager defaultManagerSettings * microsPerSecond
managerSettings = defaultManagerSettings
{ managerResponseTimeout = responseTimeoutMicro upstreamMicros
}
manager <- newManager managerSettings
-- Initialize proxy state (load balancers + health checkers) -- Initialize proxy state (load balancers + health checkers)
proxyState <- initProxyState config manager proxyState <- initProxyState config manager

View File

@ -1,3 +1,8 @@
{-
©AngelaMos | 2026
Connection.hs
-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
@ -10,14 +15,14 @@ module Aenebris.Connection
, isWebSocketUpgrade , isWebSocketUpgrade
, isStreamingResponse , isStreamingResponse
, getTimeout , getTimeout
, microsPerSecond
, httpOkStatusCode
) where ) where
import Data.ByteString (ByteString) import Data.ByteString (ByteString)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import Data.Maybe (isJust, fromMaybe) import Data.Maybe (isJust)
import Network.HTTP.Types (HeaderName, Status, statusCode) import Network.HTTP.Types (HeaderName, Status, statusCode)
data ConnectionState data ConnectionState
@ -35,23 +40,31 @@ data ConnectionType
| ChunkedStream | ChunkedStream
deriving (Eq, Show) deriving (Eq, Show)
microsPerSecond :: Int
microsPerSecond = 1_000_000
httpOkStatusCode :: Int
httpOkStatusCode = 200
data TimeoutConfig = TimeoutConfig data TimeoutConfig = TimeoutConfig
{ tcHttpIdle :: Int { tcHttpIdle :: !Int
, tcWebSocketTunnel :: Int , tcWebSocketTunnel :: !Int
, tcStreamingResponse :: Int , tcStreamingResponse :: !Int
, tcProxyPingInterval :: Int , tcProxyPingInterval :: !Int
, tcPongTimeout :: Int , tcPongTimeout :: !Int
, tcConnectTimeout :: Int , tcConnectTimeout :: !Int
, tcUpstreamReadSeconds :: !Int
} }
defaultTimeoutConfig :: TimeoutConfig defaultTimeoutConfig :: TimeoutConfig
defaultTimeoutConfig = TimeoutConfig defaultTimeoutConfig = TimeoutConfig
{ tcHttpIdle = 60 { tcHttpIdle = 60
, tcWebSocketTunnel = 3600 , tcWebSocketTunnel = 3600
, tcStreamingResponse = 3600 , tcStreamingResponse = 3600
, tcProxyPingInterval = 30 , tcProxyPingInterval = 30
, tcPongTimeout = 10 , tcPongTimeout = 10
, tcConnectTimeout = 5 , tcConnectTimeout = 5
, tcUpstreamReadSeconds = 30
} }
getTimeout :: TimeoutConfig -> ConnectionState -> Int getTimeout :: TimeoutConfig -> ConnectionState -> Int
@ -96,4 +109,7 @@ isStreamingResponse status headers =
hasContentLength = isJust (lookup "Content-Length" headers) hasContentLength = isJust (lookup "Content-Length" headers)
isUnknownLength = statusCode status == 200 && not hasContentLength && not hasTransferEncodingChunked isUnknownLength =
statusCode status == httpOkStatusCode
&& not hasContentLength
&& not hasTransferEncodingChunked

View File

@ -44,6 +44,12 @@ requiredNumClass = 1
requiredNumTreePerIteration :: Int requiredNumTreePerIteration :: Int
requiredNumTreePerIteration = 1 requiredNumTreePerIteration = 1
maxNumLeaves :: Int
maxNumLeaves = 4096
maxNumTrees :: Int
maxNumTrees = 10000
postParseLineSentinel :: Int postParseLineSentinel :: Int
postParseLineSentinel = -1 postParseLineSentinel = -1
@ -355,18 +361,26 @@ requireField key mv = case mv of
("Missing required header key: " <> key)) ("Missing required header key: " <> key))
runTrees :: [(Int, Text)] -> Either ParseError [Tree] runTrees :: [(Int, Text)] -> Either ParseError [Tree]
runTrees lns = case dropWhile (lineIsBlank . snd) lns of runTrees = goTrees 0
[] -> Right [] where
((n, ln):rest) goTrees :: Int -> [(Int, Text)] -> Either ParseError [Tree]
| T.strip ln == endOfTreesMarker -> Right [] goTrees treeCount lns
| T.isPrefixOf baseTreeKeyPrefix (T.strip ln) -> do | treeCount > maxNumTrees = Left
let (block, after) = break (lineIsBlank . snd) rest (ParseError postParseLineSentinel treeKey
tree <- parseTreeBlock block ("Tree count exceeds maxNumTrees "
more <- runTrees after <> T.pack (show maxNumTrees)))
Right (tree : more) | otherwise = case dropWhile (lineIsBlank . snd) lns of
| otherwise -> Left [] -> Right []
(ParseError n treeKey ((n, ln):rest)
("Expected 'Tree=N' or 'end of trees', got: " <> ln)) | T.strip ln == endOfTreesMarker -> Right []
| T.isPrefixOf baseTreeKeyPrefix (T.strip ln) -> do
let (block, after) = break (lineIsBlank . snd) rest
tree <- parseTreeBlock block
more <- goTrees (treeCount + 1) after
Right (tree : more)
| otherwise -> Left
(ParseError n treeKey
("Expected 'Tree=N' or 'end of trees', got: " <> ln))
parseTreeBlock :: [(Int, Text)] -> Either ParseError Tree parseTreeBlock :: [(Int, Text)] -> Either ParseError Tree
parseTreeBlock block = do parseTreeBlock block = do
@ -428,6 +442,14 @@ assignTreeField acc n key val
finalizeTree :: TreeAcc -> Either ParseError Tree finalizeTree :: TreeAcc -> Either ParseError Tree
finalizeTree acc = do finalizeTree acc = do
nL <- requireField keyNumLeaves (taNumLeaves acc) nL <- requireField keyNumLeaves (taNumLeaves acc)
unless (nL > 0)
(Left (ParseError postParseLineSentinel keyNumLeaves
("num_leaves must be positive: " <> T.pack (show nL))))
unless (nL <= maxNumLeaves)
(Left (ParseError postParseLineSentinel keyNumLeaves
("num_leaves " <> T.pack (show nL)
<> " exceeds maxNumLeaves "
<> T.pack (show maxNumLeaves))))
nC <- requireField keyNumCat (taNumCat acc) nC <- requireField keyNumCat (taNumCat acc)
leafValues <- requireField keyLeafValue (taLeafValue acc) leafValues <- requireField keyLeafValue (taLeafValue acc)
unless (length leafValues == nL) unless (length leafValues == nL)

View File

@ -13,6 +13,12 @@ module Aenebris.Proxy
import Aenebris.Backend import Aenebris.Backend
import Aenebris.Config import Aenebris.Config
import Aenebris.Connection import Aenebris.Connection
( ConnectionType(..)
, defaultTimeoutConfig
, detectConnectionType
, microsPerSecond
, tcUpstreamReadSeconds
)
import Aenebris.HealthCheck import Aenebris.HealthCheck
import Aenebris.LoadBalancer import Aenebris.LoadBalancer
import Aenebris.TLS import Aenebris.TLS
@ -93,7 +99,9 @@ import Network.Wai.Handler.Warp
, setTimeout , setTimeout
) )
import Network.Wai.Handler.WarpTLS (runTLS) import Network.Wai.Handler.WarpTLS (runTLS)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr) import System.IO (hPutStrLn, stderr)
import System.Timeout (timeout)
import Data.ByteString (ByteString) import Data.ByteString (ByteString)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Char8 as BS8
@ -215,7 +223,9 @@ startProxy ProxyState{..} = do
putStrLn $ "Health checking enabled for all upstreams" putStrLn $ "Health checking enabled for all upstreams"
case configListen psConfig of case configListen psConfig of
[] -> error "No listen ports configured" [] -> do
hPutStrLn stderr "ERROR: No listen ports configured"
exitFailure
listenConfigs -> do listenConfigs -> do
case psRateLimiter of case psRateLimiter of
Just _ -> putStrLn "Rate limiting enabled" Just _ -> putStrLn "Rate limiting enabled"
@ -339,9 +349,9 @@ launchHTTPS port tlsConfig app = do
tlsResult <- createTLSSettings certFile keyFile tlsResult <- createTLSSettings certFile keyFile
case tlsResult of case tlsResult of
Left err -> do Left err -> do
hPutStrLn stderr $ "ERROR: Failed to load TLS certificate" hPutStrLn stderr "ERROR: Failed to load TLS certificate"
hPutStrLn stderr $ " " ++ show err hPutStrLn stderr $ " " ++ show err
error "TLS configuration error" exitFailure
Right tlsSettings -> do Right tlsSettings -> do
let warpSettings = defaultSettings & setPort port let warpSettings = defaultSettings & setPort port
@ -352,7 +362,9 @@ launchHTTPS port tlsConfig app = do
putStrLn $ " └─ Strong cipher suites enforced" putStrLn $ " └─ Strong cipher suites enforced"
runTLS tlsSettings warpSettings app runTLS tlsSettings warpSettings app
_ -> error "TLS configuration error: cert and key required" _ -> do
hPutStrLn stderr "ERROR: TLS configuration requires both cert and key"
exitFailure
-- | Launch HTTPS server with SNI support (multiple certificates) -- | Launch HTTPS server with SNI support (multiple certificates)
launchHTTPSWithSNI :: Int -> TLSConfig -> Application -> IO () launchHTTPSWithSNI :: Int -> TLSConfig -> Application -> IO ()
@ -366,9 +378,9 @@ launchHTTPSWithSNI port tlsConfig app = do
tlsResult <- createSNISettings domainList defaultCert defaultKey tlsResult <- createSNISettings domainList defaultCert defaultKey
case tlsResult of case tlsResult of
Left err -> do Left err -> do
hPutStrLn stderr $ "ERROR: Failed to load SNI certificates" hPutStrLn stderr "ERROR: Failed to load SNI certificates"
hPutStrLn stderr $ " " ++ show err hPutStrLn stderr $ " " ++ show err
error "SNI configuration error" exitFailure
Right tlsSettings -> do Right tlsSettings -> do
let warpSettings = defaultSettings & setPort port let warpSettings = defaultSettings & setPort port
@ -381,7 +393,10 @@ launchHTTPSWithSNI port tlsConfig app = do
putStrLn $ " └─ Strong cipher suites enforced" putStrLn $ " └─ Strong cipher suites enforced"
runTLS tlsSettings warpSettings app runTLS tlsSettings warpSettings app
_ -> error "SNI configuration error: sni, default_cert, and default_key required" _ -> do
hPutStrLn stderr
"ERROR: SNI requires sni, default_cert, and default_key"
exitFailure
-- | Main proxy application (WAI) -- | Main proxy application (WAI)
proxyApp :: Config -> Map Text LoadBalancer -> Manager -> Application proxyApp :: Config -> Map Text LoadBalancer -> Manager -> Application
@ -525,25 +540,34 @@ forwardRequest manager clientReq backendHost respond = do
, HTTP.requestBody = streamingBody , HTTP.requestBody = streamingBody
} }
withResponse backendReq manager $ \backendResponse -> do let upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig
let status = HTTP.responseStatus backendResponse * microsPerSecond
headers = HTTP.responseHeaders backendResponse mResult <- timeout upstreamMicros $
bodyReader = HTTP.responseBody backendResponse withResponse backendReq manager $ \backendResponse -> do
let status = HTTP.responseStatus backendResponse
headers = HTTP.responseHeaders backendResponse
bodyReader = HTTP.responseBody backendResponse
if shouldStreamResponse headers if shouldStreamResponse headers
then do then do
hPutStrLn stderr "[STREAM] Streaming response detected" hPutStrLn stderr "[STREAM] Streaming response detected"
respond $ responseStream status (filterResponseHeaders headers) $ \write flush -> do respond $ responseStream status (filterResponseHeaders headers) $ \write flush -> do
let loop = do let loop = do
chunk <- brRead bodyReader chunk <- brRead bodyReader
unless (BS.null chunk) $ do unless (BS.null chunk) $ do
write (byteString chunk) write (byteString chunk)
flush flush
loop loop
loop loop
else do else do
body <- readFullBody bodyReader body <- readFullBody bodyReader
respond $ responseLBS status (filterResponseHeaders headers) body respond $ responseLBS status (filterResponseHeaders headers) body
case mResult of
Just rr -> return rr
Nothing -> respond $ responseLBS
status504
[("Content-Type", "text/plain")]
"504 Gateway Timeout: upstream did not respond in time"
shouldStreamResponse :: [(HeaderName, BS.ByteString)] -> Bool shouldStreamResponse :: [(HeaderName, BS.ByteString)] -> Bool
shouldStreamResponse headers = shouldStreamResponse headers =

View File

@ -1,3 +1,7 @@
{-
©AngelaMos | 2026
TLS.hs
-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
@ -8,23 +12,27 @@ module Aenebris.TLS
, createSNISettings , createSNISettings
, validateCertificate , validateCertificate
, CertificateError(..) , CertificateError(..)
, strongCipherSuites
) where ) where
import qualified Data.ByteString as BS import Control.Exception (SomeException, try)
import qualified Data.ByteString.Lazy as LBS
import Data.Default.Class (def)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Map.Strict as Map
import Network.Wai.Handler.WarpTLS
import qualified Network.TLS as TLS
import qualified Network.TLS.Extra.Cipher as Cipher
import Data.Default.Class (def)
import Data.X509 (SignedCertificate) import Data.X509 (SignedCertificate)
import Data.X509.File (readSignedObject) import Data.X509.File (readSignedObject)
import qualified Network.TLS as TLS
import qualified Network.TLS.Extra.Cipher as Cipher
import Network.Wai.Handler.WarpTLS
import System.Directory (doesFileExist) import System.Directory (doesFileExist)
import Control.Exception (try, SomeException) import System.IO (hPutStrLn, stderr)
httpsRequiredMessage :: LBS.ByteString
httpsRequiredMessage = "This server requires HTTPS"
-- | Certificate loading errors
data CertificateError data CertificateError
= CertFileNotFound FilePath = CertFileNotFound FilePath
| KeyFileNotFound FilePath | KeyFileNotFound FilePath
@ -32,130 +40,128 @@ data CertificateError
| InvalidKey FilePath String | InvalidKey FilePath String
deriving (Show, Eq) deriving (Show, Eq)
-- | Create TLS settings for a single certificate (non-SNI) createTLSSettings
createTLSSettings :: FilePath -> FilePath -> IO (Either CertificateError TLSSettings) :: FilePath
-> FilePath
-> IO (Either CertificateError TLSSettings)
createTLSSettings certFile keyFile = do createTLSSettings certFile keyFile = do
-- Validate files exist
certExists <- doesFileExist certFile certExists <- doesFileExist certFile
keyExists <- doesFileExist keyFile keyExists <- doesFileExist keyFile
if not certExists if not certExists
then return $ Left (CertFileNotFound certFile) then pure (Left (CertFileNotFound certFile))
else if not keyExists else if not keyExists
then return $ Left (KeyFileNotFound keyFile) then pure (Left (KeyFileNotFound keyFile))
else do else do
-- Try to load the credential to validate it
result <- try $ TLS.credentialLoadX509 certFile keyFile result <- try $ TLS.credentialLoadX509 certFile keyFile
case result of case result of
Left (err :: SomeException) -> Left (err :: SomeException) ->
return $ Left (InvalidCertificate certFile (show err)) pure (Left (InvalidCertificate certFile (show err)))
Right (Left err) -> Right (Left err) ->
return $ Left (InvalidCertificate certFile err) pure (Left (InvalidCertificate certFile err))
Right (Right _) ->
pure (Right (configureTLS certFile keyFile))
Right (Right _credential) -> do configureTLS :: FilePath -> FilePath -> TLSSettings
-- Create TLS settings with strong security configureTLS certFile keyFile = (tlsSettings certFile keyFile)
let tlsConfig = (tlsSettings certFile keyFile) { tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12] , tlsCiphers = strongCipherSuites
, tlsCiphers = strongCipherSuites , onInsecure = DenyInsecure httpsRequiredMessage
, onInsecure = DenyInsecure "This server requires HTTPS" }
}
return $ Right tlsConfig createSNISettings
:: [(Text, FilePath, FilePath)]
-- | Create TLS settings with SNI support for multiple domains -> FilePath
createSNISettings :: [(Text, FilePath, FilePath)] -> FilePath -> FilePath -> IO (Either CertificateError TLSSettings) -> FilePath
-> IO (Either CertificateError TLSSettings)
createSNISettings domains defaultCert defaultKey = do createSNISettings domains defaultCert defaultKey = do
-- Validate default certificate defaultCertOk <- doesFileExist defaultCert
defaultExists <- doesFileExist defaultCert defaultKeyOk <- doesFileExist defaultKey
defaultKeyExists <- doesFileExist defaultKey if not defaultCertOk
then pure (Left (CertFileNotFound defaultCert))
if not defaultExists else if not defaultKeyOk
then return $ Left (CertFileNotFound defaultCert) then pure (Left (KeyFileNotFound defaultKey))
else if not defaultKeyExists
then return $ Left (KeyFileNotFound defaultKey)
else do else do
-- Validate all domain certificates exist validations <- mapM validateDomainCert domains
validationResults <- mapM validateDomainCert domains case sequence validations of
case sequence validationResults of Left err -> pure (Left err)
Left err -> return $ Left err Right _ -> pure (Right (configureSNI domains defaultCert defaultKey))
Right _ -> do
-- Create SNI-enabled TLS settings using the default cert first
let baseTLS = tlsSettings defaultCert defaultKey
tlsConfig = baseTLS
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsCiphers = strongCipherSuites
, onInsecure = DenyInsecure "This server requires HTTPS"
, tlsServerHooks = def
{ TLS.onServerNameIndication = \mHostname -> case mHostname of
Nothing -> loadCredentials defaultCert defaultKey
Just hostname -> sniCallback domains defaultCert defaultKey hostname
}
}
return $ Right tlsConfig configureSNI
where :: [(Text, FilePath, FilePath)]
validateDomainCert :: (Text, FilePath, FilePath) -> IO (Either CertificateError ()) -> FilePath
validateDomainCert (domain, certFile, keyFile) = do -> FilePath
certExists <- doesFileExist certFile -> TLSSettings
keyExists <- doesFileExist keyFile configureSNI domains defaultCert defaultKey =
let baseTLS = tlsSettings defaultCert defaultKey
in baseTLS
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsCiphers = strongCipherSuites
, onInsecure = DenyInsecure httpsRequiredMessage
, tlsServerHooks = def
{ TLS.onServerNameIndication = \mHostname -> case mHostname of
Nothing -> credentialsOrDefault defaultCert defaultKey
Just hostname ->
sniCallback domains defaultCert defaultKey hostname
}
}
if not certExists validateDomainCert
then return $ Left (CertFileNotFound certFile) :: (Text, FilePath, FilePath) -> IO (Either CertificateError ())
else if not keyExists validateDomainCert (_domain, certFile, keyFile) = do
then return $ Left (KeyFileNotFound keyFile) certExists <- doesFileExist certFile
else return $ Right () keyExists <- doesFileExist keyFile
if not certExists
then pure (Left (CertFileNotFound certFile))
else if not keyExists
then pure (Left (KeyFileNotFound keyFile))
else pure (Right ())
-- | SNI callback function - returns credentials based on hostname sniCallback
sniCallback :: [(Text, FilePath, FilePath)] -> FilePath -> FilePath -> String -> IO TLS.Credentials :: [(Text, FilePath, FilePath)]
sniCallback domains defaultCert defaultKey hostname = do -> FilePath
let hostnameText = T.pack hostname -> FilePath
-- Look up domain in map -> String
-> IO TLS.Credentials
sniCallback domains defaultCert defaultKey hostname =
let domainMap :: Map Text (FilePath, FilePath)
domainMap = Map.fromList [(d, (c, k)) | (d, c, k) <- domains] domainMap = Map.fromList [(d, (c, k)) | (d, c, k) <- domains]
in case Map.lookup (T.pack hostname) domainMap of
Nothing -> credentialsOrDefault defaultCert defaultKey
Just (certFile, keyFile) ->
credentialsOrDefault certFile keyFile
case Map.lookup hostnameText domainMap of credentialsOrDefault :: FilePath -> FilePath -> IO TLS.Credentials
Nothing -> do credentialsOrDefault certFile keyFile = do
-- No match, use default certificate
loadCredentials defaultCert defaultKey
Just (certFile, keyFile) -> do
-- Found matching domain, load its certificate
loadCredentials certFile keyFile
-- | Load TLS credentials from certificate and key files
loadCredentials :: FilePath -> FilePath -> IO TLS.Credentials
loadCredentials certFile keyFile = do
result <- TLS.credentialLoadX509 certFile keyFile result <- TLS.credentialLoadX509 certFile keyFile
case result of case result of
Left err -> Left err -> do
error $ "Failed to load certificate: " ++ err hPutStrLn stderr $
"TLS: failed to load credential at "
<> certFile <> " (" <> err <> "); SNI handler returns empty credentials"
pure (TLS.Credentials [])
Right credential -> Right credential ->
return $ TLS.Credentials [credential] pure (TLS.Credentials [credential])
-- | Validate a certificate file (check if it's readable and valid) validateCertificate
validateCertificate :: FilePath -> IO (Either CertificateError [SignedCertificate]) :: FilePath -> IO (Either CertificateError [SignedCertificate])
validateCertificate certFile = do validateCertificate certFile = do
exists <- doesFileExist certFile exists <- doesFileExist certFile
if not exists if not exists
then return $ Left (CertFileNotFound certFile) then pure (Left (CertFileNotFound certFile))
else do else do
result <- try $ readSignedObject certFile result <- try $ readSignedObject certFile
case result of case result of
Left (err :: SomeException) -> Left (err :: SomeException) ->
return $ Left (InvalidCertificate certFile (show err)) pure (Left (InvalidCertificate certFile (show err)))
Right certs -> Right certs ->
return $ Right certs pure (Right certs)
-- | Strong cipher suites for production (TLS 1.2 + TLS 1.3)
strongCipherSuites :: [TLS.Cipher] strongCipherSuites :: [TLS.Cipher]
strongCipherSuites = strongCipherSuites =
-- TLS 1.3 cipher suites (preferred) [ Cipher.cipher13_AES_128_GCM_SHA256
[ Cipher.cipher_TLS13_AES128GCM_SHA256 , Cipher.cipher13_AES_256_GCM_SHA384
, Cipher.cipher_TLS13_AES256GCM_SHA384 , Cipher.cipher13_CHACHA20_POLY1305_SHA256
, Cipher.cipher_TLS13_CHACHA20POLY1305_SHA256 , Cipher.cipher_ECDHE_RSA_WITH_AES_128_GCM_SHA256
] ++
-- TLS 1.2 cipher suites (fallback, only ECDHE + AEAD)
[ Cipher.cipher_ECDHE_RSA_WITH_AES_128_GCM_SHA256
, Cipher.cipher_ECDHE_RSA_WITH_AES_256_GCM_SHA384 , Cipher.cipher_ECDHE_RSA_WITH_AES_256_GCM_SHA384
, Cipher.cipher_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 , Cipher.cipher_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
, Cipher.cipher_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 , Cipher.cipher_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256

View File

@ -1,27 +1,95 @@
{-
©AngelaMos | 2026
Tunnel.hs
-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecordWildCards #-}
module Aenebris.Tunnel module Aenebris.Tunnel
( tunnelWebSocket ( ConnectError(..)
, connectToBackend
, parseHostPort
, parseUpgradeStatus
, tunnelWebSocket
, streamResponse , streamResponse
, bidirectionalCopy , bidirectionalCopy
) where ) where
import Control.Concurrent.Async (race_) import Control.Concurrent.Async (race_)
import Control.Exception (SomeException, try, bracket) import Control.Exception
( SomeException
, bracketOnError
, finally
, try
)
import Data.ByteString (ByteString) import Data.ByteString (ByteString)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Char8 as BS8
import Data.CaseInsensitive (original) import Data.CaseInsensitive (original)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Network.HTTP.Types (HeaderName)
import Network.Socket (Socket) import Network.Socket (Socket)
import qualified Network.Socket as Socket import qualified Network.Socket as Socket
import qualified Network.Socket.ByteString as SocketBS import qualified Network.Socket.ByteString as SocketBS
import Network.Wai import Network.Wai
( Request
, rawPathInfo
, rawQueryString
, requestHeaders
, requestMethod
)
import System.IO (hPutStrLn, stderr) import System.IO (hPutStrLn, stderr)
import System.Timeout (timeout)
defaultBackendPort :: Int
defaultBackendPort = 80
upgradeRecvChunkBytes :: Int
upgradeRecvChunkBytes = 4_096
maxUpgradeHeaderBytes :: Int
maxUpgradeHeaderBytes = 16_384
tunnelRecvChunkBytes :: Int
tunnelRecvChunkBytes = 65_536
connectTimeoutSeconds :: Int
connectTimeoutSeconds = 5
upgradeIdleSeconds :: Int
upgradeIdleSeconds = 30
microsPerSecond :: Int
microsPerSecond = 1_000_000
upgradeStatusSwitching :: Int
upgradeStatusSwitching = 101
badGatewayResponseLine :: ByteString
badGatewayResponseLine = "HTTP/1.1 502 Bad Gateway\r\n\r\n"
upgradeTerminator :: ByteString
upgradeTerminator = "\r\n\r\n"
httpHeaderLineEnd :: ByteString
httpHeaderLineEnd = "\r\n"
httpVersionAndCrlf :: ByteString
httpVersionAndCrlf = " HTTP/1.1\r\n"
httpFieldSeparator :: ByteString
httpFieldSeparator = ": "
requestPathSeparator :: ByteString
requestPathSeparator = " "
data ConnectError
= ResolutionFailed !String !Int
| ConnectTimeout !String !Int
| ConnectFailed !String !Int !String
deriving (Eq, Show)
tunnelWebSocket tunnelWebSocket
:: Request :: Request
@ -31,39 +99,62 @@ tunnelWebSocket
-> IO () -> IO ()
tunnelWebSocket clientReq backendHost clientSend clientRecv = do tunnelWebSocket clientReq backendHost clientSend clientRecv = do
hPutStrLn stderr $ "[WS] Initiating tunnel to " ++ T.unpack backendHost hPutStrLn stderr $ "[WS] Initiating tunnel to " ++ T.unpack backendHost
let (host, port) = parseHostPort backendHost
result <- try $ do outcome <- try $ do
let (host, port) = parseHostPort backendHost eSock <- connectToBackend host port
case eSock of
bracket Left err -> do
(connectToBackend host port) hPutStrLn stderr $ "[WS] Connection failed: " ++ show err
Socket.close clientSend badGatewayResponseLine
$ \backendSocket -> do Right sock ->
sendUpgradeRequest backendSocket clientReq runUpgrade clientReq clientSend clientRecv sock
upgradeResponse <- receiveUpgradeResponse backendSocket `finally` Socket.close sock
case outcome of
case parseUpgradeStatus upgradeResponse of
Just 101 -> do
hPutStrLn stderr "[WS] Backend accepted upgrade (101)"
clientSend upgradeResponse
bidirectionalCopy clientRecv clientSend
(SocketBS.recv backendSocket 65536)
(SocketBS.sendAll backendSocket)
Just code -> do
hPutStrLn stderr $ "[WS] Backend rejected upgrade: " ++ show code
clientSend upgradeResponse
Nothing -> do
hPutStrLn stderr "[WS] Invalid upgrade response"
clientSend "HTTP/1.1 502 Bad Gateway\r\n\r\n"
case result of
Left (e :: SomeException) -> Left (e :: SomeException) ->
hPutStrLn stderr $ "[WS] Tunnel error: " ++ show e hPutStrLn stderr $ "[WS] Tunnel error: " ++ show e
Right () -> Right () ->
hPutStrLn stderr "[WS] Tunnel closed" hPutStrLn stderr "[WS] Tunnel closed"
runUpgrade
:: Request
-> (ByteString -> IO ())
-> IO ByteString
-> Socket
-> IO ()
runUpgrade clientReq clientSend clientRecv sock = do
sendUpgradeRequest sock clientReq
mResponse <- timeout
(upgradeIdleSeconds * microsPerSecond)
(receiveUpgradeResponse sock)
case mResponse of
Nothing -> do
hPutStrLn stderr "[WS] Upgrade response timed out"
clientSend badGatewayResponseLine
Just upgradeResponse ->
dispatchUpgrade sock clientSend clientRecv upgradeResponse
dispatchUpgrade
:: Socket
-> (ByteString -> IO ())
-> IO ByteString
-> ByteString
-> IO ()
dispatchUpgrade sock clientSend clientRecv upgradeResponse =
case parseUpgradeStatus upgradeResponse of
Just code | code == upgradeStatusSwitching -> do
hPutStrLn stderr "[WS] Backend accepted upgrade (101)"
clientSend upgradeResponse
bidirectionalCopy
clientRecv clientSend
(SocketBS.recv sock tunnelRecvChunkBytes)
(SocketBS.sendAll sock)
Just code -> do
hPutStrLn stderr $ "[WS] Backend rejected upgrade: " ++ show code
clientSend upgradeResponse
Nothing -> do
hPutStrLn stderr "[WS] Invalid upgrade response"
clientSend badGatewayResponseLine
bidirectionalCopy bidirectionalCopy
:: IO ByteString :: IO ByteString
-> (ByteString -> IO ()) -> (ByteString -> IO ())
@ -72,11 +163,9 @@ bidirectionalCopy
-> IO () -> IO ()
bidirectionalCopy clientRecv clientSend backendRecv backendSend = do bidirectionalCopy clientRecv clientSend backendRecv backendSend = do
hPutStrLn stderr "[TUNNEL] Starting bidirectional copy" hPutStrLn stderr "[TUNNEL] Starting bidirectional copy"
race_ race_
(copyLoop "client->backend" clientRecv backendSend) (copyLoop "client->backend" clientRecv backendSend)
(copyLoop "backend->client" backendRecv clientSend) (copyLoop "backend->client" backendRecv clientSend)
hPutStrLn stderr "[TUNNEL] Bidirectional copy ended" hPutStrLn stderr "[TUNNEL] Bidirectional copy ended"
copyLoop :: String -> IO ByteString -> (ByteString -> IO ()) -> IO () copyLoop :: String -> IO ByteString -> (ByteString -> IO ()) -> IO ()
@ -112,56 +201,74 @@ parseHostPort hostPort =
[host, portStr] -> [host, portStr] ->
case reads (T.unpack portStr) of case reads (T.unpack portStr) of
[(port, "")] -> (T.unpack host, port) [(port, "")] -> (T.unpack host, port)
_ -> (T.unpack host, 80) _ -> (T.unpack host, defaultBackendPort)
[host] -> (T.unpack host, 80) [host] -> (T.unpack host, defaultBackendPort)
_ -> (T.unpack hostPort, 80) _ -> (T.unpack hostPort, defaultBackendPort)
connectToBackend :: String -> Int -> IO Socket connectToBackend :: String -> Int -> IO (Either ConnectError Socket)
connectToBackend host port = do connectToBackend host port = do
addrInfos <- Socket.getAddrInfo resolution <- try $ Socket.getAddrInfo
(Just Socket.defaultHints { Socket.addrSocketType = Socket.Stream }) (Just Socket.defaultHints { Socket.addrSocketType = Socket.Stream })
(Just host) (Just host)
(Just $ show port) (Just (show port))
case resolution of
Left (_ :: SomeException) -> pure (Left (ResolutionFailed host port))
Right [] -> pure (Left (ResolutionFailed host port))
Right (addr : _) -> attemptConnect host port addr
case addrInfos of attemptConnect
[] -> error $ "Cannot resolve: " ++ host ++ ":" ++ show port :: String -> Int -> Socket.AddrInfo -> IO (Either ConnectError Socket)
(addr:_) -> do attemptConnect host port addr =
sock <- Socket.socket bracketOnError
(Socket.addrFamily addr) (Socket.socket
Socket.Stream (Socket.addrFamily addr)
Socket.defaultProtocol Socket.Stream
Socket.connect sock (Socket.addrAddress addr) Socket.defaultProtocol)
return sock Socket.close
$ \sock -> do
mConnect <- timeout
(connectTimeoutSeconds * microsPerSecond)
(try (Socket.connect sock (Socket.addrAddress addr)))
case mConnect of
Nothing -> do
Socket.close sock
pure (Left (ConnectTimeout host port))
Just (Left (e :: SomeException)) -> do
Socket.close sock
pure (Left (ConnectFailed host port (show e)))
Just (Right ()) ->
pure (Right sock)
sendUpgradeRequest :: Socket -> Request -> IO () sendUpgradeRequest :: Socket -> Request -> IO ()
sendUpgradeRequest sock req = do sendUpgradeRequest sock req =
let method = requestMethod req let method = requestMethod req
path = rawPathInfo req <> rawQueryString req path = rawPathInfo req <> rawQueryString req
headers = requestHeaders req headers = requestHeaders req
requestLine = method <> requestPathSeparator <> path <> httpVersionAndCrlf
requestLine = method <> " " <> path <> " HTTP/1.1\r\n"
headerLines = BS.concat headerLines = BS.concat
[ original name <> ": " <> value <> "\r\n" [ original name <> httpFieldSeparator <> value <> httpHeaderLineEnd
| (name, value) <- headers | (name, value) <- headers
] ]
fullRequest = requestLine <> headerLines <> "\r\n" fullRequest = requestLine <> headerLines <> httpHeaderLineEnd
in SocketBS.sendAll sock fullRequest
SocketBS.sendAll sock fullRequest
receiveUpgradeResponse :: Socket -> IO ByteString receiveUpgradeResponse :: Socket -> IO ByteString
receiveUpgradeResponse sock = do receiveUpgradeResponse sock = go BS.empty
chunk <- SocketBS.recv sock 4096 where
if "\r\n\r\n" `BS.isInfixOf` chunk go !acc
then return chunk | upgradeTerminator `BS.isInfixOf` acc = pure acc
else do | BS.length acc >= maxUpgradeHeaderBytes = pure acc
rest <- receiveUpgradeResponse sock | otherwise = do
return $ chunk <> rest chunk <- SocketBS.recv sock upgradeRecvChunkBytes
if BS.null chunk
then pure acc
else go (acc <> chunk)
parseUpgradeStatus :: ByteString -> Maybe Int parseUpgradeStatus :: ByteString -> Maybe Int
parseUpgradeStatus response = parseUpgradeStatus response = case BS8.lines response of
case BS8.words (head $ BS8.lines response) of [] -> Nothing
(_:codeBS:_) -> (firstLine : _) -> case BS8.words firstLine of
case reads (BS8.unpack codeBS) of (_ : codeBS : _) -> case reads (BS8.unpack codeBS) of
[(code, "")] -> Just code [(code, "")] -> Just code
_ -> Nothing _ -> Nothing
_ -> Nothing _ -> Nothing

View File

@ -72,13 +72,16 @@ data Target
| TargetUserAgent | TargetUserAgent
deriving (Eq, Show) deriving (Eq, Show)
newtype CompiledRegex = CompiledRegex { unCompiledRegex :: Regex } data CompiledRegex = CompiledRegex
{ unCompiledRegex :: !Regex
, compiledRegexPattern :: !ByteString
}
instance Show CompiledRegex where instance Show CompiledRegex where
show _ = "<CompiledRegex>" show r = "<CompiledRegex " ++ show (compiledRegexPattern r) ++ ">"
instance Eq CompiledRegex where instance Eq CompiledRegex where
_ == _ = False a == b = compiledRegexPattern a == compiledRegexPattern b
data Operator data Operator
= OpRegex !CompiledRegex = OpRegex !CompiledRegex
@ -115,13 +118,13 @@ compileRegex :: ByteString -> Either String CompiledRegex
compileRegex pat = compileRegex pat =
case compile compOpts execOpts pat of case compile compOpts execOpts pat of
Left err -> Left err Left err -> Left err
Right r -> Right (CompiledRegex r) Right r -> Right (CompiledRegex r pat)
where where
compOpts = TDFA.defaultCompOpt { TDFA.caseSensitive = False } compOpts = TDFA.defaultCompOpt { TDFA.caseSensitive = False }
execOpts = TDFA.defaultExecOpt execOpts = TDFA.defaultExecOpt
runRegex :: CompiledRegex -> ByteString -> Bool runRegex :: CompiledRegex -> ByteString -> Bool
runRegex (CompiledRegex r) input = runRegex cr input =
case execute r input of case execute (unCompiledRegex cr) input of
Right (Just _) -> True Right (Just _) -> True
_ -> False _ -> False

View File

@ -817,6 +817,16 @@ wafSpec = describe "WAF" $ do
severityScore SevWarning `shouldBe` 3 severityScore SevWarning `shouldBe` 3
severityScore SevNotice `shouldBe` 2 severityScore SevNotice `shouldBe` 2
it "Eq CompiledRegex is reflexive (x == x)" $
case compileRegex "abc" of
Right r -> r `shouldBe` r
Left err -> expectationFailure err
it "Eq CompiledRegex distinguishes different patterns" $
case (compileRegex "abc", compileRegex "def") of
(Right r1, Right r2) -> (r1 == r2) `shouldBe` False
_ -> expectationFailure "expected both to compile"
it "default ruleset includes rules" $ it "default ruleset includes rules" $
length (rsRules defaultRuleSet) `shouldSatisfy` (> 0) length (rsRules defaultRuleSet) `shouldSatisfy` (> 0)
@ -2074,6 +2084,16 @@ mlLoaderSpec = describe "ML.Loader" $ do
(T.replace "leaf_value=0.5\n" "leaf_value=0.5\nbogus=1\n" mlLoaderModel)) (T.replace "leaf_value=0.5\n" "leaf_value=0.5\nbogus=1\n" mlLoaderModel))
`shouldSatisfy` isLeft `shouldSatisfy` isLeft
it "rejects num_leaves above maxNumLeaves" $
parseEnsemble
(TE.encodeUtf8 (T.replace "num_leaves=1" "num_leaves=999999" mlLoaderModel))
`shouldSatisfy` parseFailsAt "num_leaves"
it "rejects num_leaves of 0" $
parseEnsemble
(TE.encodeUtf8 (T.replace "num_leaves=1" "num_leaves=0" mlLoaderModel))
`shouldSatisfy` parseFailsAt "num_leaves"
describe "feature_names containing '='" $ describe "feature_names containing '='" $
it "accepts feature_names with '=' in a name" $ it "accepts feature_names with '=' in a name" $
parseEnsemble parseEnsemble