wai-csrf-0.1: lib/Wai/CSRF.hs
-- | This module exports tool to prevent cross-site request forgeries in
-- "Network.Wai". Consider using it in combination with "Wai.CryptoCookie".
module Wai.CSRF
( Config (..)
, defaultConfig
, tokenFromRequestHeader
, tokenFromRequestCookie
, setCookie
, expireCookie
, middleware
-- * Token
, Token (..)
, randomToken
, tokenToBase64UU
, tokenFromBase64UU
-- * Masked token
, MaskedToken (..)
, maskedTokenToBase64UU
, maskedTokenFromBase64UU
, randomMaskToken
, unmaskToken
) where
import Crypto.Random qualified as C
import Data.ByteArray qualified as BA
import Data.ByteArray.Encoding qualified as BA
import Data.ByteArray.Sized qualified as BAS
import Data.ByteString qualified as B
import Data.CaseInsensitive qualified as CI
import Data.Time.Clock.POSIX qualified as Time
import Network.HTTP.Types qualified as H
import Network.Wai qualified as Wai
import Web.Cookie qualified as C
--------------------------------------------------------------------------------
-- | CSRF token.
--
-- * It is safe to send and receive the 'Token' through HTTP cookies and
-- headers.
--
-- * If you need to send or receive the 'Token' as part of the request or
-- response body, use 'MaskedToken' instead.
newtype Token = Token (BAS.SizedByteArray 32 B.ByteString)
instance Show Token where
showsPrec n (Token s) = showsPrec n $ BAS.unSizedByteArray s
instance Eq Token where
Token a == Token b = BA.constEq a b
-- | A CSRF token is just random 32 bytes. Its meaning and validity depends on
-- how and whether you tie it to a user session.
randomToken :: (C.MonadRandom m) => m Token
randomToken = fmap (Token . BAS.unsafeSizedByteArray) (C.getRandomBytes 32)
-- | @'tokenFromBase64UU' . 'tokenToBase64UU' == 'Just'@
tokenToBase64UU :: Token -> B.ByteString
tokenToBase64UU (Token t) =
BA.convertToBase BA.Base64URLUnpadded t
-- | @'tokenFromBase64UU' . 'tokenToBase64UU' == 'Just'@
tokenFromBase64UU :: B.ByteString -> Maybe Token
tokenFromBase64UU b =
case BA.convertFromBase BA.Base64URLUnpadded b of
Right (x :: BA.Bytes) -> Token <$> BAS.fromByteArrayAccess x
_ -> Nothing
--------------------------------------------------------------------------------
-- | If you embed a 'Token' as is in a response body when HTTP body compression
-- is enabled, it is possible for a malicious actor to recover the 'Token'
-- through a /BREACH/ attack or similar. In order to prevent that, send a
-- different 'MaskedToken' (generated with 'randomMaskToken') each time
-- instead.
newtype MaskedToken = MaskedToken (BAS.SizedByteArray 64 BA.Bytes)
instance Show MaskedToken where
showsPrec n (MaskedToken s) = showsPrec n $ BAS.unSizedByteArray s
instance Eq MaskedToken where
MaskedToken a == MaskedToken b = BA.constEq a b
toMaskedToken :: Mask -> Token -> MaskedToken
toMaskedToken (Mask m) (Token s) =
let x = BAS.xor m s
in MaskedToken $! BAS.append m (x `asTypeOf` m)
fromMaskedToken :: MaskedToken -> (Mask, Token)
fromMaskedToken (MaskedToken t) =
let (m, x) = BAS.splitAt t
in (Mask m, Token $! BAS.xor m (x `asTypeOf` m))
-- | @'maskedTokenFromBase64UU' . 'maskedTokenToBase64UU' == 'Just'@
maskedTokenToBase64UU :: MaskedToken -> B.ByteString
maskedTokenToBase64UU (MaskedToken t) = BA.convertToBase BA.Base64URLUnpadded t
-- | @'maskedTokenFromBase64UU' . 'maskedTokenToBase64UU' == 'Just'@
maskedTokenFromBase64UU :: B.ByteString -> Maybe MaskedToken
maskedTokenFromBase64UU b = case BA.convertFromBase BA.Base64URLUnpadded b of
Right (x :: BA.Bytes) -> MaskedToken <$> BAS.fromByteArrayAccess x
_ -> Nothing
-- | See 'MaskedToken'.
newtype Mask = Mask (BAS.SizedByteArray 32 BA.Bytes)
instance Show Mask where
showsPrec n (Mask s) = showsPrec n $ BAS.unSizedByteArray s
instance Eq Mask where
Mask a == Mask b = BA.constEq a b
randomMask :: (C.MonadRandom m) => m Mask
randomMask = fmap (Mask . BAS.unsafeSizedByteArray) (C.getRandomBytes 32)
-- | @'unmaskToken' '<$>' 'randomMaskToken' tok@ and @'pure' tok@ produce
-- the same output @tok@.
randomMaskToken :: (C.MonadRandom m) => Token -> m MaskedToken
randomMaskToken t = flip toMaskedToken t <$> randomMask
-- | @'unmaskToken' '<$>' 'randomMaskToken' tok@ and @'pure' tok@ produce
-- the same output @tok@.
unmaskToken :: MaskedToken -> Token
unmaskToken = snd . fromMaskedToken
--------------------------------------------------------------------------------
-- | Config common to 'middleware', 'tokenFromRequestHeader' and
-- 'tokenFromRequestCookie'.
--
-- Consider using 'defaultConfig' and updating desired fields only.
data Config = Config
{ cookieName :: B.ByteString
-- ^ Used by 'tokenFromRequestCookie', 'setCookie', 'expireCookie' and
-- 'middleware'.
, headerName :: B.ByteString
-- ^ Used by 'tokenFromRequestHeader' and 'middleware'.
, reject :: Wai.Request -> Maybe (Token, Bool) -> Maybe Wai.Response
-- ^ Used by 'middleware'. Decide whether the incoming 'Wai.Request' should
-- be rejected with a given 'Wai.Response'. Takes the 'Token' that came in
-- through a cookie (see 'Config'\'s @cookieName@), if any, as well as a
-- 'Bool' which is 'True' if there is a matching 'Token' that came in
-- through a header (see 'Config'\'s @headerName@).
--
-- If the token comes through the request body (see 'MaskedToken'), then
-- it is sometimes best not to reject the request here, and instead check
-- and potentially reject the request downstream, so as to preserve the
-- streaming nature of processing the request body.
--
-- Notice that nothing in @'Maybe' ('Token', 'Bool')@ has been evaluated
-- by the time @reject@ is called, so unless you force their evaluation,
-- using 'middleware' is essentially free.
}
-- | Default CSRF settings.
--
-- * Cookie name is @CSRF-TOKEN@.
--
-- * Header name is @X-CSRF-TOKEN@.
--
-- * Reject with 'H.forbidden403' all request who are neither @GET@, @HEAD@,
-- @OPTIONS@ nor @TRACE@, unless the 'Token' is present in both cookie and
-- header and they are equal.
defaultConfig :: Config
defaultConfig =
Config
{ cookieName = "CSRF-TOKEN"
, headerName = "X-CSRF-TOKEN"
, reject = \req yteq ->
if
| Wai.requestMethod req == H.methodGet -> Nothing
| Wai.requestMethod req == H.methodHead -> Nothing
| Wai.requestMethod req == H.methodOptions -> Nothing
| Wai.requestMethod req == H.methodTrace -> Nothing
| Just (_, eq) <- yteq, eq -> Nothing
| otherwise -> Just $ Wai.responseLBS H.forbidden403 [] "CSRF"
}
-- | Obtain the 'Token' from the 'Wai.Request' headers.
--
-- You don't need to use this if you are using 'middleware'.
tokenFromRequestHeader :: Config -> Wai.Request -> Maybe Token
tokenFromRequestHeader c = \r -> do
[t64] <- pure $ lookupMany n $ Wai.requestHeaders r
tokenFromBase64UU t64
where
n = CI.mk c.headerName
-- | Obtain the 'Token' from the 'Wai.Request' cookies.
--
-- You don't need to use this if you are using 'middleware'.
tokenFromRequestCookie :: Config -> Wai.Request -> Maybe Token
tokenFromRequestCookie c r = do
[t64] <- pure $ lookupMany c.cookieName $ requestCookies r
tokenFromBase64UU t64
-- | Construct a 'C.SetCookie' to set the CSRF 'Token'.
--
-- The 'C.SetCookie' has these settings, some of which could be overriden.
--
-- * Cookie name is 'Config'\'s @cookieName@.
--
-- * @HttpOnly@: No, and you shouldn't change this.
--
-- * @Max-Age@ and @Expires@: This cookie never expires. We recommend
-- relying on server-side expiration instead, as the lifetime of the
-- cookie could easily be extended by a legitimate but malicious client.
-- It is recommended that you rotate the 'Token' each time a new user
-- session is established.
--
-- * @Path@: @\/@
--
-- * @SameSite@: @Lax@.
--
-- * @Secure@: Yes.
--
-- * @Domain@: Not set.
setCookie :: Config -> Token -> C.SetCookie
setCookie c tok =
(expireCookie c)
{ C.setCookieValue = tokenToBase64UU tok
, C.setCookieExpires = Nothing
, C.setCookieMaxAge = Nothing
}
-- | Construct a 'C.SetCookie' expiring the cookie named 'Config'\'s
-- @cookieName@.
expireCookie :: Config -> C.SetCookie
expireCookie c =
C.defaultSetCookie
{ C.setCookieName = c.cookieName
, C.setCookieValue = ""
, C.setCookieDomain = Nothing
, C.setCookieExpires = Just (Time.posixSecondsToUTCTime 0)
, C.setCookieHttpOnly = False
, C.setCookieMaxAge = Just (negate 1)
, C.setCookiePath = Just "/"
, C.setCookieSameSite = Just C.sameSiteLax
, C.setCookieSecure = True
}
-- | Construct a 'Wai.Middleware' (almost) that does the following:
--
-- 1. Try to find the CSRF 'Token' among the incoming 'Wai.Request' cookies
-- (see 'Config'\'s @cookieName@).
--
-- 2. Use 'Config'\'s @reject@ to decide if the incoming 'Wai.Request'
-- should be rejected.
--
-- 3. If the 'Wai.Request' wasn't rejected, we pass the 'Token' found in the
-- cookie, if any, to the underlying 'Wai.Application'.
--
-- Important: This doesn't set any cookie. You must explicitly add
-- 'setCookie' to a 'Wai.Response' yourself.
middleware
:: Config
-> (Maybe Token -> Wai.Application)
-> Wai.Application
middleware c = \fapp req respond -> do
let yct = fyct req
yte = liftA2 (\ct ht -> (ct, ct == ht)) yct (fyht req)
case c.reject req yte of
Nothing -> fapp yct req respond
Just res -> respond res
where
fyct = tokenFromRequestCookie c
fyht = tokenFromRequestHeader c
--------------------------------------------------------------------------------
requestCookies :: Wai.Request -> [(B.ByteString, B.ByteString)]
requestCookies r = C.parseCookies =<< lookupMany "Cookie" (Wai.requestHeaders r)
lookupMany :: (Eq k) => k -> [(k, v)] -> [v]
lookupMany k = findMany (== k)
findMany :: (Eq k) => (k -> Bool) -> [(k, v)] -> [v]
findMany f = map snd . filter (\(a, _) -> f a)