Files
ChuBlog/chusql-web/src/ChuSQL/Web/Secure.hs
T

100 lines
3.4 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Secure (
securityHeaders,
contentSecurityPolicy,
sameOriginOnly,
bodyLimit,
bodyLimitDynamic,
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import Data.Text (Text)
import qualified Data.Text.Encoding as TE
import Network.HTTP.Types (
Header,
Method,
hContentLength,
hOrigin,
methodPost,
status403,
status413,
)
import Network.Wai (Middleware, Request (..), mapResponseHeaders, responseLBS)
-- 三个 WAI 安全中间件:响应头、同源校验、请求体上限。
-- | 所有响应都带上的安全头
securityHeaders :: Middleware
securityHeaders app req respond =
app req (respond . mapResponseHeaders (\headers -> filter (\(name, _) -> name /= "Content-Security-Policy" || lookup name headers == Nothing) extraHeaders ++ headers))
-- | 只给编辑器样式发随机 nonce
contentSecurityPolicy :: Maybe Text -> Text
contentSecurityPolicy nonce =
"default-src 'self'; script-src 'self'; style-src 'self'"
<> maybe "" (\value -> " 'nonce-" <> value <> "'") nonce
<> "; font-src 'self'; worker-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
-- | 统一加的安全响应头
extraHeaders :: [Header]
extraHeaders =
[ ("Content-Security-Policy", TE.encodeUtf8 (contentSecurityPolicy Nothing))
, ("X-Content-Type-Options", "nosniff")
, ("X-Frame-Options", "DENY")
, ("Referrer-Policy", "no-referrer")
, ("Cache-Control", "no-store")
]
-- | 变更类请求要求同源,无 Origin 放行
sameOriginOnly :: Middleware
sameOriginOnly app req respond =
if isMutating (requestMethod req) && not (originAllowed req)
then respond (responseLBS status403 [] "cross-origin request rejected")
else app req respond
-- | 这个方法算不算会改数据的
isMutating :: Method -> Bool
isMutating m = m `elem` [methodPost, "PUT", "PATCH", "DELETE"]
-- | Origin 与 Host 是否一致
originAllowed :: Request -> Bool
originAllowed req = case lookup hOrigin (requestHeaders req) of
Nothing -> True
Just origin -> case lookup "Host" (requestHeaders req) of
Nothing -> False
Just host -> authorityOf origin == host || authorityOf origin == stripPort host
-- | 取出 host:port,去掉 scheme 与路径
authorityOf :: BS.ByteString -> BS.ByteString
authorityOf raw =
let noScheme = case BSC.breakSubstring "//" raw of
(_, rest) | not (BSC.null rest) -> BSC.drop 2 rest
_ -> raw
in fst (BSC.break (== '/') noScheme)
-- | 去掉端口 host:8080 -> host
stripPort :: BS.ByteString -> BS.ByteString
stripPort = fst . BSC.break (== ':')
-- | 按 Content-Length 拦下过大的请求体
bodyLimit :: Int -> Middleware
bodyLimit limit = bodyLimitDynamic (pure limit)
-- | 同上,但上限每次请求现取
bodyLimitDynamic :: IO Int -> Middleware
bodyLimitDynamic limitOf app req respond = do
limit <- limitOf
if declaredTooBig limit req
then respond (responseLBS status413 [] "request body too large")
else app req respond
-- | Content-Length 是否超限
declaredTooBig :: Int -> Request -> Bool
declaredTooBig limit req = case lookup hContentLength (requestHeaders req) of
Nothing -> False
Just v -> case BSC.readInt v of
Just (n, rest) -> BSC.null rest && n > limit
Nothing -> False