github-tools-0.1.2: src/GitHub/WebHook/Handler.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TupleSections #-}
module GitHub.WebHook.Handler
( Handler (..)
, Error (..)
, runHandler
, removeNulls
) where
import Crypto.Hash (HMAC, SHA1, digestToHexByteString, hmac,
hmacGetDigest)
import Data.Aeson (ToJSON (..), Value (..),
eitherDecodeStrict')
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Aeson.Types (parseEither)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BC8
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (decodeUtf8)
import Data.UUID (UUID, fromASCIIBytes)
import qualified Data.Vector as Vector
import GitHub.Types
data Handler m = Handler
{ hSecretKeys :: [String]
-- ^ Secret keys which are used to authenticate the incoming request.
-- If the list is empty then no authentication is required. The handler
-- tries each key until it finds one which works. This makes it easier
-- to rotate keys because you can have multiple ones active at any given
-- point in time.
, hBody :: m ByteString
-- ^ Action which is used to read the request body.
, hHeader :: ByteString -> m (Maybe ByteString)
-- ^ Action which is used to retrieve a particular header from the
-- request.
}
data Error
= InvalidRequest
-- ^ The incoming request is not well-formed. If that happens it means
-- GitHub screwed something up, or changed the format of the request.
| ParseError !Text
-- ^ The request looks valid but we failed to parse the payload. This
-- could be because our parsing code is wrong, or because GitHub added
-- a new event type which we don't handle yet.
| IncompleteParse Value Payload
-- ^ The request looks valid but we failed to parse the payload. This
-- could be because our parsing code is wrong, or because GitHub added
-- a new event type which we don't handle yet.
| UnsignedRequest
-- ^ We were expecting a signed request but no signature was included.
-- Such requests are rejected beause we don't want to accept requests from
-- untrusted sources.
| InvalidSignature
-- ^ A signature was included in the request but it did not match the
-- secret key which was providid to the handler. Usually points to
-- a configuration error on either end.
toParseError :: String -> Either Error Payload
toParseError = Left . ParseError . Text.pack
removeNulls :: ToJSON a => a -> Value
removeNulls = go . toJSON
where
go (Array x) = Array . Vector.map go $ x
go (Object x) = Object . KeyMap.map go . KeyMap.filter (not . isEmpty) $ x
go x = x
isEmpty Null = True
isEmpty (Array x) = null x
isEmpty _ = False
toSuccess :: Value -> Payload -> Either Error Payload
toSuccess value payload =
if removeNulls payload == removeNulls value
then Right payload
else Left $ IncompleteParse value payload
verifySecretKey :: ByteString -> ByteString -> String -> Bool
verifySecretKey rawBody sig key = sig == ("sha1=" <> digestToHexByteString
(hmacGetDigest (hmac (BC8.pack key) rawBody :: HMAC SHA1)))
runHandler :: (Applicative m, Monad m) => Handler m -> m (Either Error (UUID, Payload))
runHandler h = do
mbDelivery <- (fromASCIIBytes =<<) <$> hHeader h "X-GitHub-Delivery"
res <- do
rawBody <- hBody h
mbSignature <- hHeader h "X-Hub-Signature"
let authenticatedBody
= case (hSecretKeys h, mbSignature) of
-- No secret key and no signature. Pass along the body
-- unverified.
([], Nothing) -> Right rawBody
-- Signature is available but no secret keys to verify it.
-- This is not a fatal error, we can still process the event.
([], Just _) -> Right rawBody
-- Secret keys are available but the request is not signed.
-- Reject the request.
(_, Nothing) -> Left UnsignedRequest
-- Both the signature and secret keys are available. Verify
-- the signature with the first key which works, otherwise
-- reject the request.
(secretKeys, Just sig) ->
if any (verifySecretKey rawBody sig) secretKeys
then Right rawBody
else Left InvalidSignature
mbEventName <- hHeader h "X-GitHub-Event"
pure $ do
eventName <- maybe (Left InvalidRequest) Right mbEventName
body <- authenticatedBody
case eitherDecodeStrict' body of
Left e -> toParseError e
Right value -> either toParseError (toSuccess value) $
parseEither (webhookPayloadParser $ decodeUtf8 eventName) value
pure $ case mbDelivery of
Nothing -> Left InvalidRequest
Just uuid -> fmap (uuid,) res