Pular para o conteúdo
Esta página ainda não foi traduzida. O conteúdo abaixo está em inglês. Ler em inglês

Security

Security building blocks

SSRF-safe HTTP client, signed URLs, incoming and outgoing webhooks, security events and the crypto-randomness lint rule.

Última atualização:

SSRF-safe HTTP client#

Use httpclient.Safe for every outbound request to a URL a user controls (webhooks, link previews, avatars by URL):

Go
client := httpclient.Safe(httpclient.Options{Timeout: 5 * time.Second, MaxBody: 2 << 20})
resp, err := client.Get(userURL)
// or: resp, err := httpclient.Get(ctx, userURL)
  • Only public addresses: loopback, private, link-local (cloud metadata 169.254.169.254), CGNAT, multicast and reserved ranges are refused, also as IPv4-mapped IPv6.
  • The check runs on the resolved IP at connect time, so DNS rebinding and redirects to internal hosts fail too.
  • http/https only, at most 5 redirects, no proxy, timeouts, and bodies over MaxBody fail. Options.Allow adds trusted prefixes.

Signed URLs#

Go
key, err := app.DeriveKey("signed.urls", 32) // config.ErrKeyMissing without APP_KEY
if err != nil {
	return err
}

signer := signed.New(key). // panics on a key shorter than 32 bytes
	WithPreviousKeys(app.DerivePreviousKeys("signed.urls", 32)...) // APP_KEY rotation
link, err := signer.Sign(appURL+"/unsubscribe/42", 7*24*time.Hour) // adds expires and signature

r.Get("/unsubscribe/{id}", unsubscribe).Use(signer.Middleware()) // 403 when tampered or expired

The signature covers path and query (HMAC-SHA256), is checked in constant time and works behind proxies. storage.TemporaryURL builds on it.

Webhook signatures#

Go
verify, err := webhook.HMAC("X-Hub-Signature-256", secret, 1<<20) // error at boot on an empty secret
if err != nil {
	return err
}

r.Post("/webhooks/github", handle).Use(verify)

ok := webhook.VerifyHMAC(secret, body, r.Header.Get("X-Signature")) // hex or base64, optional "sha256=" prefix
sig := webhook.Sign(secret, payload)                                  // for outgoing webhooks

webhook.Middleware(header, secret, maxBody) is the lenient form: with an empty secret it reports nothing and refuses every request, so prefer webhook.HMAC. The middleware reads at most maxBody bytes (413; 0 means 1 MiB), verifies, restores the body for the handler and answers 401 otherwise. A plain body HMAC does not stop someone who captured a valid request from sending it again, so add the two pieces below.

Standard Webhooks (Svix, Resend, Clerk…)#

Providers that follow Standard Webhooks send webhook-id, webhook-timestamp and webhook-signature headers. webhook.Standard checks the signature over id.timestamp.body and refuses timestamps more than 5 minutes away, so a captured request cannot be replayed later:

Go
verify, err := webhook.Standard(cfg.WebhookSecret, webhook.StandardOptions{}) // "whsec_..." from the provider
if err != nil {
	return err // a malformed secret fails at boot, not on the first webhook
}

r.Post("/webhooks/resend", handle).Use(verify)
OptionDefault
MaxBody1 MiB
Tolerance5 minutes, both directions
Nowtime.Now (inject a fixed clock in tests)

Handle each event once#

Providers deliver at least once: the same event can arrive twice (timeouts, retries). webhook.Dedupe remembers handled event ids in any cache.Store:

Go
c, _ := app.Cache()

r.Post("/webhooks/resend", handle).Use(
	verify, // always verify first
	webhook.Dedupe(c.Store(), webhook.StandardID, webhook.DedupeOptions{}),
)
SituationAnswer
First deliveryhandler runs; a 2xx answer is remembered for TTL (72h)
Redelivery of a handled event200 with Webhook-Duplicate: true, handler not run
Duplicate while the first is still running409, the provider retries
Handler answered 3xx/4xx/5xxnot remembered, so the provider's retry runs
No event id400
Store unavailable503 (fail closed)

For providers with their own headers, pass a function that returns the event id, for example func(r *http.Request) string { return r.Header.Get("X-Event-Id") }. Use a shared store (database or Redis) when the app runs on more than one instance.

