Skip to content

Commit 7e572a4

Browse files
Merge branch 'master' into fix/auth-pat-scope-chain-and-redirect-open
2 parents fe5d671 + f69f6f2 commit 7e572a4

15 files changed

Lines changed: 1542 additions & 64 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "instant-api — webhook auth failures (Brevo/SES — secret unset or signature mismatch)",
3+
"policyName": "instant-api alerts",
4+
"description": "P1 operator signal. Fires when inbound email-provider webhooks (Brevo HMAC, SES/SNS RSA) authenticate-fail at the api boundary. Two distinct reasons are emitted: (a) reason='secret_unset' — the operator has not deployed BREVO_WEBHOOK_SECRET / SES_SNS_SUBSCRIPTION_ARN to the api Deployment. This means EVERY inbound provider event is rejected and forwarder_sent.classification is never updated. Rule 12 (email truth surface) is broken until the operator deploys the secret. CRITICAL within 5 min — fix is one kubectl set env. (b) reason='signature_mismatch' — secret IS configured but the inbound signature does not verify. Real causes: (1) provider rotated their signing key, (2) drive-by probe traffic, (3) man-in-the-middle on the webhook URL. WARN at >10/h — operator should rotate the secret in the dashboard if the provider confirms a key rotation. Source: api/internal/handlers/email_webhooks.go webhookAuthFailure() helper; counter instant_webhook_auth_failures_total. API-19/96/97/98 (QA 2026-05-29).",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT count(*) FROM Metric WHERE metricName = 'instant_webhook_auth_failures_total' AND reason = 'secret_unset' FACET webhook"
8+
},
9+
"terms": [
10+
{
11+
"priority": "CRITICAL",
12+
"operator": "ABOVE",
13+
"threshold": 0,
14+
"thresholdDuration": 300,
15+
"thresholdOccurrences": "ALL"
16+
}
17+
],
18+
"signal": {
19+
"aggregationWindow": 60,
20+
"aggregationMethod": "EVENT_FLOW",
21+
"aggregationDelay": 120,
22+
"fillOption": "STATIC",
23+
"fillValue": 0
24+
},
25+
"expiration": {
26+
"expirationDuration": 3600,
27+
"openViolationOnExpiration": false,
28+
"closeViolationsOnExpiration": true
29+
},
30+
"violationTimeLimitSeconds": 86400
31+
}

internal/handlers/auth_first_ordering_test.go

Lines changed: 583 additions & 0 deletions
Large diffs are not rendered by default.

