packages feed

yesod-csp 0.1.1.0 → 0.2.0.0

raw patch · 5 files changed

+235/−32 lines, 5 filesdep +attoparsecdep +mono-traversabledep +sybPVP ok

version bump matches the API change (PVP)

Dependencies added: attoparsec, mono-traversable, syb, template-haskell, uniplate

API changes (from Hackage documentation)

+ Yesod.Csp: MetaSource :: Text -> Source
+ Yesod.Csp: data EscapedURI
+ Yesod.Csp: escapeAndParseURI :: Text -> Maybe EscapedURI
+ Yesod.Csp: instance Data.Data.Data Yesod.Csp.Directive
+ Yesod.Csp: instance Data.Data.Data Yesod.Csp.EscapedURI
+ Yesod.Csp: instance Data.Data.Data Yesod.Csp.SandboxOptions
+ Yesod.Csp: instance Data.Data.Data Yesod.Csp.Source
+ Yesod.Csp: instance GHC.Classes.Eq Yesod.Csp.Directive
+ Yesod.Csp: instance GHC.Classes.Eq Yesod.Csp.EscapedURI
+ Yesod.Csp: instance GHC.Classes.Eq Yesod.Csp.SandboxOptions
+ Yesod.Csp: instance GHC.Show.Show Yesod.Csp.Directive
+ Yesod.Csp: instance GHC.Show.Show Yesod.Csp.EscapedURI
+ Yesod.Csp: instance GHC.Show.Show Yesod.Csp.SandboxOptions
+ Yesod.Csp: instance GHC.Show.Show Yesod.Csp.Source
+ Yesod.Csp.Example: cdn :: Source
+ Yesod.Csp.Example: getExample8R :: Handler Html
+ Yesod.Csp.TH: csp :: QuasiQuoter
+ Yesod.Csp.TH: directive :: Parser DirectiveList
+ Yesod.Csp.TH: reportUri :: Parser Directive
+ Yesod.Csp.TH: sandbox :: Parser Directive
+ Yesod.Csp.TH: sandboxOptions :: Parser SandboxOptions
+ Yesod.Csp.TH: source :: Parser Source
+ Yesod.Csp.TH: withSourceList :: Parser Directive
- Yesod.Csp: Host :: URI -> Source
+ Yesod.Csp: Host :: EscapedURI -> Source
- Yesod.Csp: ReportUri :: URI -> Directive
+ Yesod.Csp: ReportUri :: EscapedURI -> Directive

Files

