{-# LANGUAGE OverloadedStrings #-}
-- | HTTP-level pieces shared by the executable and the test suite:
-- response security headers, the verified CSP, static MIME types,
-- repository-name validation and log-line sanitising. Kept in the
-- library (not in @app/Main.hs@) so the test suite can reach them.
module DarcsWeb.Http
( securityHeaderList
, securityHeaders
, cspHeader
, mimeType
, staticCacheControl
, isValidRepoName
, sanitizeLogLine
) where
import qualified Data.ByteString.Char8 as BC
import Data.Char (isControl)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Network.HTTP.Types (Header)
import Network.Wai (Middleware, modifyResponse, mapResponseHeaders)
import qualified CspPure
-- | Map file extension to MIME type for static assets
mimeType :: String -> TL.Text
mimeType ".css" = "text/css; charset=utf-8"
mimeType ".js" = "application/javascript"
mimeType ".png" = "image/png"
mimeType ".jpg" = "image/jpeg"
mimeType ".jpeg" = "image/jpeg"
mimeType ".gif" = "image/gif"
mimeType ".svg" = "image/svg+xml"
mimeType ".ico" = "image/x-icon"
mimeType ".woff" = "font/woff"
mimeType ".woff2" = "font/woff2"
mimeType _ = "application/octet-stream"
-- | Cache policy for static assets. Not @immutable@: the stylesheet URL
-- is unversioned, so browsers must be able to revalidate after a
-- redesign ships.
staticCacheControl :: TL.Text
staticCacheControl = "public, max-age=3600"
-- | Build CSP header using formally verified builder from Rocq.
-- The builder guarantees no semicolon injection within directive values.
cspHeader :: BC.ByteString
cspHeader = BC.pack $ CspPure.build_csp
[ ("default-src", "'none'")
, ("style-src", "'self'")
, ("img-src", "'self'")
, ("frame-ancestors", "'none'")
, ("base-uri", "'none'")
, ("form-action", "'none'")
]
-- | Security headers added to every response. HSTS is deliberately left
-- to the TLS-terminating proxy, which knows whether TLS is in use.
securityHeaderList :: [Header]
securityHeaderList =
[ ("X-Content-Type-Options", "nosniff")
, ("X-Frame-Options", "DENY")
, ("Referrer-Policy", "strict-origin-when-cross-origin")
, ("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
, ("Content-Security-Policy", cspHeader)
, ("X-Robots-Tag", "noindex, nofollow")
, ("Cross-Origin-Opener-Policy", "same-origin")
, ("Cross-Origin-Resource-Policy", "same-origin")
]
-- | WAI middleware that adds security response headers to every response.
securityHeaders :: Middleware
securityHeaders = modifyResponse $ mapResponseHeaders (++ securityHeaderList)
-- | Repository names must be a single, non-hidden path component without
-- control characters (NUL would be truncated by C path APIs).
isValidRepoName :: T.Text -> Bool
isValidRepoName name =
not (T.null name
|| T.any isControl name
|| T.any (== '/') name || T.any (== '\\') name
|| ".." `T.isInfixOf` name
|| T.isPrefixOf "." name)
-- | Replace control characters so request-derived text cannot forge or
-- split log lines.
sanitizeLogLine :: String -> String
sanitizeLogLine = map (\c -> if isControl c then ' ' else c)