diff --git a/webhook.go b/webhook.go new file mode 100644 index 0000000..300ac78 --- /dev/null +++ b/webhook.go @@ -0,0 +1,204 @@ +// Webhook + SSO helpers: the secret-handling side of talking to +// Discourse from a server. +// +// Webhooks: Discourse signs each delivery's raw body with the webhook +// secret as HMAC-SHA256 hex in X-Discourse-Event-Signature (with a +// "sha256=" prefix). Never trust an unsigned or mis-signed delivery — +// the endpoint is internet-facing by nature. +// +// SSO (Discourse Connect): Discourse redirects users to your site with +// ?sso=&sig= +// signed with the shared SSO secret; you answer by redirecting back to +// return_sso_url with a payload signed the same way. +// +// Secrets arrive via environment (DISCOURSE_WEBHOOK_SECRET / +// DISCOURSE_SSO_SECRET, see WebhookSecretFromEnv / SSOSecretFromEnv) — +// never flags, never arguments, never logged, never echoed in errors. +package discourse + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" +) + +// ErrBadSignature: HMAC verification failed (webhook or SSO). The +// payload must be treated as attacker-controlled and dropped. +var ErrBadSignature = errors.New("discourse: bad signature") + +// WebhookSecretFromEnv reads DISCOURSE_WEBHOOK_SECRET (trimmed). +func WebhookSecretFromEnv() ([]byte, error) { + return secretFromEnv("DISCOURSE_WEBHOOK_SECRET") +} + +// SSOSecretFromEnv reads DISCOURSE_SSO_SECRET (trimmed). +func SSOSecretFromEnv() ([]byte, error) { + return secretFromEnv("DISCOURSE_SSO_SECRET") +} + +func secretFromEnv(name string) ([]byte, error) { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return nil, fmt.Errorf("%w: %s is not set", ErrInvalidRequest, name) + } + return []byte(v), nil +} + +// VerifyWebhook checks a Discourse webhook signature: HMAC-SHA256 hex +// digest of the raw request body under secret, constant-time compared. +// Discourse sends the digest with a "sha256=" prefix; both forms pass. +func VerifyWebhook(secret, body []byte, signature string) error { + sig := strings.TrimPrefix(strings.TrimSpace(signature), "sha256=") + want, err := hex.DecodeString(sig) + if err != nil || len(want) != sha256.Size { + return fmt.Errorf("%w: signature is not a sha256 hex digest", ErrBadSignature) + } + m := hmac.New(sha256.New, secret) + m.Write(body) + if !hmac.Equal(want, m.Sum(nil)) { + return ErrBadSignature + } + return nil +} + +// WebhookEvent is one verified webhook delivery. +type WebhookEvent struct { + ID string // X-Discourse-Event-Id (delivery id) + Type string // X-Discourse-Event-Type ("post", "topic", "user", ...) + Event string // X-Discourse-Event ("post_created", ...) + Body json.RawMessage // raw JSON payload, unmodified +} + +// ParseWebhook verifies and decodes an incoming webhook request. It +// consumes r.Body, so wrap with http.MaxBytesReader first if the +// endpoint is exposed. Signature failure returns ErrBadSignature. +func ParseWebhook(secret []byte, r *http.Request) (*WebhookEvent, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + if err != nil { + return nil, fmt.Errorf("%w: read body: %v", ErrUnreachable, err) + } + if err := VerifyWebhook(secret, body, r.Header.Get("X-Discourse-Event-Signature")); err != nil { + return nil, err + } + if !json.Valid(body) { + return nil, fmt.Errorf("%w: payload is not JSON", ErrMalformedResponse) + } + return &WebhookEvent{ + ID: r.Header.Get("X-Discourse-Event-Id"), + Type: r.Header.Get("X-Discourse-Event-Type"), + Event: r.Header.Get("X-Discourse-Event"), + Body: json.RawMessage(body), + }, nil +} + +// WebhookHandler adapts ParseWebhook to net/http: bad signatures get +// 403 and are never dispatched; verified deliveries go to next, which +// must not panic-write after the handler returns. +func WebhookHandler(secret []byte, next func(*WebhookEvent)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + evt, err := ParseWebhook(secret, r) + if err != nil { + http.Error(w, "invalid signature", http.StatusForbidden) + return + } + next(evt) + w.WriteHeader(http.StatusOK) + } +} + +// VerifySSO verifies a Discourse Connect (SSO) login redirect: sso is +// the base64 payload, sig the hex HMAC-SHA256 of the base64 string +// (not of the decoded payload). Returns the payload's fields — always +// nonce and return_sso_url. +func VerifySSO(secret []byte, sso, sig string) (url.Values, error) { + sigBytes, err := hex.DecodeString(strings.TrimSpace(sig)) + if err != nil || len(sigBytes) != sha256.Size { + return nil, fmt.Errorf("%w: sso signature is not a sha256 hex digest", ErrBadSignature) + } + m := hmac.New(sha256.New, secret) + m.Write([]byte(sso)) + if !hmac.Equal(sigBytes, m.Sum(nil)) { + return nil, ErrBadSignature + } + raw, err := base64.StdEncoding.DecodeString(sso) + if err != nil { + return nil, fmt.Errorf("%w: sso payload is not base64: %v", ErrBadSignature, err) + } + vals, err := url.ParseQuery(string(raw)) + if err != nil { + return nil, fmt.Errorf("%w: sso payload is not a querystring: %v", ErrBadSignature, err) + } + return vals, nil +} + +// BuildSSOResponse builds the signed response payload: nonce (copied +// from the incoming request) plus the identity fields Discourse accepts +// (email, external_id, username, name, avatar_url, admin, moderator, +// add_groups, remove_groups, ...). Returns the base64 payload and its +// hex signature, ready for SSORedirectURL. +func BuildSSOResponse(secret []byte, nonce string, params map[string]string) (string, string, error) { + if strings.TrimSpace(nonce) == "" { + return "", "", fmt.Errorf("%w: sso response requires the request's nonce", ErrInvalidRequest) + } + vals := url.Values{} + vals.Set("nonce", nonce) + for k, v := range params { + if v != "" { + vals.Set(k, v) + } + } + raw := vals.Encode() + b64 := base64.StdEncoding.EncodeToString([]byte(raw)) + m := hmac.New(sha256.New, secret) + m.Write([]byte(b64)) + return b64, hex.EncodeToString(m.Sum(nil)), nil +} + +// SSORedirectURL assembles the return redirect: returnURL with sso and +// sig query parameters appended. +func SSORedirectURL(returnURL, sso, sig string) (string, error) { + u, err := url.Parse(returnURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("%w: return_sso_url %q is not absolute", ErrInvalidRequest, returnURL) + } + q := u.Query() + q.Set("sso", sso) + q.Set("sig", sig) + u.RawQuery = q.Encode() + return u.String(), nil +} + +// SSOLogin is the full server-side SSO dance for one request: verify +// the incoming sso/sig pair, build the signed response with the user's +// identity, and return the URL to redirect the user's browser to. +// The context is unused today (pure crypto) but keeps the signature +// future-proof for lookups (e.g. fetching the external user's groups). +func SSOLogin(ctx context.Context, secret []byte, sso, sig string, params map[string]string) (string, error) { + vals, err := VerifySSO(secret, sso, sig) + if err != nil { + return "", err + } + nonce := vals.Get("nonce") + if nonce == "" { + return "", fmt.Errorf("%w: sso payload carries no nonce", ErrBadSignature) + } + returnURL := vals.Get("return_sso_url") + if returnURL == "" { + return "", fmt.Errorf("%w: sso payload carries no return_sso_url", ErrBadSignature) + } + b64, respSig, err := BuildSSOResponse(secret, nonce, params) + if err != nil { + return "", err + } + return SSORedirectURL(returnURL, b64, respSig) +} diff --git a/webhook_test.go b/webhook_test.go new file mode 100644 index 0000000..215783f --- /dev/null +++ b/webhook_test.go @@ -0,0 +1,228 @@ +package discourse + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const testWebhookSecret = "whsec-test-0123456789" +const testSSOSecret = "ssosec-test-0123456789" + +func hmacHex(secret string, data []byte) string { + m := hmac.New(sha256.New, []byte(secret)) + m.Write(data) + return hex.EncodeToString(m.Sum(nil)) +} + +// --- webhook payload verification --- + +func TestVerifyWebhookGoodSignature(t *testing.T) { + body := []byte(`{"post":{"id":55,"topic_id":12}}`) + sig := hmacHex(testWebhookSecret, body) + if err := VerifyWebhook([]byte(testWebhookSecret), body, sig); err != nil { + t.Fatalf("good sig rejected: %v", err) + } + if err := VerifyWebhook([]byte(testWebhookSecret), body, "sha256="+sig); err != nil { + t.Fatalf("sha256=-prefixed sig rejected: %v", err) + } +} + +func TestVerifyWebhookBadSignature(t *testing.T) { + body := []byte(`{"post":{"id":55}}`) + err := VerifyWebhook([]byte(testWebhookSecret), body, "deadbeef") + if !errors.Is(err, ErrBadSignature) { + t.Fatalf("want ErrBadSignature, got %v", err) + } + err = VerifyWebhook([]byte("other-secret"), body, hmacHex(testWebhookSecret, body)) + if !errors.Is(err, ErrBadSignature) { + t.Fatalf("wrong secret must fail, got %v", err) + } + if err := VerifyWebhook([]byte(testWebhookSecret), nil, ""); !errors.Is(err, ErrBadSignature) { + t.Fatalf("empty signature must fail, got %v", err) + } +} + +func TestParseWebhookVerifiesAndExtracts(t *testing.T) { + body := []byte(`{"post":{"id":55,"post_number":2}}`) + req := httptest.NewRequest(http.MethodPost, "/hooks/discourse", bytes.NewReader(body)) + req.Header.Set("X-Discourse-Event-Id", "0d8417a0-1c2b-4d7b") + req.Header.Set("X-Discourse-Event-Type", "post") + req.Header.Set("X-Discourse-Event", "post_created") + req.Header.Set("X-Discourse-Event-Signature", "sha256="+hmacHex(testWebhookSecret, body)) + + evt, err := ParseWebhook([]byte(testWebhookSecret), req) + if err != nil { + t.Fatalf("ParseWebhook: %v", err) + } + if evt.ID != "0d8417a0-1c2b-4d7b" || evt.Type != "post" || evt.Event != "post_created" { + t.Fatalf("headers not extracted: %+v", evt) + } + var payload struct { + Post struct { + ID int `json:"id"` + PostNumber int `json:"post_number"` + } `json:"post"` + } + if err := json.Unmarshal(evt.Body, &payload); err != nil { + t.Fatalf("body not preserved: %v", err) + } + if payload.Post.ID != 55 || payload.Post.PostNumber != 2 { + t.Fatalf("body mangled: %s", evt.Body) + } +} + +func TestWebhookHandlerRejectsAndAccepts(t *testing.T) { + body := []byte(`{"topic":{"id":12}}`) + got := 0 + h := WebhookHandler([]byte(testWebhookSecret), func(evt *WebhookEvent) { got++ }) + + r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + r.Header.Set("X-Discourse-Event-Signature", hmacHex("wrong", body)) + w := httptest.NewRecorder() + h(w, r) + if w.Code != http.StatusForbidden || got != 0 { + t.Fatalf("bad sig: want 403 + no dispatch, got %d dispatch=%d", w.Code, got) + } + + r = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + r.Header.Set("X-Discourse-Event-Signature", "sha256="+hmacHex(testWebhookSecret, body)) + r.Header.Set("X-Discourse-Event", "topic_created") + w = httptest.NewRecorder() + h(w, r) + if w.Code != http.StatusOK || got != 1 { + t.Fatalf("good sig: want 200 + dispatch, got %d dispatch=%d", w.Code, got) + } +} + +// --- SSO (Discourse Connect) --- + +func ssoPayload(t *testing.T, fields string) (string, string) { + t.Helper() + b64 := base64.StdEncoding.EncodeToString([]byte(fields)) + return b64, hmacHex(testSSOSecret, []byte(b64)) +} + +func TestVerifySSO(t *testing.T) { + b64, sig := ssoPayload(t, "nonce=cb68251eefb35f7e&return_sso_url=https%3A%2F%2Fapp.example.test%2Fsso%2Fcallback") + vals, err := VerifySSO([]byte(testSSOSecret), b64, sig) + if err != nil { + t.Fatalf("VerifySSO: %v", err) + } + if vals.Get("nonce") != "cb68251eefb35f7e" { + t.Fatalf("nonce not decoded: %q", vals.Get("nonce")) + } + if vals.Get("return_sso_url") != "https://app.example.test/sso/callback" { + t.Fatalf("return url not decoded: %q", vals.Get("return_sso_url")) + } +} + +func TestVerifySSORejectsTampering(t *testing.T) { + b64, sig := ssoPayload(t, "nonce=abc") + if _, err := VerifySSO([]byte(testSSOSecret), b64+"x", sig); !errors.Is(err, ErrBadSignature) { + t.Fatalf("tampered payload must fail, got %v", err) + } + if _, err := VerifySSO([]byte("other"), b64, sig); !errors.Is(err, ErrBadSignature) { + t.Fatalf("wrong secret must fail, got %v", err) + } + if _, err := VerifySSO([]byte(testSSOSecret), "!!not-base64!!", sig); err == nil { + t.Fatal("undecodable payload must fail") + } + if _, err := VerifySSO([]byte(testSSOSecret), "", ""); !errors.Is(err, ErrBadSignature) { + t.Fatalf("empty sso/sig must fail, got %v", err) + } +} + +func TestBuildSSOResponseRoundTrip(t *testing.T) { + b64, sig, err := BuildSSOResponse([]byte(testSSOSecret), "cb68251eefb35f7e", map[string]string{ + "email": "charles@turnsys.com", + "external_id": "7", + "username": "reachableceo", + "admin": "true", + }) + if err != nil { + t.Fatalf("BuildSSOResponse: %v", err) + } + vals, err := VerifySSO([]byte(testSSOSecret), b64, sig) + if err != nil { + t.Fatalf("round-trip verify: %v", err) + } + for k, want := range map[string]string{ + "nonce": "cb68251eefb35f7e", "email": "charles@turnsys.com", + "external_id": "7", "username": "reachableceo", "admin": "true", + } { + if got := vals.Get(k); got != want { + t.Fatalf("field %s: want %q got %q", k, want, got) + } + } + if _, err := base64.StdEncoding.DecodeString(b64); err != nil { + t.Fatalf("response not std base64: %v", err) + } +} + +func TestBuildSSOResponseRequiresNonce(t *testing.T) { + if _, _, err := BuildSSOResponse([]byte(testSSOSecret), "", map[string]string{"email": "x@y.test"}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("empty nonce: want ErrInvalidRequest, got %v", err) + } +} + +func TestSSORedirectURL(t *testing.T) { + u, err := SSORedirectURL("https://app.example.test/sso/callback", "c29tZXBheWxvYWQ=", "aabbcc") + if err != nil { + t.Fatalf("SSORedirectURL: %v", err) + } + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("parse: %v", err) + } + if parsed.Scheme != "https" || parsed.Host != "app.example.test" { + t.Fatalf("host mangled: %s", u) + } + q := parsed.Query() + if q.Get("sso") != "c29tZXBheWxvYWQ=" || q.Get("sig") != "aabbcc" { + t.Fatalf("query mangled: %s", u) + } +} + +// --- secret loading from env --- + +func TestSecretsFromEnv(t *testing.T) { + t.Setenv("DISCOURSE_WEBHOOK_SECRET", " hook-secret ") + t.Setenv("DISCOURSE_SSO_SECRET", " sso-secret ") + wh, err := WebhookSecretFromEnv() + if err != nil || string(wh) != "hook-secret" { + t.Fatalf("webhook secret: %q %v", wh, err) + } + sso, err := SSOSecretFromEnv() + if err != nil || string(sso) != "sso-secret" { + t.Fatalf("sso secret: %q %v", sso, err) + } +} + +func TestSecretsFromEnvMissing(t *testing.T) { + t.Setenv("DISCOURSE_WEBHOOK_SECRET", "") + t.Setenv("DISCOURSE_SSO_SECRET", "") + if _, err := WebhookSecretFromEnv(); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("missing webhook secret: want ErrInvalidRequest, got %v", err) + } + if _, err := SSOSecretFromEnv(); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("missing sso secret: want ErrInvalidRequest, got %v", err) + } +} + +func TestWebhookSecretNeverLeaksInError(t *testing.T) { + body := []byte(`{}`) + err := VerifyWebhook([]byte(testWebhookSecret), body, "bad") + if strings.Contains(err.Error(), testWebhookSecret) { + t.Fatalf("secret leaked in error: %v", err) + } +}