chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// reservedCIDRs lists IP ranges that must never be targeted by outbound webhook requests.
|
||||
// Covers loopback, RFC-1918 private, link-local (including cloud IMDS at 169.254.169.254),
|
||||
// and their IPv6 equivalents.
|
||||
var reservedCIDRs = []string{
|
||||
"127.0.0.0/8", // IPv4 loopback
|
||||
"10.0.0.0/8", // RFC-1918 class A
|
||||
"172.16.0.0/12", // RFC-1918 class B
|
||||
"192.168.0.0/16", // RFC-1918 class C
|
||||
"169.254.0.0/16", // Link-local / cloud IMDS
|
||||
"::1/128", // IPv6 loopback
|
||||
"fc00::/7", // IPv6 unique local
|
||||
"fe80::/10", // IPv6 link-local
|
||||
}
|
||||
|
||||
// reservedNetworks is the parsed form of reservedCIDRs, built once at startup.
|
||||
var reservedNetworks []*net.IPNet
|
||||
|
||||
func init() {
|
||||
for _, cidr := range reservedCIDRs {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("webhook: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||
}
|
||||
reservedNetworks = append(reservedNetworks, network)
|
||||
}
|
||||
}
|
||||
|
||||
// AllowPrivateIPs controls whether webhook URLs may resolve to reserved/private
|
||||
// IP addresses. When true, the SSRF protection is disabled. This is useful for
|
||||
// self-hosted deployments where webhooks target services on the local network.
|
||||
var AllowPrivateIPs bool
|
||||
|
||||
// isReservedIP reports whether ip falls within any reserved/private range.
|
||||
func isReservedIP(ip net.IP) bool {
|
||||
if AllowPrivateIPs {
|
||||
return false
|
||||
}
|
||||
for _, network := range reservedNetworks {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateURL checks that rawURL:
|
||||
// 1. Parses as a valid absolute URL.
|
||||
// 2. Uses the http or https scheme.
|
||||
// 3. Does not resolve to a reserved/private IP address.
|
||||
//
|
||||
// It returns a gRPC InvalidArgument status error so callers can return it directly.
|
||||
func ValidateURL(rawURL string) error {
|
||||
u, err := url.ParseRequestURI(rawURL)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "invalid webhook URL: %v", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return status.Errorf(codes.InvalidArgument, "webhook URL must use http or https scheme, got %q", u.Scheme)
|
||||
}
|
||||
|
||||
ips, err := net.LookupHost(u.Hostname())
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "webhook URL hostname could not be resolved: %v", err)
|
||||
}
|
||||
|
||||
for _, ipStr := range ips {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip != nil && isReservedIP(ip) {
|
||||
return status.Errorf(codes.InvalidArgument, "webhook URL must not resolve to a reserved or private IP address")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSigningSecret checks that secret is either empty (allowed) or contains
|
||||
// only printable ASCII characters (0x20–0x7E), excluding all control characters
|
||||
// such as \r and \n, which would corrupt the webhook signature headers. When the
|
||||
// secret uses the Standard Webhooks "whsec_<base64>" serialization, the base64
|
||||
// body must decode cleanly so signing cannot silently fall back to the wrong key.
|
||||
func ValidateSigningSecret(secret string) error {
|
||||
if secret == "" {
|
||||
return nil
|
||||
}
|
||||
for _, r := range secret {
|
||||
if r < 0x20 || r > 0x7E {
|
||||
return status.Errorf(codes.InvalidArgument, "signing secret contains invalid character")
|
||||
}
|
||||
}
|
||||
if _, err := resolveSigningKey(secret); err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
// timeout is the timeout for webhook request. Default to 30 seconds.
|
||||
timeout = 30 * time.Second
|
||||
|
||||
// safeClient is the shared HTTP client used for all webhook dispatches.
|
||||
// Its Transport guards against SSRF by blocking connections to reserved/private
|
||||
// IP addresses at dial time, which also defeats DNS rebinding attacks.
|
||||
safeClient = &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
},
|
||||
}
|
||||
|
||||
asyncPostQueue = make(chan *WebhookRequestPayload, 128)
|
||||
)
|
||||
|
||||
func init() {
|
||||
for range 4 {
|
||||
go func() {
|
||||
for payload := range asyncPostQueue {
|
||||
if err := Post(payload); err != nil {
|
||||
slog.Warn("Failed to dispatch webhook asynchronously",
|
||||
slog.String("url", payload.URL),
|
||||
slog.String("activityType", payload.ActivityType),
|
||||
slog.Any("err", err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// safeDialContext is a net.Dialer.DialContext replacement that resolves the target
|
||||
// hostname and rejects any address that falls within a reserved/private IP range.
|
||||
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("webhook: invalid address %q", addr)
|
||||
}
|
||||
|
||||
ips, err := net.DefaultResolver.LookupHost(ctx, host)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "webhook: failed to resolve host %q", host)
|
||||
}
|
||||
|
||||
for _, ipStr := range ips {
|
||||
if ip := net.ParseIP(ipStr); ip != nil && isReservedIP(ip) {
|
||||
return nil, errors.Errorf("webhook: connection to reserved/private IP address is not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(host, port))
|
||||
}
|
||||
|
||||
type WebhookRequestPayload struct {
|
||||
// The target URL for the webhook request.
|
||||
URL string `json:"url"`
|
||||
// The type of activity that triggered this webhook.
|
||||
ActivityType string `json:"activityType"`
|
||||
// The resource name of the creator. Format: users/{user}
|
||||
Creator string `json:"creator"`
|
||||
// The memo that triggered this webhook (if applicable).
|
||||
Memo *v1pb.Memo `json:"memo"`
|
||||
// Optional signing secret for HMAC-SHA256 signature. Not serialized to JSON.
|
||||
SigningSecret string `json:"-"`
|
||||
}
|
||||
|
||||
// resolveSigningKey returns the raw HMAC key for a signing secret. Secrets using
|
||||
// the Standard Webhooks "whsec_<base64>" serialization are base64-decoded to their
|
||||
// raw bytes; any other secret is used as-is. It returns an error when a whsec_-prefixed
|
||||
// secret is not valid base64, so callers fail loudly instead of silently signing with
|
||||
// the wrong key (which would make every signature unverifiable by the receiver).
|
||||
func resolveSigningKey(secret string) ([]byte, error) {
|
||||
if rest, ok := strings.CutPrefix(secret, "whsec_"); ok {
|
||||
decoded, err := base64.StdEncoding.DecodeString(rest)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "signing secret has whsec_ prefix but is not valid base64")
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
return []byte(secret), nil
|
||||
}
|
||||
|
||||
// GenerateSigningSecret returns a new Standard Webhooks signing secret in the
|
||||
// "whsec_<base64>" form, backed by 32 cryptographically-random bytes — comfortably
|
||||
// within the spec's 24–64 byte range.
|
||||
func GenerateSigningSecret() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", errors.Wrap(err, "failed to read random bytes for signing secret")
|
||||
}
|
||||
return "whsec_" + base64.StdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Post posts the message to webhook endpoint.
|
||||
func Post(requestPayload *WebhookRequestPayload) error {
|
||||
body, err := json.Marshal(requestPayload)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to marshal webhook request to %s", requestPayload.URL)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", requestPayload.URL, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to construct webhook request to %s", requestPayload.URL)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if requestPayload.SigningSecret != "" {
|
||||
key, err := resolveSigningKey(requestPayload.SigningSecret)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to derive signing key for webhook to %s", requestPayload.URL)
|
||||
}
|
||||
|
||||
msgID := "msg_" + uuid.New().String()
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(msgID + "." + timestamp + "."))
|
||||
mac.Write(body)
|
||||
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
req.Header.Set("webhook-id", msgID)
|
||||
req.Header.Set("webhook-timestamp", timestamp)
|
||||
req.Header.Set("webhook-signature", "v1,"+signature)
|
||||
}
|
||||
|
||||
resp, err := safeClient.Do(req)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to post webhook to %s", requestPayload.URL)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to read webhook response from %s", requestPayload.URL)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return errors.Errorf("failed to post webhook %s, status code: %d", requestPayload.URL, resp.StatusCode)
|
||||
}
|
||||
|
||||
response := &struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}{}
|
||||
if err := json.Unmarshal(b, response); err != nil {
|
||||
return errors.Wrapf(err, "failed to unmarshal webhook response from %s", requestPayload.URL)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return errors.Errorf("receive error code sent by webhook server, code %d, msg: %s", response.Code, response.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostAsync posts the message to webhook endpoint asynchronously.
|
||||
// It enqueues the request for bounded asynchronous dispatch and does not wait for the response.
|
||||
func PostAsync(requestPayload *WebhookRequestPayload) {
|
||||
if requestPayload == nil {
|
||||
slog.Warn("Dropped webhook dispatch because payload is nil")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case asyncPostQueue <- requestPayload:
|
||||
default:
|
||||
slog.Warn("Dropped webhook dispatch because the async queue is full",
|
||||
slog.String("url", requestPayload.URL),
|
||||
slog.String("activityType", requestPayload.ActivityType))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostAsyncNilPayloadDoesNotPanic(t *testing.T) {
|
||||
require.NotPanics(t, func() {
|
||||
PostAsync(nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveSigningKey(t *testing.T) {
|
||||
rawKey := []byte("0123456789abcdef")
|
||||
whsec := "whsec_" + base64.StdEncoding.EncodeToString(rawKey)
|
||||
|
||||
t.Run("plain secret used as-is", func(t *testing.T) {
|
||||
key, err := resolveSigningKey("my-plain-secret")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("my-plain-secret"), key)
|
||||
})
|
||||
|
||||
t.Run("whsec_ prefix is base64-decoded", func(t *testing.T) {
|
||||
key, err := resolveSigningKey(whsec)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rawKey, key)
|
||||
})
|
||||
|
||||
t.Run("whsec_ with invalid base64 fails loudly", func(t *testing.T) {
|
||||
_, err := resolveSigningKey("whsec_not!valid!base64!")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateSigningSecret(t *testing.T) {
|
||||
secret, err := GenerateSigningSecret()
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(secret, "whsec_"), "generated secret must use the whsec_ prefix")
|
||||
require.NoError(t, ValidateSigningSecret(secret), "generated secret must pass validation")
|
||||
|
||||
key, err := resolveSigningKey(secret)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, key, 32, "generated secret must decode to 32 raw bytes")
|
||||
|
||||
other, err := GenerateSigningSecret()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, secret, other, "each generated secret must be unique")
|
||||
}
|
||||
|
||||
func TestValidateSigningSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty is allowed", secret: "", wantErr: false},
|
||||
{name: "printable ascii", secret: "abcDEF123!@#", wantErr: false},
|
||||
{name: "valid whsec_", secret: "whsec_" + base64.StdEncoding.EncodeToString([]byte("key")), wantErr: false},
|
||||
{name: "newline rejected", secret: "abc\ndef", wantErr: true},
|
||||
{name: "carriage return rejected", secret: "abc\rdef", wantErr: true},
|
||||
{name: "tab rejected", secret: "abc\tdef", wantErr: true},
|
||||
{name: "non-ascii rejected", secret: "abc€def", wantErr: true},
|
||||
{name: "whsec_ with invalid base64 rejected", secret: "whsec_not!base64", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateSigningSecret(tc.secret)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostSignsRequest verifies the end-to-end Standard Webhooks signature so a
|
||||
// receiver following the documented verification recipe will accept our requests.
|
||||
func TestPostSignsRequest(t *testing.T) {
|
||||
// httptest listens on 127.0.0.1, which the SSRF guard blocks by default.
|
||||
prev := AllowPrivateIPs
|
||||
AllowPrivateIPs = true
|
||||
defer func() { AllowPrivateIPs = prev }()
|
||||
|
||||
rawKey := []byte("0123456789abcdef0123456789abcdef")
|
||||
cases := []struct {
|
||||
name string
|
||||
secret string
|
||||
key []byte
|
||||
}{
|
||||
{name: "plain secret", secret: "plain-secret-value", key: []byte("plain-secret-value")},
|
||||
{name: "whsec_ secret", secret: "whsec_" + base64.StdEncoding.EncodeToString(rawKey), key: rawKey},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotID, gotTimestamp, gotSignature string
|
||||
var gotBody []byte
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotID = r.Header.Get("webhook-id")
|
||||
gotTimestamp = r.Header.Get("webhook-timestamp")
|
||||
gotSignature = r.Header.Get("webhook-signature")
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := Post(&WebhookRequestPayload{
|
||||
URL: server.URL,
|
||||
ActivityType: "memos.memo.created",
|
||||
Creator: "users/1",
|
||||
SigningSecret: tc.secret,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, strings.HasPrefix(gotID, "msg_"), "webhook-id should be prefixed with msg_")
|
||||
require.NotEmpty(t, gotTimestamp)
|
||||
require.True(t, strings.HasPrefix(gotSignature, "v1,"), "signature should carry the v1 version tag")
|
||||
|
||||
// Recompute the signature the way a receiver would and confirm it matches.
|
||||
mac := hmac.New(sha256.New, tc.key)
|
||||
mac.Write([]byte(gotID + "." + gotTimestamp + "."))
|
||||
mac.Write(gotBody)
|
||||
want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
require.Equal(t, "v1,"+want, gotSignature)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostWithoutSecretSetsNoSignatureHeaders ensures unsigned webhooks stay unsigned.
|
||||
func TestPostWithoutSecretSetsNoSignatureHeaders(t *testing.T) {
|
||||
prev := AllowPrivateIPs
|
||||
AllowPrivateIPs = true
|
||||
defer func() { AllowPrivateIPs = prev }()
|
||||
|
||||
var hasSignatureHeaders bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hasSignatureHeaders = r.Header.Get("webhook-id") != "" ||
|
||||
r.Header.Get("webhook-timestamp") != "" ||
|
||||
r.Header.Get("webhook-signature") != ""
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := Post(&WebhookRequestPayload{
|
||||
URL: server.URL,
|
||||
ActivityType: "memos.memo.created",
|
||||
Creator: "users/1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, hasSignatureHeaders, "no signature headers should be set when no secret is configured")
|
||||
}
|
||||
Reference in New Issue
Block a user