feat: webhook + SSO secret helpers (HMAC verification)
The server side of talking to Discourse, all stdlib crypto: VerifyWebhook checks X-Discourse-Event-Signature (sha256 HMAC hex, constant-time, with or without the sha256= prefix) over the raw body; ParseWebhook/WebhookHandler turn that into a drop-in http.HandlerFunc that 403s mis-signed deliveries before dispatch. VerifySSO decodes + verifies a Discourse Connect login redirect (HMAC over the base64 string), BuildSSOResponse signs the identity answer (nonce + email/external_id/username/...), and SSOLogin does the whole dance in one call, returning the redirect URL. Secrets load from DISCOURSE_WEBHOOK_SECRET / DISCOURSE_SSO_SECRET, are trimmed, never logged and never echoed in errors. Part of Redmine 507 (Discourse Go client): the webhook helper is what the fleet receiver and 495 delivery loop verify pushes with.
This commit is contained in:
+204
@@ -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=<base64 payload>&sig=<hex HMAC-SHA256 of that base64 string>
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
+228
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user