first commit
Some checks failed
Backend Tests / Static Checks (push) Has been cancelled
Backend Tests / Tests (other) (push) Has been cancelled
Backend Tests / Tests (plugin) (push) Has been cancelled
Backend Tests / Tests (server) (push) Has been cancelled
Backend Tests / Tests (store) (push) Has been cancelled
Build Canary Image / build-frontend (push) Has been cancelled
Build Canary Image / build-push (linux/amd64) (push) Has been cancelled
Build Canary Image / build-push (linux/arm64) (push) Has been cancelled
Build Canary Image / merge (push) Has been cancelled
Frontend Tests / Lint (push) Has been cancelled
Frontend Tests / Build (push) Has been cancelled
Proto Linter / Lint Protos (push) Has been cancelled

This commit is contained in:
2026-03-04 06:30:47 +00:00
commit bb402d4ccc
777 changed files with 135661 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
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)
}
}
// isReservedIP reports whether ip falls within any reserved/private range.
func isReservedIP(ip net.IP) bool {
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
}

120
plugin/webhook/webhook.go Normal file
View File

@@ -0,0 +1,120 @@
package webhook
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"time"
"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,
},
}
)
// 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"`
}
// 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")
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(resp.Body)
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 spawns a new goroutine to handle the request and does not wait for the response.
func PostAsync(requestPayload *WebhookRequestPayload) {
go func() {
if err := Post(requestPayload); err != nil {
slog.Warn("Failed to dispatch webhook asynchronously",
slog.String("url", requestPayload.URL),
slog.String("activityType", requestPayload.ActivityType),
slog.Any("err", err))
}
}()
}

View File

@@ -0,0 +1 @@
package webhook