Documentation
¶
Overview ¶
Package slackhook authenticates and parses inbound Slack interactivity requests. It exposes a constant-time HMAC verifier (with Slack's replay window), an HTTP middleware that gates a downstream handler, and a parser for the interaction envelope. It mirrors internal/githubhook; the differences are Slack's signing scheme (a timestamped base string) and the form-encoded body.
Index ¶
Constants ¶
const DefaultMaxAge = 5 * time.Minute
DefaultMaxAge is Slack's documented replay window: requests whose timestamp is more than five minutes from now (in either direction, to absorb clock skew) are rejected before the HMAC is checked.
const MaxBodyBytes int64 = 1 << 20 // 1 MiB
MaxBodyBytes caps the size of an accepted interaction body. Slack's interaction payloads are small (a JSON envelope in a single form field), so 1 MiB is generous and guards against memory-exhaustion attacks.
const SignatureHeader = "X-Slack-Signature"
SignatureHeader carries Slack's "v0=<hex>" request signature.
const TimestampHeader = "X-Slack-Request-Timestamp"
TimestampHeader carries the Unix-seconds timestamp Slack signed into the base string. It is part of the signed payload, so trusting it for replay protection is safe once the signature checks out.
Variables ¶
var ErrInvalidSignature = errors.New("slackhook: invalid signature")
ErrInvalidSignature is returned when the signature does not match the body.
var ErrMissingPayload = errors.New("slackhook: missing payload field")
ErrMissingPayload is returned when the form body has no `payload` field.
var ErrStaleTimestamp = errors.New("slackhook: stale timestamp")
ErrStaleTimestamp is returned when the request timestamp falls outside the replay window. Kept distinct from ErrInvalidSignature so callers and tests can tell a replayed (or badly clock-skewed) request from a forged one, even though both map to 401 at the HTTP layer.
Functions ¶
func NewHandler ¶
func NewHandler(sink InteractionSink, logger *slog.Logger) http.Handler
NewHandler returns an http.Handler that parses a Slack interaction body and forwards it to sink. It assumes the body has already been verified by SignatureMiddleware.
Slack retries any delivery that does not return 2xx within ~3 seconds, so the handler always responds 200 once the signature is trusted: a malformed or unactionable payload is logged and ignored rather than retried. A sink error is logged but does not change the response — at-least-once retries of inbound clicks would do more harm than the dropped update.
func SignatureMiddleware ¶
SignatureMiddleware returns an HTTP middleware that:
- rejects any request whose body exceeds MaxBodyBytes (413),
- rejects any request missing the signature or timestamp header (401),
- rejects any request whose signature is invalid or whose timestamp is stale (401),
- passes a fresh body reader to next, so downstream handlers can read the verified body without juggling the raw stream themselves.
The signature is verified over the raw bytes before any form parsing, exactly like internal/githubhook.
Types ¶
type Action ¶
Action is a single interactive element the user activated. ActionID is the element's configured identifier (e.g. "start_review"); Value is its opaque payload (e.g. an encoded repo + PR number).
type Channel ¶
type Channel struct {
ID string
}
Channel is the conversation the interactive message lives in.
type Interaction ¶
type Interaction struct {
Type string
User User
Channel Channel
Message Message
Actions []Action
ResponseURL string
TriggerID string
}
Interaction is the parsed view of a Slack interaction envelope, holding only the fields notifycat uses. Slack sends a much larger object; unknown fields are ignored. Extending behavior usually means adding a field here rather than writing a new parser.
func ParseInteraction ¶
func ParseInteraction(body []byte) (Interaction, error)
ParseInteraction decodes a Slack interaction request body. The body is application/x-www-form-urlencoded with a single `payload` field holding URL-encoded JSON.
type InteractionSink ¶
type InteractionSink func(ctx context.Context, interaction Interaction) error
InteractionSink receives a parsed Interaction. It is the seam between the HTTP layer and whatever acts on the interaction (the click handler, added in a later issue); defining it here keeps slackhook unaware of any downstream package. A nil sink is allowed — the foundation endpoint verifies, parses, and logs without yet routing anywhere.
type Message ¶
type Message struct {
TS string
Text string
RawBlocks json.RawMessage
}
Message identifies the message that carried the interactive component. TS is the Slack timestamp used to address the message; Text is the top-level plain-text fallback; RawBlocks is the original blocks array echoed back by Slack so handlers can pass it through to chat.update without re-composing it.
type Option ¶
type Option func(*Verifier)
Option configures a Verifier.
func WithClock ¶
WithClock overrides the time source used for replay-window checks. Tests inject a fixed clock; production uses time.Now.
func WithMaxAge ¶
WithMaxAge overrides the replay window. Defaults to DefaultMaxAge.
type User ¶
User is the Slack user who triggered the interaction. ID is the stable "U…" identifier; Username is a display convenience and may be absent.
type Verifier ¶
type Verifier struct {
// contains filtered or unexported fields
}
Verifier checks Slack request signatures against a shared signing secret and enforces the replay window.
func NewVerifier ¶
NewVerifier returns a Verifier configured with the given signing secret.
func (*Verifier) Verify ¶
Verify checks that signature is a valid "v0=<hex>" HMAC of Slack's base string ("v0:{timestamp}:{rawBody}") under the verifier's secret, and that timestamp is within the replay window.
The staleness check runs first — the timestamp is part of the signed base string, so a forged-but-fresh timestamp still fails the HMAC. The HMAC comparison runs in constant time to prevent timing oracles.
Returns ErrStaleTimestamp for a timestamp outside the window and ErrInvalidSignature for any signature mismatch or malformed input.