internal/handlers/billing.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,95 @@ var reusableSubscriptionStatuses = map[string]struct{}{
9898
// from buying a plan they already pay for.
9999
const errCheckoutAlreadyOnTier = "already_on_plan"
100100

101+
// ── BUG-P112 traffic_env derivation + live-key-in-dev guard ─────────────────
102+
//
103+
// Razorpay's API-key convention encodes the environment in the prefix:
104+
//
105+
// rzp_live_* → LIVE mode (real card mandates, real money)
106+
// rzp_test_* → TEST mode (sandbox card-mandate fixtures, no money moves)
107+
//
108+
// Surface this derived field on every checkout response (rule 22 — agents and
109+
// the SPA must be able to read the mode WITHOUT seeing the actual key value).
110+
// CRITICAL: the actual `RazorpayKeyID` value MUST NEVER leak in any response
111+
// — only the boolean derivation `traffic_env: "production" | "test"`. Tests
112+
// pin this constraint.
113+
//
114+
// In addition to surfacing the derivation, the handler short-circuits with
115+
// 503 billing_misconfigured when ENVIRONMENT≠"production" but the key is
116+
// LIVE. Real money flowing through a staging/dev deployment is the BUG-P111
117+
// failure mode (anonymous /app/checkout reaching a LIVE subscription page).
118+
// Fail fast at the API instead of minting the subscription.
119+
120+
// razorpayLiveKeyPrefix / razorpayTestKeyPrefix match the documented Razorpay
121+
// key-ID convention. https://razorpay.com/docs/api/authentication/#api-keys.
122+
// Lowercase comparison via strings.HasPrefix(strings.ToLower(...)) so a stray
123+
// uppercase key does not bypass the guard.
124+
const (
125+
razorpayLiveKeyPrefix = "rzp_live_"
126+
razorpayTestKeyPrefix = "rzp_test_"
127+
)
128+
129+
// trafficEnv classifies a Razorpay key ID as "production" (LIVE) or "test".
130+
// Returns ("test", false) for empty/unrecognised input — the safer default
131+
// is "test" because callers branching on the field then treat the deployment
132+
// as non-prod, and the missing-config branch (billing_not_configured) catches
133+
// the empty case before this is ever surfaced.
134+
//
135+
// recognised=true means the key carried a known prefix — the live/test
136+
// distinction is authoritative. recognised=false means the key was empty
137+
// or didn't match the convention; do not draw deployment conclusions.
138+
func trafficEnv(razorpayKeyID string) (env string, recognised bool) {
139+
k := strings.ToLower(strings.TrimSpace(razorpayKeyID))
140+
if k == "" {
141+
return "test", false
142+
}
143+
if strings.HasPrefix(k, razorpayLiveKeyPrefix) {
144+
return "production", true
145+
}
146+
if strings.HasPrefix(k, razorpayTestKeyPrefix) {
147+
return "test", true
148+
}
149+
return "test", false
150+
}
151+
152+
// deploymentEnv normalises the configured ENVIRONMENT value for comparison
153+
// against the Razorpay key class. "production" is the only value that
154+
// permits a LIVE key; everything else (development, test, staging,
155+
// preview-*, "") rejects.
156+
func deploymentEnv(cfgEnvironment string) string {
157+
return strings.ToLower(strings.TrimSpace(cfgEnvironment))
158+
}
159+
160+
// detectBillingMisconfiguration returns ("", "") when the (deployment, key)
161+
// pairing is valid, or (code, message) when it is dangerous and the request
162+
// must be short-circuited with 503 before any Razorpay call.
163+
//
164+
// The single failure mode this catches: ENVIRONMENT="development" (or any
165+
// non-prod value) paired with a LIVE Razorpay key. That combination created
166+
// BUG-P111 — a staging or dev deployment minted a real LIVE subscription
167+
// against the prod Razorpay account. Tests pin every variant explicitly.
168+
//
169+
// Note: a production deployment with a TEST key is NOT caught here — that's
170+
// a different class of operator bug (test cards in prod) which the existing
171+
// billing_not_configured + plan_id-missing guards already cover and which
172+
// surfaces honest test-card behaviour to the user anyway. Adding a third
173+
// failure mode here would risk false-positiving every staging deploy that
174+
// uses a sandbox key by design.
175+
func detectBillingMisconfiguration(cfgEnvironment, razorpayKeyID string) (code, message string) {
176+
env, recognised := trafficEnv(razorpayKeyID)
177+
if !recognised {
178+
// Key is empty or has an unknown prefix — the billing_not_configured
179+
// branch below handles this. Don't double-classify.
180+
return "", ""
181+
}
182+
dep := deploymentEnv(cfgEnvironment)
183+
if env == "production" && dep != "production" {
184+
return "billing_misconfigured",
185+
"Razorpay LIVE key configured on a non-production deployment (ENVIRONMENT=" + dep + "). Refusing to mint a real subscription. Operator: rotate to a test key (rzp_test_*) or set ENVIRONMENT=production. See https://instanode.dev/docs/operator/billing-modes."
186+
}
187+
return "", ""
188+
}
189+
101190
// BillingHandler handles billing and Razorpay webhook endpoints.
102191
type BillingHandler struct {
103192
db *sql.DB
@@ -706,6 +795,34 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
706795
}
707796
planID := h.razorpayPlanIDFor(plan, frequency)
708797

798+
// ── BUG-P112 live-key-in-dev guard ─────────────────────────────────────
799+
// A LIVE Razorpay key pointed at a non-prod deployment is the
800+
// BUG-P111/P112 root cause: anyone (even unauth, via the BUG-P111 SPA
801+
// regression) reaching this handler would have minted a REAL Razorpay
802+
// subscription on the prod Razorpay account. Fast-fail with a clear
803+
// operator agent_action BEFORE the create-subscription call so the
804+
// Razorpay dashboard is not polluted with phantom test subscriptions.
805+
//
806+
// Run BEFORE the billing_not_configured check: a live-key-in-dev
807+
// deployment is dangerous EVEN IF the operator forgot to set
808+
// RAZORPAY_PLAN_ID_TEAM — the underlying configuration drift is the
809+
// signal we must surface first.
810+
if code, message := detectBillingMisconfiguration(h.cfg.Environment, h.cfg.RazorpayKeyID); code != "" {
811+
// Derive (but DO NOT log) the key class. Logging the actual key value
812+
// is a hard no — only the boolean derivation is safe.
813+
derivedTrafficEnv, _ := trafficEnv(h.cfg.RazorpayKeyID)
814+
slog.Error("billing.checkout.misconfigured_live_key_in_nonprod",
815+
"team_id", teamID,
816+
"plan", plan,
817+
"deployment_environment", h.cfg.Environment,
818+
"traffic_env", derivedTrafficEnv,
819+
"request_id", requestID,
820+
)
821+
return respondErrorWithAgentAction(c, fiber.StatusServiceUnavailable, code, message,
822+
"Operator: a LIVE Razorpay key is configured on a non-production deployment. Either rotate to a test key (rzp_test_*) or set ENVIRONMENT=production. Real subscriptions cannot be minted against this deployment until that is fixed.",
823+
"https://instanode.dev/docs/operator/billing-modes")
824+
}
825+
709826
if h.cfg.RazorpayKeyID == "" || h.cfg.RazorpayKeySecret == "" || planID == "" {
710827
slog.Warn("billing.checkout.not_configured",
711828
"team_id", teamID,
@@ -762,11 +879,16 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
762879
// subscription_id the first checkout produced — same response shape as a
763880
// fresh create below.
764881
if reuseSubID, reuseURL, reuse := h.reusablePendingCheckout(c.Context(), teamID, requestID); reuse {
882+
// BUG-P112: include traffic_env on the reuse path too — same
883+
// derivation, same NEVER-leak-the-key contract. SPA branches on
884+
// this field regardless of whether the sub was freshly minted.
885+
derivedTrafficEnv, _ := trafficEnv(h.cfg.RazorpayKeyID)
765886
return c.JSON(fiber.Map{
766887
"ok": true,
767888
"short_url": reuseURL,
768889
"subscription_id": reuseSubID,
769890
"reused": true,
891+
"traffic_env": derivedTrafficEnv,
770892
})
771893
}
772894
// ────────────────────────────────────────────────────────────────────────
@@ -935,10 +1057,15 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
9351057
"request_id", requestID,
9361058
)
9371059

1060+
// BUG-P112: surface the derived `traffic_env` so clients (the SPA, MCP
1061+
// agents, curl users) can detect production-vs-test mode without ever
1062+
// seeing the actual RAZORPAY_KEY_ID. NEVER include the key value here.
1063+
derivedTrafficEnv, _ := trafficEnv(h.cfg.RazorpayKeyID)
9381064
return c.JSON(fiber.Map{
9391065
"ok": true,
9401066
"short_url": shortURL,
9411067
"subscription_id": subID,
1068+
"traffic_env": derivedTrafficEnv,
9421069
})
9431070
}
9441071

0 commit comments

Comments
 (0)