@@ -7,10 +7,13 @@ import (
77 "crypto/sha256"
88 "encoding/hex"
99 "encoding/json"
10+ "errors"
1011 "fmt"
1112 "io"
1213 "log/slog"
14+ "net"
1315 "net/http"
16+ "net/url"
1417 "strings"
1518 "time"
1619
@@ -29,12 +32,13 @@ import (
2932// POST /portal/webhooks Register one (secret returned ONCE)
3033// PATCH /portal/webhooks/:id Partial update (url?/events?/active?)
3134// DELETE /portal/webhooks/:id Hard delete
35+ // POST /portal/webhooks/:id/rotate Fresh signing secret, shown once
3236// POST /portal/webhooks/:id/test Fire a test delivery, report accepted/failed
3337//
34- // The signing secret is returned exactly once, in the Create answer, and
35- // is never recoverable afterwards. Every response's row carries only
36- // secret_prefix (a display hint); the full secret is json:"-" on the
37- // model and NEVER logged.
38+ // The signing secret is returned exactly once, in the Create and Rotate
39+ // answers, and is never recoverable afterwards. Every response's row
40+ // carries only secret_prefix (a display hint); the full secret is
41+ // json:"-" on the model and NEVER logged.
3842//
3943// Identity: middleware.SessionAuth (the /portal group's auth) puts the
4044// session user's id into the gin context under "user_id". Every lookup
@@ -43,6 +47,13 @@ import (
4347type PortalWebhookHandler struct {
4448 store customerWebhookStore
4549 client customerWebhookDoer
50+ // lookup resolves a delivery target's host so a name that now points
51+ // into loopback / RFC 1918 / CGNAT / metadata space is refused even
52+ // when the literal saved at write time was public (DNS changes between
53+ // save and delivery). nil means "classify literal addresses only" —
54+ // tests that fake the transport leave it unset; the production
55+ // constructor always wires the real resolver.
56+ lookup func (ctx context.Context , host string ) ([]net.IPAddr , error )
4657}
4758
4859// customerWebhookStore is the seam this handler needs from the store.
@@ -57,6 +68,7 @@ type customerWebhookStore interface {
5768 CountCustomerWebhooksByUser (ctx context.Context , userID string ) (int , error )
5869 FindCustomerWebhooksForEvent (ctx context.Context , event string ) ([]* model.CustomerWebhook , error )
5970 TouchCustomerWebhookLastDelivery (ctx context.Context , id string ) error
71+ RotateCustomerWebhookSecret (ctx context.Context , id , rawSecret string ) error
6072 Audit (ctx context.Context , log * model.AuditLog )
6173}
6274
@@ -88,7 +100,11 @@ const customerWebhookTestEvent = "webhook.test"
88100// NewPortalWebhookHandler wires the handler to the store. The caller
89101// (main.go) passes the concrete *store.Store; tests pass a fake.
90102func NewPortalWebhookHandler (s customerWebhookStore ) * PortalWebhookHandler {
91- return & PortalWebhookHandler {store : s , client : defaultCustomerWebhookClient ()}
103+ return & PortalWebhookHandler {
104+ store : s ,
105+ client : defaultCustomerWebhookClient (),
106+ lookup : net .DefaultResolver .LookupIPAddr ,
107+ }
92108}
93109
94110// defaultCustomerWebhookClient is the delivery client: short timeout so
@@ -136,6 +152,15 @@ func normalizeCustomerWebhook(w *model.CustomerWebhook) error {
136152 if err := model .ValidateCustomerWebhookURL (w .URL ); err != nil {
137153 return apperr .BadRequest (err .Error ())
138154 }
155+ // §61 hardening on top of the model's fast filter: that check is
156+ // std-lib-only and misses CGNAT (100.64/10 — where cloud metadata
157+ // services like 100.100.100.100 live), the protocol/documentation
158+ // blocks, multicast and the IPv4-embedding IPv6 transition ranges.
159+ // The write path refuses those too; the message is the model's, so
160+ // one policy reads as one policy.
161+ if err := guardWebhookLiteralTarget (w .URL ); err != nil {
162+ return apperr .BadRequest (err .Error ())
163+ }
139164 events , err := model .FoldCustomerWebhookEvents (w .Events )
140165 if err != nil {
141166 return apperr .BadRequest (err .Error ())
@@ -144,6 +169,113 @@ func normalizeCustomerWebhook(w *model.CustomerWebhook) error {
144169 return nil
145170}
146171
172+ // errWebhookPrivateTarget is the one refusal shared by every webhook
173+ // target check in this package. Same wording as the model's, so create,
174+ // update, and the delivery-time guard all read as one policy.
175+ var errWebhookPrivateTarget = errors .New ("url must not point at loopback or private addresses" )
176+
177+ // webhookInternalNets is everything a webhook must never be delivered
178+ // to. It mirrors service.nonPublicNets (the merchant delivery guard)
179+ // exactly: loopback, RFC 1918, CGNAT (100.64/10 — cloud metadata
180+ // services live there too), link-local, the IETF/benchmark/documentation
181+ // blocks, class E, broadcast, and the IPv6 equivalents including the
182+ // ranges that embed an IPv4 address (NAT64, 6to4). One list, one
183+ // doctrine — a target refused by the merchant system is refused by the
184+ // customer system and vice versa.
185+ var webhookInternalNets = func () []* net.IPNet {
186+ cidrs := []string {
187+ "0.0.0.0/8" , "10.0.0.0/8" , "100.64.0.0/10" , "127.0.0.0/8" ,
188+ "169.254.0.0/16" , "172.16.0.0/12" , "192.0.0.0/24" , "192.0.2.0/24" ,
189+ "192.168.0.0/16" , "198.18.0.0/15" , "198.51.100.0/24" , "203.0.113.0/24" ,
190+ "224.0.0.0/4" , "240.0.0.0/4" , "255.255.255.255/32" ,
191+ "::/128" , "::1/128" , "64:ff9b::/96" , "100::/64" , "2001::/32" ,
192+ "2001:db8::/32" , "2002::/16" , "fc00::/7" , "fe80::/10" , "ff00::/8" ,
193+ }
194+ nets := make ([]* net.IPNet , 0 , len (cidrs ))
195+ for _ , c := range cidrs {
196+ _ , n , err := net .ParseCIDR (c )
197+ if err != nil {
198+ panic ("webhook: bad cidr " + c )
199+ }
200+ nets = append (nets , n )
201+ }
202+ return nets
203+ }()
204+
205+ // isNonPublicWebhookIP reports whether ip is a target no webhook may be
206+ // delivered to (see webhookInternalNets). A v4-mapped v6 address is
207+ // checked as its v4 self. Nil/unparseable fails closed.
208+ func isNonPublicWebhookIP (ip net.IP ) bool {
209+ if ip == nil {
210+ return true
211+ }
212+ if v4 := ip .To4 (); v4 != nil {
213+ ip = v4
214+ }
215+ for _ , n := range webhookInternalNets {
216+ if n .Contains (ip ) {
217+ return true
218+ }
219+ }
220+ return false
221+ }
222+
223+ // guardWebhookLiteralTarget refuses a webhook URL whose HOST is a
224+ // literal non-public address (or "localhost"). Hostnames pass here and
225+ // are classified at delivery time — DNS can change between save and
226+ // send, so the literal check alone is not the authoritative guard.
227+ func guardWebhookLiteralTarget (raw string ) error {
228+ u , err := url .Parse (raw )
229+ if err != nil {
230+ return err
231+ }
232+ host := u .Hostname ()
233+ if strings .EqualFold (host , "localhost" ) {
234+ return errWebhookPrivateTarget
235+ }
236+ if ip := net .ParseIP (host ); ip != nil && isNonPublicWebhookIP (ip ) {
237+ return errWebhookPrivateTarget
238+ }
239+ return nil
240+ }
241+
242+ // guardWebhookResolvedTarget is the delivery-time half: literals are
243+ // re-classified (a row saved before a policy tightening stays refused),
244+ // and when a resolver is wired the host's ADDRESSES are classified too
245+ // — closing the DNS-rebinding window between save and send. An
246+ // unresolvable or address-less name fails closed: "we could not check"
247+ // is not "safe" (an NXDOMAIN for us can be the metadata service for
248+ // whatever resolves next).
249+ func guardWebhookResolvedTarget (ctx context.Context , lookup func (context.Context , string ) ([]net.IPAddr , error ), raw string ) error {
250+ if err := guardWebhookLiteralTarget (raw ); err != nil {
251+ return err
252+ }
253+ if lookup == nil {
254+ return nil
255+ }
256+ u , err := url .Parse (raw )
257+ if err != nil {
258+ return err
259+ }
260+ host := u .Hostname ()
261+ if net .ParseIP (host ) != nil {
262+ return nil // literal already classified above
263+ }
264+ addrs , err := lookup (ctx , host )
265+ if err != nil {
266+ return errWebhookPrivateTarget
267+ }
268+ if len (addrs ) == 0 {
269+ return errWebhookPrivateTarget
270+ }
271+ for _ , a := range addrs {
272+ if isNonPublicWebhookIP (a .IP ) {
273+ return errWebhookPrivateTarget
274+ }
275+ }
276+ return nil
277+ }
278+
147279// List answers GET /portal/webhooks: the caller's own endpoints, newest
148280// first. A row never carries the secret or its hash — only secret_prefix
149281// (see the model's json tags).
@@ -283,6 +415,49 @@ func (h *PortalWebhookHandler) Delete(c *gin.Context) {
283415 response .NoContent (c )
284416}
285417
418+ // Rotate answers POST /portal/webhooks/:id/rotate with a FRESH signing
419+ // secret, in the same shape Create answers with
420+ // ({"webhook": <row>, "secret": "whsec_…"} — 200 instead of 201). The
421+ // secret is shown ONCE here and is unrecoverable afterwards; the row
422+ // carries only secret_prefix.
423+ //
424+ // Storage goes through the same sealing as creation
425+ // (crypto.Seal inside store.RotateCustomerWebhookSecret), and the old
426+ // secret is invalid the moment the write commits: every read path opens
427+ // what is stored, so the next delivery is signed with the new secret —
428+ // immediate invalidation, with the single documented edge that a
429+ // delivery already in flight when the write commits was signed with the
430+ // secret it read before rotation.
431+ //
432+ // Ownership is the same quiet 404 as every other portal write: a
433+ // cross-user id is indistinguishable from a missing one, so probing
434+ // someone else's webhook id gains nothing. Never logs the secret; the
435+ // audit trail records only its display prefix.
436+ func (h * PortalWebhookHandler ) Rotate (c * gin.Context ) {
437+ wh , ok := h .resolveOwnedWebhook (c )
438+ if ! ok {
439+ return
440+ }
441+ ctx := c .Request .Context ()
442+ secret , err := model .NewCustomerWebhookSecret ()
443+ if err != nil {
444+ response .Internal (c , err )
445+ return
446+ }
447+ if err := h .store .RotateCustomerWebhookSecret (ctx , wh .ID , secret ); err != nil {
448+ response .Internal (c , err )
449+ return
450+ }
451+ wh .Secret = secret
452+ wh .SecretPrefix = model .CustomerWebhookDisplayPrefix (secret )
453+ h .store .Audit (ctx , & model.AuditLog {
454+ Entity : "customer_webhook" , EntityID : wh .ID , Action : "secret_rotated" ,
455+ ActorType : "portal_user" , ActorID : portalUserID (c ), IPAddress : c .ClientIP (),
456+ Changes : map [string ]any {"secret_prefix" : wh .SecretPrefix },
457+ })
458+ response .OK (c , gin.H {"webhook" : wh , "secret" : secret })
459+ }
460+
286461// DispatchTest answers POST /portal/webhooks/:id/test: it fires a signed
287462// test delivery at exactly this endpoint and reports whether the
288463// endpoint accepted it (2xx). It is synchronous with a short timeout, so
@@ -369,6 +544,15 @@ func (h *PortalWebhookHandler) DispatchCustomerEvent(ctx context.Context, event
369544// completed exchange (any status, including non-2xx) and an error only on
370545// a transport failure.
371546func (h * PortalWebhookHandler ) postDelivery (ctx context.Context , wh * model.CustomerWebhook , event string , data map [string ]any ) (int , error ) {
547+ // §61 delivery-time target guard (SSRF): the write-time policy only
548+ // saw the URL as saved — a legacy row, or a name whose DNS has moved
549+ // since, must not turn this dispatch into a request to the metadata
550+ // service or an internal host. Literals are always classified;
551+ // resolved addresses too whenever a resolver is wired (production
552+ // always has one — NewPortalWebhookHandler sets it).
553+ if err := guardWebhookResolvedTarget (ctx , h .lookup , wh .URL ); err != nil {
554+ return 0 , err
555+ }
372556 payload := map [string ]any {
373557 "event" : event ,
374558 "timestamp" : time .Now ().UTC ().Format (time .RFC3339 ),
0 commit comments