Outgoing webhooks#

Sometimes your app is the provider: a marketplace tells each seller "order.paid" at a URL the seller typed in. webhook.Sender does the hard parts:

  • it signs every request with the Standard Webhooks scheme, so receivers can verify it with webhook.Standard (or any Standard Webhooks library);
  • it sends from a queue job, so a slow or broken seller never slows down your request handler, and failed attempts are retried with exponential backoff;
  • it sends through httpclient.Safe, so a seller cannot make your server call http://169.254.169.254 or http://localhost:6379.

1. Give each endpoint a secret#

When a seller registers their URL, generate a secret, store both and show the secret once:

Go
secret, err := webhook.NewSecret() // "whsec_" + 32 random bytes in base64
if err != nil {
	return err
}

_, err = db.ExecContext(ctx,
	`INSERT INTO seller_webhooks (seller_id, url, secret) VALUES (?, ?, ?)`, sellerID, url, secret)

Store the secret as you would any credential (for example as an orm.Encrypted[string] column).

2. Create the sender and register its job#

The sender looks the endpoint up by id on every attempt. The queue only stores that id, never the URL or the secret, and a URL or secret changed by the seller applies to pending retries too.

Go
deliveries, err := webhook.NewSQLDeliveryLog(db, "") // table "webhook_deliveries"
if err != nil {
	return err
}

if err := deliveries.CreateTable(ctx); err != nil {
	return err
}

sender, err := webhook.NewSender(webhook.SenderOptions{
	Endpoint: func(ctx context.Context, id string) (webhook.Endpoint, error) {
		var ep webhook.Endpoint
		var active bool

		err := db.QueryRowContext(ctx,
			`SELECT url, secret, active FROM seller_webhooks WHERE seller_id = ?`, id).Scan(&ep.URL, &ep.Secret, &active)
		if errors.Is(err, sql.ErrNoRows) || (err == nil && !active) {
			return ep, webhook.ErrEndpointDisabled // drop the delivery, no retries
		}

		return ep, err // other errors are retried
	},
	Log: deliveries,
	OnGone: func(ctx context.Context, id string) error { // the seller answered 410 Gone
		_, err := db.ExecContext(ctx, `UPDATE seller_webhooks SET active = false WHERE seller_id = ?`, id)
		return err
	},
})
if err != nil {
	return err
}

q, err := app.Queue()
if err != nil {
	return err
}

if err := q.Register(sender.Job()); err != nil {
	return err
}

SQLDeliveryLog owns its schema (only the table name is configurable). If you already have a deliveries table, implement webhook.DeliveryLog instead and map each Attempt (endpoint id, event id, status, duration, error category, time; never the URL, secret or payload) onto your columns. Record runs after every attempt from concurrent workers; its error is logged and never retries the delivery:

Go
Log: webhook.DeliveryLogFunc(func(ctx context.Context, a webhook.Attempt) error {
	_, err := db.ExecContext(ctx,
		`INSERT INTO seller_webhook_deliveries (seller_id, message_id, http_status, failure, created_at)
		 VALUES (?, ?, ?, ?, ?)`, a.Endpoint, a.EventID, a.Status, a.Error, a.At)
	return err
}),

3. Send an event#

Go
payload, err := json.Marshal(map[string]any{
	"type": "order.paid",
	"data": map[string]any{"order_id": order.ID, "total": order.Total},
})
if err != nil {
	return err
}

err = sender.Send(ctx, q, webhook.Delivery{
	ID:       "order_paid_" + strconv.FormatInt(order.ID, 10), // optional: "msg_<random>" when empty; no dots or spaces, at most 255 bytes
	Endpoint: strconv.FormatInt(order.SellerID, 10),
	Payload:  payload,
})

Send only enqueues; a worker (vukem queue:work, or serve with the memory driver) makes the HTTP call. The same webhook-id is sent on every retry, so the seller can drop duplicates (with webhook.Dedupe if they use Vukem too). The payload is stored in the job as JSON, so insignificant whitespace may be removed before sending; the signature always covers the exact bytes sent.

What happens on each attempt#

