Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion slack-web.cabal
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
cabal-version: 3.0
name: slack-web
version: 2.2.0.0
version: 2.3.0.0

build-type: Simple

Expand Down
84 changes: 71 additions & 13 deletions src/Web/Slack/Experimental/RequestVerification.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 <https://api.slack.com/authentication/verifying-requests-from-slack#signing_secrets_admin_page>
newtype SlackSigningSecret
= SlackSigningSecret ByteString
deriving stock (Eq)
Expand All @@ -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
Expand All @@ -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: <https://api.slack.com/authentication/verifying-requests-from-slack>
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
-- <https://api.slack.com/interactivity/handling#payloads>
--
-- See: <https://api.slack.com/authentication/verifying-requests-from-slack>
--
-- @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
Comment on lines +123 to +124
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
validateRequestRaw decoder secret sig reqTs body =
liftIO getPOSIXTime >>= \time -> pure $ validateRequestRaw' decoder time secret sig reqTs body
validateRequestRaw decoder secret sig reqTs body = do
time <- liftIO getPOSIXTime
pure $ validateRequestRaw' decoder time secret sig reqTs body

Use do notation here.


-- | 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 <-
Expand All @@ -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
5 changes: 3 additions & 2 deletions src/Web/Slack/Internal.hs
Original file line number Diff line number Diff line change
@@ -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)
Expand Down