{-# 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) ]