diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a04cc9..3861cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.3.0.0 (2025-04-16) +* [#149](https://github.com/MercuryTechnologies/slack-web/pull/149) + Support request verification of non-JSON payloads. + # 2.2.0.0 (2025-03-21) * [#145](https://github.com/MercuryTechnologies/slack-web/pull/145) Implement `conversations.info` API method. diff --git a/slack-web.cabal b/slack-web.cabal index c86bedc..00133a1 100644 --- a/slack-web.cabal +++ b/slack-web.cabal @@ -1,6 +1,6 @@ cabal-version: 3.0 name: slack-web -version: 2.2.0.0 +version: 2.3.0.0 build-type: Simple diff --git a/src/Web/Slack/Experimental/RequestVerification.hs b/src/Web/Slack/Experimental/RequestVerification.hs index 0afc02b..114a698 100644 --- a/src/Web/Slack/Experimental/RequestVerification.hs +++ b/src/Web/Slack/Experimental/RequestVerification.hs @@ -2,9 +2,13 @@ module Web.Slack.Experimental.RequestVerification ( SlackSigningSecret (..), SlackSignature (..), SlackRequestTimestamp (..), - SlackVerificationFailed (..), + SlackVerificationFailed, + SlackVerificationFailed' (..), validateRequest, validateRequest', + validateRequestRaw, + validateRequestRaw', + decodeRequestJSON, ) where import Crypto.Hash (SHA256, digestFromByteString) @@ -19,7 +23,7 @@ import Web.HttpApiData (FromHttpApiData (..)) import Web.Slack.Prelude -- | Slack generated Signing Secret placed into configuration. --- See https://api.slack.com/authentication/verifying-requests-from-slack#signing_secrets_admin_page +-- See newtype SlackSigningSecret = SlackSigningSecret ByteString deriving stock (Eq) @@ -43,7 +47,14 @@ instance FromHttpApiData SlackSignature where parseUrlPiece _ = error "SlackSignature should not be in a url piece" parseHeader = Right . SlackSignature -data SlackVerificationFailed +-- | Error for an invalid Slack request body. +type SlackVerificationFailed = SlackVerificationFailed' Text + +-- | Error for an invalid Slack request body. Allows for arbitrary parse +-- error types. +-- +-- @since 2.3.0.0 +data SlackVerificationFailed' parseError = VerificationMissingTimestamp | VerificationMalformedTimestamp ByteString | VerificationTimestampOutOfRange Int @@ -52,31 +63,78 @@ data SlackVerificationFailed | VerificationMalformedSignature String | VerificationUndecodableSignature ByteString | VerificationSignatureMismatch - | VerificationCannotParse Text + | VerificationCannotParse parseError deriving stock (Show, Eq) + deriving anyclass (Exception) + +type role SlackVerificationFailed' representational -instance Exception SlackVerificationFailed +-- | Decodes the JSON request body as plain JSON (as would be seen in a +-- 'SlackWebhookEvent'). +-- +-- @since 2.3.0.0 +decodeRequestJSON :: (FromJSON body) => ByteString -> Either Text body +decodeRequestJSON = mapLeft pack . eitherDecodeStrict +-- | Validates that a Slack request is signed appropriately to prove it +-- originated from Slack, then decodes it as JSON, in the spirit of "Parse, +-- don't validate". +-- +-- See: validateRequest :: - (MonadIO m, FromJSON a) => + (MonadIO m, FromJSON body) => SlackSigningSecret -> SlackSignature -> SlackRequestTimestamp -> ByteString -> - m (Either SlackVerificationFailed a) -validateRequest secret sig reqTs body = - liftIO getPOSIXTime >>= \time -> pure $ validateRequest' time secret sig reqTs body + m (Either SlackVerificationFailed body) +validateRequest = validateRequestRaw decodeRequestJSON -- | Pure version of 'validateRequest'. Probably only useful for tests. validateRequest' :: - (FromJSON a) => + (FromJSON body) => + NominalDiffTime -> + SlackSigningSecret -> + SlackSignature -> + SlackRequestTimestamp -> + ByteString -> + Either SlackVerificationFailed body +validateRequest' = validateRequestRaw' decodeRequestJSON + +-- | Validates that a Slack request is signed appropriately to prove it +-- originated from Slack, then decodes it, in the spirit of "Parse, don't +-- validate". +-- +-- This function is necessary for the interactive webhooks that are +-- x-form-urlencoded with a @payload@ field. For more info on those, see +-- +-- +-- See: +-- +-- @since 2.3.0.0 +validateRequestRaw :: + (MonadIO m) => + (ByteString -> Either err body) -> + SlackSigningSecret -> + SlackSignature -> + SlackRequestTimestamp -> + ByteString -> + m (Either (SlackVerificationFailed' err) body) +validateRequestRaw decoder secret sig reqTs body = + liftIO getPOSIXTime >>= \time -> pure $ validateRequestRaw' decoder time secret sig reqTs body + +-- | Pure version of 'validateRequestRaw'. Probably only useful for tests. +-- +-- @since 2.3.0.0 +validateRequestRaw' :: + (ByteString -> Either err body) -> NominalDiffTime -> SlackSigningSecret -> SlackSignature -> SlackRequestTimestamp -> ByteString -> - Either SlackVerificationFailed a -validateRequest' now (SlackSigningSecret secret) (SlackSignature sigHeader) (SlackRequestTimestamp timestampString) body = do + Either (SlackVerificationFailed' err) body +validateRequestRaw' decoder now (SlackSigningSecret secret) (SlackSignature sigHeader) (SlackRequestTimestamp timestampString) body = do let fiveMinutes = 5 * 60 -- timestamp must be an Int for proper basestring construction below timestamp <- @@ -99,4 +157,4 @@ validateRequest' now (SlackSigningSecret secret) (SlackSignature sigHeader) (Sla let basestring = encodeUtf8 ("v0:" <> tshow timestamp <> ":") <> body when (hmac secret basestring /= sig) $ Left VerificationSignatureMismatch - mapLeft (VerificationCannotParse . pack) $ eitherDecodeStrict body + mapLeft VerificationCannotParse $ decoder body diff --git a/src/Web/Slack/Internal.hs b/src/Web/Slack/Internal.hs index 579f50f..1487e8c 100644 --- a/src/Web/Slack/Internal.hs +++ b/src/Web/Slack/Internal.hs @@ -1,9 +1,10 @@ +-- Due to AuthClientData +{-# OPTIONS_GHC -Wno-orphans #-} + -- | Internal things in slack-web. May be changed arbitrarily! module Web.Slack.Internal where import Data.Aeson (Value (..)) --- import Servant.Client.Core - import Data.Aeson.KeyMap qualified as KM import Network.HTTP.Client (Manager) import Servant.API hiding (addHeader)