Implementing a Content Security Policy in Yesod
If you use Mozilla’s Observatory website security scanner, you’ll quickly find you won’t get an A+ grade without correctly implementing a Content Security Policy.
A
It also doesn’t help that documentation you might assume to be authoritative is sometimes misleading. In fact, following various tutorials on the Internet — many of which are contradictory — initially lead me to implement a CSP using host-based whitelisting. The benefit of this approach is that you can just hardcode your whitelist, and implement your CSP in nginx without touching your application. There are a number of drawbacks though; not only are host-based whitelists cumbersome to maintain, they’re also generally insecure as evidenced in a Google research paper. The short story is that it’s better to use a nonce-based approach.
This presents its own set of problems though. For a nonce to be effective, it must be:
- Encoded in Base64
- Randomly generated on every request
- Present in both the CSP header and the HTML tag of the script to which you’re granting execution permission
The first two constraints are trivial to solve — we can use the uuid
and
base64-bytestring
libraries to produce our nonces.
import ClassyPrelude.Yesod
import Data.ByteString.Base64 qualified as B64
import Data.UUID (toASCIIBytes)
import Data.UUID.V4 (nextRandom)
generateRequestNonce :: MonadHandler m => m Text
generateRequestNonce = do
uuid <- liftIO nextRandom
return $ decodeUtf8 $ B64.encode $ toASCIIBytes uuid
The nextRandom
function generates our random value. The quality of the
randomness of this value doesn’t really matter, so we could have used
randomIO
from the System.Random
module here. The reason I decided against
using that function is that it’s quite slow. There are other libraries
available for generating random values with good performance, but I’m already
using UUIDV4 values in the rest of my application.
The toASCIIBytes
function turns our random UUID
value into a ByteString
,
which is the type that our Base64 encoding function expects. Finally, we use
decodeUtf8
to turn our bytestring into a Text
value, as this is the
specific string-like type that Yesod’s HTML tag and response header functions
expect.
Now that we have our nonce, how do we shoehorn it into the script tags we wish
to whitelist? Typically when writing an application in Yesod, you’d use the
addScript
function to generate the script tags and insert them in the
document. This won’t work though, because the nonce should be inserted into its
own HTML attribute in that script tag. Luckily, Yesod provides us a way to
generate a script tag with arbitrary additional attributes.
getHomeR :: Handler Html
getHomeR = do
nonce <- generateRequestNonce
defaultLayout do
addScriptAttrs (StaticR js_jquery_js) [("nonce", nonce)]
$(widgetFile "home")
This works, but the ergonomics aren’t great and it adds quite a lot of noise. Fortunately Haskell makes it easy to abstract away this kind of thing, so we can write our own small helper functions to clean this up. The same principle applies whether we’re adding scripts we host ourselves, or scripts hosted elsewhere.
addScriptCSP :: MonadWidget m => Route (HandlerSite m) -> m ()
addScriptCSP route = do
nonce <- generateRequestNonce
addScriptAttrs route [("nonce", unCSPNonce nonce)]
addScriptRemoteCSP :: MonadWidget m => Text -> m ()
addScriptRemoteCSP uri = do
nonce <- generateRequestNonce
addScriptRemoteAttrs uri [("nonce", nonce)]
addScriptEitherCSP :: MonadWidget m => Either (Route (HandlerSite m)) Text -> m ()
addScriptEitherCSP = either addScriptCSP addScriptRemoteCSP
getHomeR :: Handler Html
getHomeR = defaultLayout do
addScriptCSP $ StaticR js_jquery_js
addScriptRemoteCSP "https://cdn.ywxi.net/js/1.js"
$(widgetFile "home")
This takes care of all the JavaScript libraries we wish to import, but there is
one more crucial script tag left to cover. When we compose widgets together
which contain some Julius (Shakespeare-flavoured JavaScript) code, Yesod joins
those disparate bits of widget-specific code together into a file typically
called autogen-xxxxxxxx.js
, where those x
s are [I think] the hash of the
file.
This is done in the core of the Yesod framework, so you can’t directly override
this behaviour. As of yesod-core-1.6.16
however, there’s a new typeclass
method that allows us to add arbitrary attributes to that generated tag too. We
can use this method together with the nonce generator function we wrote
earlier.
instance Yesod App where
jsAttributesHandler = do
nonce <- generateRequestNonce
return [("nonce", nonce)]
-- Here you would have your own implementations for
-- approot, yesodMiddleware, defaultLayout, etc.
Now that we have our nonce in all our HTML script tags, we also need to stuff
it into the script-src
directive of our CSP response header. We could use
the addHeader
function in every handler in our application, but that would be
cumbersome to maintain and adds unnecessary noise. Instead, we can add this
response header to every request from just one place using a middleware.
instance Yesod App where
yesodMiddleware =
defaultYesodMiddleware . addCSPMiddleware
The middleware function that we want to write will need to know the value of
the nonce we generated earlier in the request lifecycle. Usually when you want
to store some state in the application memory, you’d add a new field to the
App
record, initialise it when the application starts up, and manipulate the
value it holds from your handler functions. In our case however, we don’t
actually want the value to exist outside the lifecycle of a single request so
this is a great opportunity to take advantage of Yesod’s per-request caching.
cspCommon :: [Text]
cspCommon =
[ "img-src 'self' data:"
, "object-src 'none'"
, "form-action 'self'"
, "frame-ancestors 'none'"
, "base-uri 'self'"
, "report-uri /report-csp"
]
csp :: Maybe Text -> Text
csp mNonce =
intercalate ";" $ cspCommon <> [unwords scriptSrc]
where mkNonce n = "'nonce-" <> n <> "'"
scriptSrc =
[ "script-src"
, "'self'"
, "about:"
, "'strict-dynamic'"
] <> maybe [] ((:[]) . mkNonce) mNonce
addCSPMiddleware :: (HandlerFor m) a -> (HandlerFor m) a
addCSPMiddleware handler = do
res <- handler
nonce <- cacheGet
addHeader "Content-Security-Policy" $ csp nonce
return res
How this works is rather clever. You might be curious about the cacheGet
function. How does it know exactly what to fetch from the cache? We didn’t even
tell it which key to use for the lookup! Well, the per-request cache stores
data by type, and Haskell can infer the type of cached value we’re
interested in because we’ve passed our cached nonce
value to the csp
function which expects a Maybe Text
argument.
Since values are cached by type, it logically follows that we can only cache
one value for any given type. If we wanted to cache some other Text
value,
we’d be stuck. The way around this is to wrap our Text
value with a
newtype
.
newtype CSPNonce = CSPNonce { unCSPNonce :: Text }
deriving (Eq, Ord)
Making this change necessitates we also change the functions we wrote earlier.
Our generator will not only have to create a value of the correct type, but it
will also need to add that value to the per-request cache. Since this function
will potentially be called multiple times during a single request, we should
make it idempotent by having it first check the cache, and using the CSPNonce
value if it already exists. Again, type inference is working here (notice we
specified the type in the function’s type signature) to magically select the
correct cache key.
generateRequestNonce :: MonadHandler m => m CSPNonce
generateRequestNonce = do
mNonce <- cacheGet
case mNonce of
Just nonce -> return nonce
Nothing -> do
uuid <- liftIO nextRandom
let nonce = mkNonce uuid
cacheSet nonce
return nonce
where
mkNonce =
CSPNonce . decodeUtf8 . B64.encode . toASCIIBytes
I won’t show the final implementations of the other functions, because it’s
mostly a case changing Text
to CSPNonce
and unwrapping that newtype in a
couple of places. If you’re following along at home, you can just let the
compiler guide you to the end. There are a couple of final steps for a more
complete implementation, but these do nothing to further explain the mechanics
of the above approach and are left as an exercise for the reader. They pertain
to CSP not working correctly in [Mobile] Safari. More specifically:
- Safari uses a non-standard header name. Use the
ua-parse
library to determine the user’s browser and set the header name appropriately. - Safari does not support the
'strict-dynamic'
directive, so you need host-based whitelisting anyway. Use another newtype and cache a set ofCSPHost
values.
When you do implement your own CSP, be sure to check your syntax with Google’s
CSP evaluator, and also make good use of the report-uri
directive so you
can see if some of your content is unintentionally blocked for some users in
production.