Seller's answerResult
2xxdelivered, done
410 Gonenot retried; OnGone runs (disable the endpoint there)
3xxnot retried: redirects are not followed, because they would send the signed body elsewhere
any other status, a timeout or a connection errorretried after 30s, 1m, 2m, 4m … (capped at 1h), 8 retries, then the job lands in queue:failed
URL resolves to a loopback, private or metadata addressrefused before connecting (httpclient.ErrBlockedAddress), retried, then failed
http:// URL, user:password@ in the URL, missing or malformed secretrefused (nothing is sent unsigned or in plaintext), retried, then failed
Endpoint returns webhook.ErrEndpointDisableddropped without a request
Delivery.ID empty or invalid when the job runsdropped (recorded as invalid); retrying cannot fix it
OnGone returns an errorlogged; the delivery is still not retried, so make OnGone reliable

Each attempt is recorded in the log: endpoint id, event id, status, duration, time and an error category (blocked_address, timeout, refused, tls, network, http_status, invalid, or empty on success). The category is safe to show the endpoint owner; the full error (resolved address, TLS details) stays in your server log. Show a seller their last attempts (at most 1000) with:

Go
attempts, err := deliveries.Recent(ctx, sellerID, 20) // newest first

Logs and errors name the endpoint id and event id only: never the URL (it may carry tokens), the secret or the payload.

OptionDefault
Timeout15s per HTTP attempt
Retries8 (-1 for no retries)
Backoffqueue.Exponential(30 * time.Second)
Loggerslog.Default()
Nowtime.Now (the webhook-timestamp clock)
InsecureAllowHTTP, InsecureAllowoff; tests only (see below)

The receiving side#

A seller using Vukem verifies your deliveries with the middleware above:

Go
verify, err := webhook.Standard(sellerSecret, webhook.StandardOptions{})
if err != nil {
	return err
}

r.Post("/webhooks/marketplace", handleOrderPaid).Use(verify)

To sign a request by hand (in another language's test, for example), webhook.SignStandard(secret, id, time.Now(), body) returns the webhook-signature value.

Testing deliveries#

httptest servers listen on 127.0.0.1 over plain HTTP, which the sender refuses by default. Opt in explicitly, in tests only:

Go
srv := httptest.NewServer(verify(handler))

sender, err := webhook.NewSender(webhook.SenderOptions{
	Endpoint:          func(context.Context, string) (webhook.Endpoint, error) { return webhook.Endpoint{URL: srv.URL, Secret: secret}, nil },
	InsecureAllowHTTP: true,
	InsecureAllow:     []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")},
})

err = sender.Deliver(ctx, webhook.Delivery{ID: "msg_1", Endpoint: "seller-1", Payload: []byte(`{"type":"order.paid"}`)})

Deliver makes one attempt and returns an error when the queue would retry it. To test the dispatch instead, use queue.NewFake() and queue.Dispatched.

Security events#

Every web.SecurityEvent (failed logins, authz.denied, CSRF rejections, rate limits, bad signatures, rejected tokens) is logged at WARN and dispatched as a security.Event on the app event bus:

Go
events.Listen(app.Events, func(ctx context.Context, e security.Event) error {
	if e.Name == "auth.login_failed" {
		app.Telegram.Send(ctx, "Failed login from "+html.EscapeString(e.IP))
	}
	return nil
})

security.Event carries Name, At, Method, Path, IP, RequestID and Attrs (never secrets).

Audit events#

Routine records that are not attacks (an admin recorded a payout, a role was granted) go through web.AuditEvent instead, so they do not drown the security alerts. It logs at INFO with message audit event and the same envelope (event, method, path, request_id), and dispatches a security.Audit (the fields of security.Event, a distinct type):

Go
web.AuditEvent(c.Request(), "payout.recorded", "actor_id", admin.AuthID(), "payout_id", p.ID)

events.Listen(app.Events, func(ctx context.Context, a security.Audit) error {
	return auditLog.Append(ctx, a.Name, a.Attrs)
})

Pass the actor as an attribute (its id) and never pass secrets or personal data such as names, emails or documents; a config.Secret value is logged masked. The dispatched security.Audit carries the client IP, which is personal data under LGPD/GDPR: set a retention period wherever you store audit records.

Lint guard#

The crypto-randomness depguard rule makes math/rand a lint error in auth, session, csrf, signed, social, webhook, config and uuid.