wai-extra-3.1.14: Network/Wai/Middleware/CombineHeaders.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
-- |
-- Sometimes incoming requests don't stick to the
-- "no duplicate headers" invariant, for a number
-- of possible reasons (e.g. proxy servers blindly
-- adding headers), or your application (or other
-- middleware) blindly adds headers.
--
-- In those cases, you can use this 'Middleware'
-- to make sure that headers that /can/ be combined
-- /are/ combined. (e.g. applications might only
-- check the first \"Accept\" header and fail, while
-- there might be another one that would match)
module Network.Wai.Middleware.CombineHeaders (
combineHeaders,
CombineSettings,
defaultCombineSettings,
HeaderMap,
HandleType,
defaultHeaderMap,
-- * Adjusting the settings
setHeader,
removeHeader,
setHeaderMap,
regular,
keepOnly,
setRequestHeaders,
setResponseHeaders,
) where
import qualified Data.ByteString as B
import qualified Data.List as L (foldl', reverse)
import qualified Data.Map.Strict as M
import Data.Word8 (_comma, _space, _tab)
import Network.HTTP.Types (Header, HeaderName, RequestHeaders)
import qualified Network.HTTP.Types.Header as H
import Network.Wai (Middleware, mapResponseHeaders, requestHeaders)
import Network.Wai.Util (dropWhileEnd)
-- | The mapping of 'HeaderName' to 'HandleType'
type HeaderMap = M.Map HeaderName HandleType
-- | These settings define which headers should be combined,
-- if the combining should happen on incoming (request) headers
-- and if it should happen on outgoing (response) headers.
--
-- Any header you put in the header map *will* be used to
-- combine those headers with commas. There's no check to see
-- if it is a header that allows comma-separated lists, so if
-- you want to combine custom headers, go ahead.
--
-- (You can check the documentation of 'defaultCombineSettings'
-- to see which standard headers are specified to be able to be
-- combined)
--
-- @since 3.1.13.0
data CombineSettings = CombineSettings
{ combineHeaderMap :: HeaderMap
-- ^ Which headers should be combined? And how? (cf. 'HandleType')
, combineRequestHeaders :: Bool
-- ^ Should request headers be combined?
, combineResponseHeaders :: Bool
-- ^ Should response headers be combined?
}
deriving (Eq, Show)
-- | Settings that combine request headers,
-- but don't touch response headers.
--
-- All types of headers that /can/ be combined
-- (as defined in the spec) /will/ be combined.
--
-- To be exact, this is the list:
--
-- * Accept
-- * Accept-CH
-- * Accept-Charset
-- * Accept-Encoding
-- * Accept-Language
-- * Accept-Post
-- * Access-Control-Allow-Headers
-- * Access-Control-Allow-Methods
-- * Access-Control-Expose-Headers
-- * Access-Control-Request-Headers
-- * Allow
-- * Alt-Svc @(KeepOnly \"clear\"")@
-- * Cache-Control
-- * Clear-Site-Data @(KeepOnly \"*\")@
-- * Connection
-- * Content-Encoding
-- * Content-Language
-- * Digest
-- * If-Match
-- * If-None-Match @(KeepOnly \"*\")@
-- * Link
-- * Permissions-Policy
-- * TE
-- * Timing-Allow-Origin @(KeepOnly \"*\")@
-- * Trailer
-- * Transfer-Encoding
-- * Upgrade
-- * Via
-- * Vary @(KeepOnly \"*\")@
-- * Want-Digest
--
-- N.B. Any header name that has \"KeepOnly\" after it
-- will be combined like normal, unless one of the values
-- is the one mentioned (\"*\" most of the time), then
-- that value is used and all others are dropped.
--
-- @since 3.1.13.0
defaultCombineSettings :: CombineSettings
defaultCombineSettings =
CombineSettings
{ combineHeaderMap = defaultHeaderMap
, combineRequestHeaders = True
, combineResponseHeaders = False
}
-- | Override the 'HeaderMap' of the 'CombineSettings'
-- (default: 'defaultHeaderMap')
--
-- @since 3.1.13.0
setHeaderMap :: HeaderMap -> CombineSettings -> CombineSettings
setHeaderMap mp set = set{combineHeaderMap = mp}
-- | Set whether the combining of headers should be applied to
-- the incoming request headers. (default: True)
--
-- @since 3.1.13.0
setRequestHeaders :: Bool -> CombineSettings -> CombineSettings
setRequestHeaders b set = set{combineRequestHeaders = b}
-- | Set whether the combining of headers should be applied to
-- the outgoing response headers. (default: False)
--
-- @since 3.1.13.0
setResponseHeaders :: Bool -> CombineSettings -> CombineSettings
setResponseHeaders b set = set{combineResponseHeaders = b}
-- | Convenience function to add a header to the header map or,
-- if it is already in the map, to change the 'HandleType'.
--
-- @since 3.1.13.0
setHeader :: HeaderName -> HandleType -> CombineSettings -> CombineSettings
setHeader name typ settings =
settings
{ combineHeaderMap = M.insert name typ $ combineHeaderMap settings
}
-- | Convenience function to remove a header from the header map.
--
-- @since 3.1.13.0
removeHeader :: HeaderName -> CombineSettings -> CombineSettings
removeHeader name settings =
settings
{ combineHeaderMap = M.delete name $ combineHeaderMap settings
}
-- | This middleware will reorganize the incoming and/or outgoing
-- headers in such a way that it combines any duplicates of
-- headers that, on their own, can normally have more than one
-- value, and any other headers will stay untouched.
--
-- This middleware WILL change the global order of headers
-- (they will be put in alphabetical order), but keep the
-- order of the same type of header. I.e. if there are 3
-- \"Set-Cookie\" headers, the first one will still be first,
-- the second one will still be second, etc. But now they are
-- guaranteed to be next to each other.
--
-- N.B. This 'Middleware' assumes the headers it combines
-- are correctly formatted. If one of the to-be-combined
-- headers is malformed, the new combined header will also
-- (probably) be malformed.
--
-- @since 3.1.13.0
combineHeaders :: CombineSettings -> Middleware
combineHeaders CombineSettings{..} app req resFunc =
app newReq $ resFunc . adjustRes
where
newReq
| combineRequestHeaders = req{requestHeaders = mkNewHeaders oldHeaders}
| otherwise = req
oldHeaders = requestHeaders req
adjustRes
| combineResponseHeaders = mapResponseHeaders mkNewHeaders
| otherwise = id
mkNewHeaders =
M.foldrWithKey' finishHeaders [] . L.foldl' go mempty
go acc hdr@(name, _) =
M.alter (checkHeader hdr) name acc
checkHeader :: Header -> Maybe HeaderHandling -> Maybe HeaderHandling
checkHeader (name, newVal) =
Just . \case
Nothing -> (name `M.lookup` combineHeaderMap, [newVal])
-- Yes, this reverses the order of headers, but these
-- will be reversed again in 'finishHeaders'
Just (mHandleType, hdrs) -> (mHandleType, newVal : hdrs)
-- | Unpack 'HeaderHandling' back into 'Header's again
finishHeaders
:: HeaderName -> HeaderHandling -> RequestHeaders -> RequestHeaders
finishHeaders name (shouldCombine, xs) hdrs =
case shouldCombine of
Just typ -> (name, combinedHeader typ) : hdrs
Nothing ->
-- Yes, this reverses the headers, but they
-- were already reversed by 'checkHeader'
L.foldl' (\acc el -> (name, el) : acc) hdrs xs
where
combinedHeader Regular = combineHdrs xs
combinedHeader (KeepOnly val)
| val `elem` xs = val
| otherwise = combineHdrs xs
-- headers were reversed, so do 'reverse' before combining
combineHdrs = B.intercalate ", " . fmap clean . L.reverse
clean = dropWhileEnd $ \w -> w == _comma || w == _space || w == _tab
type HeaderHandling = (Maybe HandleType, [B.ByteString])
-- | Both will concatenate with @,@ (commas), but 'KeepOnly' will drop all
-- values except the given one if present (e.g. in case of wildcards/special values)
--
-- For example: If there are multiple @"Clear-Site-Data"@ headers, but one of
-- them is the wildcard @\"*\"@ value, using @'KeepOnly' "*"@ will cause all
-- others to be dropped and only the wildcard value to remain.
-- (The @\"*\"@ wildcard in this case means /ALL site data/ should be cleared,
-- so no need to include more)
--
-- @since 3.1.13.0
data HandleType
= Regular
| KeepOnly B.ByteString
deriving (Eq, Show)
-- | Use the regular strategy when combining headers.
-- (i.e. merge into one header and separate values with commas)
--
-- @since 3.1.13.0
regular :: HandleType
regular = Regular
-- | Use the regular strategy when combining headers,
-- but if the exact supplied 'ByteString' is encountered
-- then discard all other values and only keep that value.
--
-- e.g. @keepOnly "*"@ will drop all other encountered values
--
-- @since 3.1.13.0
keepOnly :: B.ByteString -> HandleType
keepOnly = KeepOnly
-- | The default collection of HTTP headers that can be combined
-- in case there are multiples in one request or response.
--
-- See the documentation of 'defaultCombineSettings' for the exact list.
--
-- @since 3.1.13.0
defaultHeaderMap :: HeaderMap
defaultHeaderMap =
M.fromList
[ (H.hAccept, Regular)
, ("Accept-CH", Regular)
, (H.hAcceptCharset, Regular)
, (H.hAcceptEncoding, Regular)
, (H.hAcceptLanguage, Regular)
, ("Accept-Post", Regular)
, ("Access-Control-Allow-Headers", Regular) -- wildcard? yes, but can just add to list
, ("Access-Control-Allow-Methods", Regular) -- wildcard? yes, but can just add to list
, ("Access-Control-Expose-Headers", Regular) -- wildcard? yes, but can just add to list
, ("Access-Control-Request-Headers", Regular)
, (H.hAllow, Regular)
, ("Alt-Svc", KeepOnly "clear") -- special "clear" value (if any is "clear", only keep that one)
, (H.hCacheControl, Regular)
, ("Clear-Site-Data", KeepOnly "*") -- wildcard (if any is "*", only keep that one)
, -- If "close" and anything else is used together, it's already F-ed,
-- so just combine them.
(H.hConnection, Regular)
, (H.hContentEncoding, Regular)
, (H.hContentLanguage, Regular)
, ("Digest", Regular)
, -- We could handle this, but it's experimental AND
-- will be replaced by "Permissions-Policy"
-- , "Feature-Policy" -- "semicolon ';' separated"
(H.hIfMatch, Regular)
, (H.hIfNoneMatch, KeepOnly "*") -- wildcard? (if any is "*", only keep that one)
, ("Link", Regular)
, ("Permissions-Policy", Regular)
, (H.hTE, Regular)
, ("Timing-Allow-Origin", KeepOnly "*") -- wildcard? (if any is "*", only keep that one)
, (H.hTrailer, Regular)
, (H.hTransferEncoding, Regular)
, (H.hUpgrade, Regular)
, (H.hVia, Regular)
, (H.hVary, KeepOnly "*") -- wildcard? (if any is "*", only keep that one)
, ("Want-Digest", Regular)
]