src/Yesod/Csp.hs view
@@ -1,9 +1,12 @@-{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE OverloadedStrings  #-} -- | Add <http://content-security-policy.com/ CSP> headers to Yesod apps. -- This helps reduce the risk of exposure to XSS and bad assets. module Yesod.Csp (   cspPolicy   , getCspPolicy+  , EscapedURI+  , escapeAndParseURI   , DirectiveList   , Directive(..)   , SourceList@@ -11,9 +14,11 @@   , SandboxOptions(..)   ) where +import           Data.Data          (Data) import           Data.List.NonEmpty-import           Data.Text          hiding (filter, null)-import qualified Data.Text          as T+import qualified Data.Sequences     as S+import           Data.Text+import           Data.Typeable      (Typeable) import           Network.URI import           Yesod.Core @@ -34,24 +39,48 @@ getCspPolicy :: DirectiveList -> Text getCspPolicy = directiveListToHeader -withSpaces :: [Text] -> Text-withSpaces = T.intercalate " "+newtype EscapedURI = EscapedURI { uri :: URI } deriving (Eq, Data, Typeable) +instance Show EscapedURI where+  show x = show (uri x)++-- | Escapes ';' '\'' and ' ', and parses to URI+escapeAndParseURI :: Text -> Maybe EscapedURI+escapeAndParseURI = fmap EscapedURI . parseURI . escapeURIString f . unpack+  where f :: Char -> Bool+        f = not . flip elem toEscape+        toEscape :: String+        toEscape = ";'* "++directiveListToHeader :: DirectiveList -> Text+directiveListToHeader = S.intercalate "; " . fmap textDirective+ w :: Text -> SourceList -> Text w = wrap  wrap :: Text -> SourceList -> Text wrap k x = mconcat [k, " ", textSourceList x] +textSourceList :: SourceList -> Text+textSourceList = S.unwords . toList . filtered+  where filtered = fmap textSource . filterOut++-- * and none should be alone if present+filterOut :: SourceList -> SourceList+filterOut x | Wildcard `elem` x = Wildcard :| []+filterOut x | None `elem` x = None :| []+            | otherwise = x+ -- | Represents a location from which assets may be loaded. data Source = Wildcard               | None               | Self               | DataScheme-              | Host URI+              | Host EscapedURI               | Https               | UnsafeInline-              | UnsafeEval deriving (Eq)+              | UnsafeEval+              | MetaSource Text deriving (Eq, Show, Data, Typeable)  -- | A list of allowed sources for a directive. type SourceList = NonEmpty Source@@ -65,16 +94,7 @@ textSource Https = "https:" textSource UnsafeInline = "'unsafe-inline'" textSource UnsafeEval = "'unsafe-eval'"--textSourceList :: SourceList -> Text-textSourceList = withSpaces . toList . filtered-  where filtered = fmap textSource . filterOut---- * and none should be alone if present-filterOut :: SourceList -> SourceList-filterOut x | Wildcard `elem` x = Wildcard :| []-filterOut x | None `elem` x = None :| []-            | otherwise = x+textSource (MetaSource _) = ""  -- | A list of restrictions to apply. type DirectiveList = [Directive]@@ -92,16 +112,13 @@                  | FrameSrc SourceList                  -- | Applies a sandbox to the result. <http://content-security-policy.com/ See here> for more info.                  | Sandbox [SandboxOptions]-                 | ReportUri URI+                 | ReportUri EscapedURI deriving (Eq, Show, Data, Typeable)  -- | Configuration options for the sandbox. data SandboxOptions = AllowForms                       | AllowScripts                       | AllowSameOrigin-                      | AllowTopNavigation--directiveListToHeader :: DirectiveList -> Text-directiveListToHeader = T.intercalate "; " . fmap textDirective+                      | AllowTopNavigation deriving (Eq, Show, Data, Typeable)  textDirective :: Directive -> Text textDirective (DefaultSrc x) = w "default-src" x@@ -115,7 +132,7 @@ textDirective (FrameSrc x) =  w "frame-src" x textDirective (ReportUri t) = mconcat ["report-uri ", (pack . show) t] textDirective (Sandbox []) = "sandbox"-textDirective (Sandbox s) = mconcat ["sandbox ", withSpaces . fmap textSandbox $ s]+textDirective (Sandbox s) = mconcat ["sandbox ", S.unwords . fmap textSandbox $ s]   where textSandbox AllowForms = "allow-forms"         textSandbox AllowScripts = "allow-scripts"         textSandbox AllowSameOrigin = "allow-same-origin"
src/Yesod/Csp/Example.hs view
@@ -5,11 +5,12 @@ -- | Assorted examples demonstrating different policies. module Yesod.Csp.Example where +import           Data.Generics.Uniplate.Data import           Data.List.NonEmpty import           Data.Maybe-import           Network.URI-import           Yesod              hiding (get)+import           Yesod                       hiding (get) import           Yesod.Csp+import           Yesod.Csp.TH  data Example = Example mkYesod "Example" [parseRoutes|@@ -20,6 +21,7 @@   /5 Example5R GET   /6 Example6R GET   /7 Example7R GET POST+  /8 Example8R GET |]  instance Yesod Example@@ -43,7 +45,7 @@ -- | Allows images from a certain uri. getExample3R :: Handler Html getExample3R = do-  let dom = fromJust (parseURI "http://httpbin.org")+  let dom = fromJust (escapeAndParseURI "http://httpbin.org")   cspPolicy [ImgSrc (Host dom :| [])]   defaultLayout $     [whamlet|@@ -94,6 +96,18 @@   cspPolicy [Sandbox [AllowForms]]   defaultLayout $     [whamlet|yayyy|]++cdn :: Source+cdn = Host (fromJust $ escapeAndParseURI "https://cdn.com")++getExample8R :: Handler Html+getExample8R = do+  let policy = [csp|script-src 'self'|]+  cspPolicy $ addCdn <$> policy+  defaultLayout [whamlet|wooo|]+  where addCdn = transform f+        f (ScriptSrc x) = ScriptSrc $ cdn <| x+        f x = x  -- | Run a webserver to serve these examples at /1, /2, etc. runExamples :: IO ()
+ src/Yesod/Csp/TH.hs view
@@ -0,0 +1,134 @@+{-# LANGUAGE OverloadedStrings #-}++module Yesod.Csp.TH (+    source+    , withSourceList+    , reportUri+    , sandbox+    , sandboxOptions+    , directive+    , csp+  ) where++import           Control.Applicative+import           Data.Attoparsec.Text+import           Data.Generics+import           Data.List.NonEmpty        (NonEmpty (..))+import qualified Data.Text                 as T+import qualified Language.Haskell.TH       as TH+import           Language.Haskell.TH.Quote+import           Yesod.Csp++csp :: QuasiQuoter+csp = QuasiQuoter {+      quoteExp = \str -> do+        let c = parseOnly directive (T.pack str)+        case c of+          Left err -> error $ "csp parsing error: " ++ err -- compile time error+          Right x -> dataToExpQ (const Nothing `extQ` antiCsp) x+    , quotePat  = undefined+    , quoteType = undefined+    , quoteDec  = undefined+    }++antiCsp :: Source -> Maybe (TH.Q TH.Exp)+antiCsp (MetaSource x) = Just . return $ TH.AppE (TH.ConE (TH.mkName "Host")) (TH.VarE (TH.mkName (T.unpack x)))+antiCsp _ = Nothing++metaSource :: Parser Source+metaSource = do+  _ <- char '$'+  x <- many (digit <|> letter)+  return $ MetaSource (T.pack x)++source :: Parser Source+source = wildcard+         <|> none+         <|> self+         <|> dataScheme+         <|> https+         <|> host+         <|> unsafeInline+         <|> unsafeEval+         <|> metaSource+  where wildcard = string "*" *> pure Wildcard+        none = string "'none'" *> pure None+        self = string "'self'" *> pure Self+        dataScheme = string "data:" *> pure DataScheme+        host :: Parser Source+        host = do+          u <- takeTill separated+          case escapeAndParseURI u of+            Nothing -> fail "host"+            Just uri -> return $ Host uri+        https = do+          _ <- string "https:"+          c <- peekChar+          case c of+            (Just ' ') -> return Https+            (Just ';') -> return Https+            Nothing -> return Https+            _ -> fail "https"+        unsafeInline = string "unsafe-inline" *> pure UnsafeInline+        unsafeEval = string "unsafe-eval" *> pure UnsafeEval++separated :: Char -> Bool+separated x = x == ';' || x == ' '++-- Safe to head and tail these sources as they come from the `sepBy1` combinator+mkWithSource :: (NonEmpty Source -> Directive) -> [Source] -> Parser Directive+mkWithSource f x = pure $ f (head x :| tail x)++withSourceList :: Parser Directive+withSourceList = defaultSrc+                 <|> scriptSrc+                 <|> scriptSrc+                 <|> styleSrc+                 <|> imgSrc+                 <|> connectSrc+                 <|> fontSrc+                 <|> objectSrc+                 <|> mediaSrc+                 <|> frameSrc+  where defaultSrc = d "default-src" DefaultSrc+        scriptSrc = d "script-src" ScriptSrc+        styleSrc = d "style-src" StyleSrc+        imgSrc = d "img-src" ImgSrc+        connectSrc = d "connect-src" ConnectSrc+        fontSrc = d "font-src" FontSrc+        objectSrc = d "object-src" ObjectSrc+        mediaSrc = d "media-src" MediaSrc+        frameSrc = d "frame-src" FrameSrc+        d x y = string x >> s >> slist >>= mkWithSource y+        slist = sepBy1 source (char ' ')+        s = string " "++reportUri :: Parser Directive+reportUri = do+  _ <- string "report-uri"+  _ <- string " "+  u <- takeTill separated+  case escapeAndParseURI u of+    Nothing -> fail "reportUri" -- n.b. compile time error+    Just uri -> return $ ReportUri uri++sandbox :: Parser Directive+sandbox = do+  _ <- string "sandbox"+  _ <- string " "+  x <- sepBy sandboxOptions (char ' ')+  return $ Sandbox x++sandboxOptions :: Parser SandboxOptions+sandboxOptions = allowForms+                 <|> allowScripts+                 <|> allowSameOrigin+                 <|> allowTopNavigation+  where allowForms = string "allow-forms" *> pure AllowForms+        allowScripts = string "allow-scripts" *> pure AllowScripts+        allowSameOrigin = string "allow-same-origin" *> pure AllowSameOrigin+        allowTopNavigation = string "allow-top-navigation" *> pure AllowTopNavigation++directive :: Parser DirectiveList+directive = sepBy d (string "; ") <* endOfInput+  where d = withSourceList <|> reportUri <|> sandbox
test/Test.hs view
@@ -4,12 +4,13 @@ {-# LANGUAGE TemplateHaskell       #-} {-# LANGUAGE TypeFamilies          #-} +import           Data.Attoparsec.Text import           Data.List.NonEmpty import           Data.Maybe-import           Network.URI import           Test.Hspec-import           Yesod              hiding (get)+import           Yesod                hiding (get) import           Yesod.Csp+import           Yesod.Csp.TH import           Yesod.Test  data Test = Test@@ -29,13 +30,19 @@       let header = getCspPolicy [ScriptSrc (Self :| []), StyleSrc (Https :| [Self])]       assertEqual "simple header" header "script-src 'self'; style-src https: 'self'"     yit "works with domains" $ do-      let dom = fromJust $ parseURI "https://foo.com"+      let dom = fromJust $ escapeAndParseURI "https://foo.com/bar *"           header = getCspPolicy [ScriptSrc (Host dom :| [])]-      assertEqual "foo.com script-src" header "script-src https://foo.com"+      assertEqual "foo.com script-src" header "script-src https://foo.com/bar%20%2A"     yit "works with report_uri" $ do-      let dom = fromJust $ parseURI "https://foo.com"+      let dom = fromJust $ escapeAndParseURI "https://foo.com"           header = getCspPolicy [ReportUri dom]       assertEqual "report-uri" header "report-uri https://foo.com"+    yit "enforces wildcards" $ do+      let header = getCspPolicy [ScriptSrc (Wildcard :| [Https])]+      assertEqual "* should be alone" header "script-src *"+    yit "enforces nones" $ do+      let header = getCspPolicy [ScriptSrc (None :| [Https])]+      assertEqual "none should be alone" header "script-src 'none'"   ydescribe "Headers" $     yit "get set" $ do       get HomeR@@ -47,3 +54,26 @@     yit "works when not empty" $ do       let header = getCspPolicy [Sandbox [AllowForms, AllowScripts]]       assertEqual "empty sandbox" header "sandbox allow-forms allow-scripts"+  ydescribe "Parsing" $ do+    yit "works" $ do+      assertEqual "*" (parseOnly source "*") (Right Wildcard)+      assertEqual "none" (parseOnly source "'none'") (Right None)+      assertEqual "self" (parseOnly source "'self'") (Right Self)+      assertEqual "data:" (parseOnly source "data:") (Right DataScheme)+      assertEqual "https://foo.com" (parseOnly source "https://foo.com") (Right $ Host (fromJust (escapeAndParseURI "https://foo.com")))+      assertEqual "https:" (parseOnly source "https:") (Right Https)+      assertEqual "unsafe-inline" (parseOnly source "unsafe-inline") (Right UnsafeInline)+      assertEqual "unsafe-eval" (parseOnly source "unsafe-eval") (Right UnsafeEval)+      assertEqual "default-src self data:" (parseOnly withSourceList "default-src 'self' data:") (Right $ DefaultSrc (Self :| [DataScheme]))+      assertEqual "report-uri http://hello.com" (parseOnly reportUri "report-uri http://hello.com") (Right $ ReportUri (fromJust (escapeAndParseURI "http://hello.com")))+      assertEqual "sandbox allow-forms allow-scripts" (parseOnly sandbox "sandbox allow-forms allow-scripts") (Right $ Sandbox [AllowForms, AllowScripts])+    yit "works with lists" $ do+      let result = [ImgSrc $ Self :| [Https], ScriptSrc $ Host (fromJust $ escapeAndParseURI "https://foo.com") :| []]+      assertEqual "scripts and images" (parseOnly directive "img-src 'self' https:; script-src https://foo.com") (Right result)+      let result = [ImgSrc $ Self :| [DataScheme, Host (fromJust $ escapeAndParseURI "https://foo.com")]]+      assertEqual "data and hosts" (parseOnly directive "img-src 'self' data: https://foo.com") (Right result)+    yit "works with th" $ do+      let result = [ImgSrc $ Self :| [Https], ScriptSrc $ Host (fromJust $ escapeAndParseURI "https://foo.com") :| []]+      assertEqual "with th" [csp|img-src 'self' https:; script-src https://foo.com|] result+      let url = fromJust $ escapeAndParseURI "https://foo.com"+      assertEqual "with antiquoting" [csp|img-src 'self' https:; script-src $url|] result
yesod-csp.cabal view
@@ -2,7 +2,7 @@ -- documentation, see http://haskell.org/cabal/users-guide/  name:                yesod-csp-version:             0.1.1.0+version:             0.2.0.0 synopsis:            Add CSP headers to Yesod apps description:         Add CSP headers to Yesod apps. This helps reduce exposure to XSS attacks and bad assets. license:             MIT@@ -20,6 +20,7 @@ library   exposed-modules:     Yesod.Csp                        , Yesod.Csp.Example+                       , Yesod.Csp.TH   -- other-extensions:       build-depends:       base < 5                        , text@@ -27,6 +28,11 @@                        , semigroups                        , network-uri                        , yesod+                       , mono-traversable+                       , attoparsec+                       , template-haskell+                       , uniplate+                       , syb   hs-source-dirs:      src   default-language:    Haskell2010   ghc-options:         -Wall@@ -43,4 +49,6 @@                        , yesod                        , hspec                        , network-uri+                       , attoparsec+                       , template-haskell   default-language:    Haskell2010