packages feed

shibuya-metrics-0.11.0.0: src/Shibuya/Metrics/Cors.hs

-- | Opt-in, exact-origin browser response policy. This is not authentication.
module Shibuya.Metrics.Cors
  ( corsMiddleware,
    isAllowedOrigin,
    isValidOrigin,
    originGuard,
  )
where

import Data.Aeson (encode, object, (.=))
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Char (isAsciiLower, isAsciiUpper, isSpace, toLower)
import Data.List (foldl')
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding (encodeUtf8)
import Network.HTTP.Types (HeaderName, RequestHeaders, ResponseHeaders, status204, status403)
import Network.Wai (Middleware, mapResponseHeaders, requestHeaders, requestMethod, responseLBS)
import Network.WebSockets qualified as WS
import Shibuya.Metrics.Error (errorResponse)

-- | Check configured origin syntax. Entries must still be written exactly as the
-- browser serializes them; matching does not normalize scheme, host or port.
isValidOrigin :: Text -> Bool
isValidOrigin origin =
  not (Text.any isSpace origin)
    && case Text.breakOn "://" origin of
      (scheme, suffix) ->
        case Text.uncons scheme of
          Nothing -> False
          Just (first, rest) ->
            asciiLetter first
              && Text.all (\c -> asciiLetter c || asciiDigit c || c `elem` ("+-." :: String)) rest
              && not (Text.null suffix)
              && let authority = Text.drop 3 suffix
                  in not (Text.null authority)
                       && Text.all (\c -> c > '\x20' && c < '\x7f' && c `notElem` ("/?#,@\\" :: String)) authority
  where
    asciiLetter c = isAsciiLower c || isAsciiUpper c
    asciiDigit c = c >= '0' && c <= '9'

-- | Compile the allow list once when constructing an application. Matching is
-- byte-for-byte: no case folding, default-port removal or prefix matching.
isAllowedOrigin :: [Text] -> ByteString -> Bool
isAllowedOrigin origins = (`Set.member` compiled)
  where
    compiled = Set.fromList $ map encodeUtf8 origins

-- | Apply the same exact-origin policy before the inner WebSocket application
-- acquires a slot. Empty policy and headerless non-browser clients pass through.
-- A repeated Origin is refused even if each value individually matches.
originGuard :: [Text] -> WS.ServerApp -> WS.ServerApp
originGuard [] = id
originGuard origins = \app pending ->
  let headers = WS.requestHeaders (WS.pendingRequest pending)
   in if not (hasHeader "Origin" headers) || maybe False permits (singleHeader "Origin" headers)
        then app pending
        else
          WS.rejectRequestWith
            pending
            WS.defaultRejectRequest
              { WS.rejectCode = 403,
                WS.rejectMessage = "Forbidden",
                WS.rejectHeaders = [("Content-Type", "application/json"), ("Vary", "Origin")],
                WS.rejectBody =
                  LBS.toStrict $
                    encode $
                      object
                        ["error" .= ("Origin not allowed" :: Text), "code" .= ("origin_not_allowed" :: Text)]
              }
  where
    permits = isAllowedOrigin origins

-- | Disabled by an empty list. When enabled, ordinary reads keep their status and
-- body, and every response varies by Origin. Only explicit allowed origins get
-- response permission. Preflights validate methods and header names before 204.
-- This middleware does not authorize mutation; control routes must validate the
-- actual request's single Origin before acting, even when CORS is disabled.
corsMiddleware :: [Text] -> Middleware
corsMiddleware [] = id
corsMiddleware origins = \app req respond ->
  let headers = requestHeaders req
      suppliedOrigin = singleHeader "Origin" headers
      allowed = maybe False permits suppliedOrigin
      preflight =
        requestMethod req == "OPTIONS"
          && hasHeader "Origin" headers
          && hasHeader "Access-Control-Request-Method" headers
      decorate =
        mapResponseHeaders $
          if preflight
            then mergeVary preflightVary
            else decorateHeaders (if allowed then suppliedOrigin else Nothing)
   in if preflight
        then respond $ decorate $ case () of
          _
            | not allowed -> errorResponse status403 "Origin not allowed" "origin_not_allowed" []
            | maybe True (`notElem` ["GET", "POST", "OPTIONS"]) (singleHeader "Access-Control-Request-Method" headers) ->
                errorResponse status403 "CORS method not allowed" "cors_method_not_allowed" []
            | otherwise -> case requestedHeaders headers of
                Nothing -> errorResponse status403 "CORS headers not allowed" "cors_headers_not_allowed" []
                Just names ->
                  responseLBS
                    status204
                    [ ("Access-Control-Allow-Origin", maybe "" id suppliedOrigin),
                      ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"),
                      ("Access-Control-Allow-Headers", names),
                      ("Access-Control-Max-Age", "600")
                    ]
                    ""
        else app req (respond . decorate)
  where
    permits = isAllowedOrigin origins

singleHeader :: HeaderName -> RequestHeaders -> Maybe ByteString
singleHeader name headers = case [value | (key, value) <- headers, key == name] of
  [value] -> Just value
  _ -> Nothing

hasHeader :: HeaderName -> RequestHeaders -> Bool
hasHeader name = any ((== name) . fst)

requestedHeaders :: RequestHeaders -> Maybe ByteString
requestedHeaders headers = case [value | (key, value) <- headers, key == "Access-Control-Request-Headers"] of
  [] -> Just "Content-Type"
  values ->
    let names = concatMap (map trim . BS.split ',') values
     in if all (not . BS.null) values && not (null names) && all validToken names
          then Just (BS.intercalate ", " names)
          else Nothing
  where
    validToken token = not (BS.null token) && token /= "*" && BS.all tokenChar token
    tokenChar c =
      isAsciiLower c
        || isAsciiUpper c
        || (c >= '0' && c <= '9')
        || c `elem` ("!#$%&'*+-.^_`|~" :: String)

trim :: ByteString -> ByteString
trim = BS.dropWhileEnd horizontalSpace . BS.dropWhile horizontalSpace
  where
    horizontalSpace c = c == ' ' || c == '\t'

preflightVary :: [ByteString]
preflightVary = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"]

-- Preserve unrelated Vary tokens and collapse all Vary fields, including a
-- pre-existing wildcard. Strip stale permission/credential headers from hosts.
decorateHeaders :: Maybe ByteString -> ResponseHeaders -> ResponseHeaders
decorateHeaders origin headers =
  mergeVary ["Origin"] (filter (\(name, _) -> name `notElem` permissionHeaders) headers)
    <> maybe [] (\value -> [("Access-Control-Allow-Origin", value)]) origin
  where
    permissionHeaders =
      [ "Access-Control-Allow-Origin",
        "Access-Control-Allow-Credentials",
        "Access-Control-Allow-Methods",
        "Access-Control-Allow-Headers",
        "Access-Control-Max-Age",
        "Access-Control-Expose-Headers"
      ]

mergeVary :: [ByteString] -> ResponseHeaders -> ResponseHeaders
mergeVary required headers =
  let existing = concatMap (map trim . BS.split ',') [value | (name, value) <- headers, name == "Vary"]
      tokens = foldl' addToken [] (filter (not . BS.null) existing <> required)
      vary = if "*" `elem` tokens then "*" else BS.intercalate ", " tokens
      kept = filter ((/= "Vary") . fst) headers
   in kept <> [("Vary", vary)]
  where
    addToken tokens token
      | any (\existing -> BS.map toLower existing == BS.map toLower token) tokens = tokens
      | otherwise = tokens <> [token]