Skip to content

Commit 7fabead

Browse files
thanhan92-f1GitHub Copilot
andcommitted
feat: plan upgrade/downgrade with proration, webhook replay and rotation, security suite, per-route RBAC gates, a11y/responsive sweep
- subscriptions (plan SS77/SS78): ChangePlan rewritten with direction classification (float-trap-proof integer compare), immediate upgrades prorate via Stripe always_invoice (integer minor units, no local math), deferred downgrades ride subscription metadata intent executed idempotently at renewal (applied-marker + atomic clear), pending change replace/last-wins, cancel clears intent, billed-price reconcile heals plan drift; entitlements flip with licenses.plan_id and fail loud - webhooks (plan SS35): admin delivery replay (byte-identical payload, signed with current secret, X-HiTechCloud-Replay marker, replay_of lineage) + admin/portal secret rotation (shown once, immediate invalidation, crypto.Seal at rest) - security (plan SS61): IDOR/privilege-escalation/SQLi/SSRF/spoofing/secret-leak/XFF/replay regression suite (22 tests); SSRF fix - CGNAT 100.64/10 (Alibaba metadata), 0.0.0.0/8, multicast/broadcast and NAT64/Teredo/6to4 ranges now refused in model + dispatch paths; perf indexes migration 20261008_146000 (orders lower(email), webhook_deliveries, audit_logs x2, refresh_tokens expires) - RBAC rollout (plan SS8): every admin route now carries its granular RequirePermission gate (products/plans/licenses/orders/payments/customers/reports/audit/settings vocab), reports behind reports.read; admission policy unchanged (next step documented) - web (plan SS84/SS85/SS70): dialog aria-modal + focus semantics, labels/aria-labels, scoped tables + focusable scroll regions, skip-to-content + main target, focus-visible nav + aria-current, contrast fix, pagination/nav labels, mobile header fixes, client CSV export on 13 admin tables, destructive confirms completed; i18n en/vi/zh parity Co-authored-by: GitHub Copilot <copilot@github.com>
1 parent 2002f90 commit 7fabead

61 files changed

Lines changed: 4950 additions & 361 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/server/main.go‎

Lines changed: 199 additions & 180 deletions
Large diffs are not rendered by default.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Reverse of 20261008_146000_perf_indexes: only the indexes and the
2+
-- one column that migration created — nothing pre-existing is touched.
3+
4+
DROP INDEX IF EXISTS idx_refresh_tokens_expires_at;
5+
DROP INDEX IF EXISTS idx_audit_logs_entity_created_at;
6+
DROP INDEX IF EXISTS idx_audit_logs_actor_id_created_at;
7+
DROP INDEX IF EXISTS idx_webhook_deliveries_webhook_status_created;
8+
DROP INDEX IF EXISTS idx_orders_customer_email_lower;
9+
10+
ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS replay_of;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- 20261008_146000_perf_indexes — hot-path indexes (plan §62
2+
-- PERFORMANCE, §64 MIGRATIONS) plus the one additive column the §35
3+
-- delivery-replay marker needs.
4+
--
5+
-- Every index below serves a query that exists today in
6+
-- internal/store/*.go (annotated beside it). Nothing existing is
7+
-- touched or dropped, everything is IF NOT EXISTS, and the .down
8+
-- migration reverses all of it.
9+
--
10+
-- idx_orders_customer_email_lower
11+
-- store/portal_commerce.go — ListOrdersByEmail,
12+
-- CountOrdersByEmail, FindOrderByIdAndEmail and
13+
-- FindInvoiceForEmail filter on
14+
-- lower("order".customer_email) = ? / lower(orders.customer_email) = ?.
15+
-- An expression predicate cannot use the plain
16+
-- idx_orders_customer_email; mirrors idx_licenses_email_lower
17+
-- (20261001) on the licences table.
18+
--
19+
-- idx_webhook_deliveries_webhook_status_created
20+
-- store/webhook.go — ListWebhookDeliveries:
21+
-- WHERE webhook_id = ? [AND status = ?] [AND event = ?]
22+
-- ORDER BY created_at DESC, id DESC (webhookDeliveryOrder) —
23+
-- the admin delivery log + the "failed only" drill-down. The
24+
-- trailing id keeps pages stable on tied timestamps.
25+
--
26+
-- idx_audit_logs_actor_id_created_at
27+
-- store/analytics.go — GetUserDetail:
28+
-- WHERE actor_id = ? ORDER BY created_at DESC LIMIT 20.
29+
--
30+
-- idx_audit_logs_entity_created_at
31+
-- store/admin.go — ListAuditLogs:
32+
-- WHERE entity = ? [AND entity_id = ?]
33+
-- ORDER BY created_at DESC, id DESC.
34+
--
35+
-- idx_refresh_tokens_expires_at
36+
-- store/store.go — the prune sweep
37+
-- DELETE FROM refresh_tokens WHERE expires_at < now().
38+
--
39+
-- Column: webhook_deliveries.replay_of (plan §35 replay) — nullable,
40+
-- no default, no constraint. It names the delivery a replay row
41+
-- re-fires (model.WebhookReplayDelivery). Plain model.WebhookDelivery
42+
-- reads and writes never select or update it.
43+
44+
ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS replay_of TEXT;
45+
46+
CREATE INDEX IF NOT EXISTS idx_orders_customer_email_lower
47+
ON orders (lower(customer_email));
48+
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_status_created
49+
ON webhook_deliveries (webhook_id, status, created_at DESC, id DESC);
50+
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_id_created_at
51+
ON audit_logs (actor_id, created_at DESC);
52+
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_created_at
53+
ON audit_logs (entity, created_at DESC, id DESC);
54+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at
55+
ON refresh_tokens (expires_at);

‎internal/handler/portal_webhooks.go‎

Lines changed: 189 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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 (
4347
type 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.
90102
func 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.
371546
func (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),

‎internal/handler/portal_webhooks_test.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ type fakeCustomerWebhookStore struct {
3131
hooks []*model.CustomerWebhook
3232
nextID int
3333
audits []*model.AuditLog
34+
// rotates records the raw secrets handed to RotateCustomerWebhookSecret,
35+
// in call order.
36+
rotates []string
3437
}
3538

3639
func (f *fakeCustomerWebhookStore) CreateCustomerWebhook(_ context.Context, w *model.CustomerWebhook, rawSecret string) error {
@@ -72,6 +75,18 @@ func (f *fakeCustomerWebhookStore) FindCustomerWebhookByID(_ context.Context, id
7275
return nil, sql.ErrNoRows
7376
}
7477

78+
func (f *fakeCustomerWebhookStore) RotateCustomerWebhookSecret(_ context.Context, id, rawSecret string) error {
79+
for _, h := range f.hooks {
80+
if h.ID == id {
81+
h.Secret = rawSecret
82+
h.SecretPrefix = model.CustomerWebhookDisplayPrefix(rawSecret)
83+
f.rotates = append(f.rotates, rawSecret)
84+
return nil
85+
}
86+
}
87+
return sql.ErrNoRows
88+
}
89+
7590
func (f *fakeCustomerWebhookStore) UpdateCustomerWebhook(_ context.Context, w *model.CustomerWebhook) error {
7691
for i, h := range f.hooks {
7792
if h.ID == w.ID {

0 commit comments

Comments
 (0)