Repository navigation
fix(billing,payments,security): harden payment, billing and platform correctness - #1703
Conversation
…correctness
Closes #1683
Closes #1684
Closes #1685
Closes #1686
Crypto payment resilience (#1683)
- Add src/services/transactionConfirmation.ts: a single receipt-confirmation
path that requires chain-appropriate confirmation depth, rejects
receipt.status === 0 instead of reporting a revert as success, and enforces
its own timeout. ethers v5 ContractTransaction.wait() silently ignores a
timeout argument, so delegating the bound to it left every contract-based
payment able to hang and stall the retry queue queued behind it.
- Route Superfluid creation, Sablier creation/approval and ERC-20 approval
through that helper. Remove a duplicate PaymentMethodService import that
was a redeclaration error.
- rpcProvider: serialise access to the shared connection.url, so concurrent
calls (getBalancesAcrossChains fans out in parallel) can no longer send a
request to the wrong endpoint. Only transport failures now count against a
circuit; a JSON-RPC revert previously evicted healthy nodes. Require a
success threshold to close a half-open circuit.
- paymentMethodService: compare fractional spend caps with an exact
fixed-point comparison. BigNumber.from("12.5") throws, and the throw was
swallowed, so every fractional amount failed the cap check. Read token
decimals from the contract instead of assuming 6/18, and use resilient
providers for balance, gas and bytecode reads.
- transactionQueueStore: persist a broadcast marker before handing a payload
to the wallet, so a crash between submission and receipt cannot replay a
payment. Retire rather than re-send items whose outcome is unknown, add
exponential backoff, measure staleness from the last attempt, and remove the
unhandled rejections that could terminate the app.
- walletStore: read the real PaymentAttempt fields (status/attemptedAt).
Reading non-existent success/createdAt made every method score a 100%
success rate, leaving smart fallback ordering permanently inert. Also clear
isLoading when verification returns false.
Billing correctness (#1684)
- Add src/utils/invoiceTotals.ts and use it in createInvoice/updateInvoice.
updateInvoice dropped the discount, so editing any line on a discounted
invoice raised the amount payable; the discount was also never persisted.
Clamp discounts to the subtotal so a total can never go negative.
- Fix an infinite average invoice amount when invoices exist but none are paid
(Infinity is truthy, so `x / 0 || 0` returned Infinity).
- Add src/utils/billingCycleMath.ts as the one billing-cycle normaliser.
subscriptionStore omitted the annual cycle, so a 120/year plan reported 120
MRR instead of 10; analyticsService and analyticsSlice hard-coded 4.345
weeks/month while BILLING_CONVERSIONS uses 52/12.
- monthlyRevenueRecognitionJob: recognise only the increment since the last
run. Partial recognition was never marked complete, so the full elapsed
portion was re-recognised on every run, compounding daily. Track cumulative
per-entry progress and clamp deferred revenue at zero.
- groupBilling: price the actual invoiced period rather than "the last N days
relative to now", clamp ownerDiscount to 0-100, and apply tax.
- InvoiceGenerator: clamp the discount against the subtotal.
- meteringService: bill only usage inside the period being invoiced (it summed
all lifetime history while labelling it one month), fix the off-by-one
period end, and stop same-millisecond events from colliding into one
dropped-by-idempotency event.
- batchChargeService: fix the `RunHistory` field typo that made every batch
charge throw.
Platform security (#1686)
- pagination: remove the published CURSOR_HMAC_SECRET fallback. A known cursor
secret let anyone forge a cursor whose `field` is interpolated into SQL.
Require the secret in production, compare signatures in constant time, and
reject any sort/field identifier outside [A-Za-z0-9_].
- createApiServer: remove the published WEBHOOK_SIGNING_KEY fallback, which
allowed forging every outbound webhook signature. Fail closed in production.
- ApiKeyRotationService: hash keys with a keyed HMAC rather than unsalted
SHA-256, so a leaked key table cannot be attacked offline.
- Add backend/shared/security/outboundUrlGuard.ts and apply it to Zapier hook
registration, hook delivery and webhook replay, closing an SSRF path to
cloud metadata and RFC1918 hosts. Validate at both registration and send
time so already-stored targets are covered too.
- eventReplayWorker: sign with a real HMAC. The previous "sha256=" prefix
wrapped a 31-bit rolling hash, so replayed deliveries were forgeable.
- server.ts: resolve the rate-limit tier from the server-side record instead of
the client-supplied x-subscription-tier header, which granted a 100x
allowance to anyone who asked. Opt back in with TRUST_TIER_HEADER=true
behind a trusted proxy.
- server.ts: apply a 256 KiB default body cap. 31 of 32 routes read unbounded
bodies, and the HubSpot webhook accumulated chunks before signature checks.
- gateway/createApiServer: bound express.json() (10mb -> 256kb) and add
Referrer-Policy and X-DNS-Prefetch-Control.
- revalidate: compare the secret in constant time and allowlist revalidate
paths instead of passing caller input straight to res.revalidate().
Tests (#1685)
- Add suites for the new modules (transactionConfirmation, decimalAmount,
invoiceTotals, billingCycleMath, outboundUrlGuard) and extend the transaction
queue and RPC provider suites to cover the idempotency, backoff and
circuit-breaker behaviour. Update the group billing test to the explicit
period signature.
- Document the new required secrets in .env.example.
Deployment notes
- CURSOR_HMAC_SECRET, WEBHOOK_SIGNING_KEY and API_KEY_HASH_PEPPER must be set
to random values of at least 16 characters in production; startup fails
otherwise. Outside production an ephemeral value is generated with a warning.
- Rate-limit tiers are no longer self-declared by clients. Deployments behind
an authenticating proxy that sets x-subscription-tier should set
TRUST_TIER_HEADER=true and ensure the proxy strips the header from inbound
traffic.
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 38302542 | Triggered | Zapier Webhook URL | 3a87a76 | backend/shared/security/tests/outboundUrlGuard.test.ts | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
…ture) (#1711) Follow-up to #1703, which is already merged. This clears the two review threads that were left unresolved there. ## Semgrep: `subtrackr.csrf.state-changing-route-without-csrf` (x2) The rule fired on these two lines in `backend/server/createApiServer.ts`: ```ts app.post('/api/v1/stripe/billing/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY })); app.post('/api/v1/paddle/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY })); ``` **These are false positives.** Both lines install a *body parser only* — they declare no handler and change no state. The rule matches `$ROUTER.post($PATH, $HANDLER)` and excepts only a middleware literally named `csrfProtection`, `requireAuth` or `verifyWebhookSignature`; it cannot follow the call into `createStripeBillingRouter` / `createPaddleRouter` in other files. The routes are genuinely protected, and they fail closed: | Condition | Response | | --- | --- | | Signing secret unconfigured (`verifier === null`) | `503` | | HMAC over raw bytes does not match | `401` | | Already-delivered event id | `200 { duplicate: true }`, never dispatched | The digest is compared with `crypto.timingSafeEqual` inside a 300 s replay window (`StripeWebhookVerifier.verify` / `PaddleWebhookVerifier.verify`). ### Why this is *not* fixed by adding middleware to the route The tempting fix — running `verify()` again at the app level — is a silent production regression. `verify()` calls `remember(eventId)` on success, so a second call within the same request returns `fresh: false`, and the router then answers `200 { duplicate: true }` **without ever dispatching the event**. Every Stripe/Paddle subscription lifecycle update would be dropped. So this lands as a documented suppression (inline `nosemgrep` plus an entry in `.semgrep/semgrep-suppressions.yml` with a 6-month expiry), with the double-verification trap written into the code comment so the next reviewer does not "fix" it the obvious wrong way. ## GitGuardian: hardcoded Zapier webhook URL `outboundUrlGuard.test.ts` asserted on: ```ts validateOutboundUrl('https://hooks.zapier.com/hooks/catch/1/2/') ``` A fixture, not a secret — but the `/hooks/catch/<n>/<n>/` path shape is indistinguishable from a live hook URL, so it raised a false incident. Replaced with a non-`/hooks/catch/` path on the same host. The assertion only needs "public host over https", and realistic Zapier-hostname coverage is kept. ## Verification - `node --experimental-strip-types --check` passes on both touched TS files. - The `nosemgrep` justification gate from `security-scan.yml` was reproduced locally and passes (every suppression line carries a `reason:`). - `npm run lint`, `npm run typecheck` and the Jest suites were **not** run — `node_modules` is not installed in this environment. CI will cover those.
|
Not a real secret — remediated in #1711. GitGuardian id 38302542 · File The finding is a test fixture, not a credential: const result = validateOutboundUrl('https://hooks.zapier.com/hooks/catch/1/2/');It is a URL passed to The reason it still fired is that the const result = validateOutboundUrl('https://hooks.zapier.com/example-endpoint');The assertion still needs only "public host over https", and keeping the real Thanks for the catch. |
Closes #1683
Closes #1684
Closes #1685
Closes #1686
Summary
One focused hardening pass across the four Stellar Wave iteration-10 issues. Each
section below names the concrete defect fixed, because in every case the
original code looked correct at a glance and failed in a way that silently
produced wrong money or unsafe behaviour.
#1683 — Improve crypto payment resilience
Reverted transactions were reported as successful. Every on-chain path called
tx.wait()with no arguments and then only asserted that a hash came back. Areceipt with
status === 0means mined but reverted, so a failed payment wassurfaced to the user as confirmed.
New
src/services/transactionConfirmation.tscentralises three concerns and isnow used by Superfluid creation, Sablier creation, Sablier approval and ERC-20
approval:
receipt.status === 0;ContractTransaction.wait(confirmations)does not forward a timeout argumentat all, so delegating the bound to it left every contract-based payment able
to hang forever — and because
processQueueis sequential, one stucktransaction stalled the whole offline retry queue behind it.
The RPC provider could send a request to the wrong endpoint. The base ethers
provider has one mutable
connection.url, and the fallback logic swapped it percall.
getBalancesAcrossChainsfans out in parallel, so concurrent callsinterleaved and requests could be routed to a node on a different chain. Access
is now serialised.
Healthy nodes were being evicted from the circuit. Every error counted as a
failure, including legitimate JSON-RPC errors such as reverts and insufficient
funds — a working node answering correctly was treated as unreachable.
isTransportFailurenow separates transport problems from application-levelanswers. Half-open circuits also required a single success to close; they now
require a threshold, matching the documented behaviour.
Fractional amounts could never pass the spend cap. The check was
ethers.BigNumber.from(amount).gt(limit), andBigNumber.from("12.5")throws.The throw was swallowed by the surrounding
catch, so every fractional amountwas recorded as a generic failure. Replaced with exact fixed-point comparison
(
src/utils/decimalAmount.ts) — notparseFloat, which would reintroduce floaterror.
Balances used hard-coded decimals.
checkBalanceassumedUSDC ? 6 : 18,under- or over-reporting any other ERC-20 and triggering spurious fallbacks.
Decimals are now read from the contract. All four raw
JsonRpcProviderconstructions also bypassed the timeout/circuit-breaker/fallback layer entirely.
A crash between broadcast and receipt could charge the user twice. The queue
removed an entry only after execution, so an app crash mid-flight left the
item pending and the next pass re-broadcast it. A
broadcastedAtmarker is nowpersisted before the payload reaches the wallet. Items whose outcome is
genuinely unknown are retired with a "needs review" notification rather than
re-sent, and the marker is only cleared for failures that prove nothing was
submitted (wallet rejection). Also added exponential backoff, staleness measured
from the last attempt rather than creation, reference-counted connectivity
listeners, and removal of unhandled rejections that can terminate the app.
Smart fallback ordering was inert.
walletStorereadattempt.successandattempt.createdAt, neither of which exists onPaymentAttempt(it hasstatusandattemptedAt). Every field resolved toundefined, the monitor'slook-back window discarded all attempts, and every method scored a 100% success
rate. Also fixed
verifyPaymentMethodleavingisLoadingstuck whenverification returned
false.#1684 — Strengthen subscription billing correctness
Editing a line on a discounted invoice increased the amount payable.
createInvoicesubtracted the discount but never persisted it, andupdateInvoicerecomputedsubtotal + taxwith no discount at all. Newsrc/utils/invoiceTotals.tsis the single arithmetic path for both, clamps thediscount to the subtotal so a total can never go negative, and rounds each
component to minor units.
Average invoice amount was
Infinity.totalRevenue / paidInvoices || 0returns
Infinitywhen no invoice is paid, becauseInfinityis truthy.MRR was inflated 12× for annual plans.
subscriptionStorecompared rawstrings and omitted
annualentirely, so a 120/year plan reported 120 MRRinstead of 10 — and this is the store that populates
statsfor the app.analyticsServiceandanalyticsSlicehad the same omission plus a hard-coded4.345weeks/month whileBILLING_CONVERSIONSuses52/12. All four now usesrc/utils/billingCycleMath.ts.Revenue was re-recognised in full on every run. In
monthlyRevenueRecognitionJob, a'partial'recognition was never markedcomplete, so the next daily run re-recognised the entire elapsed portion,
compounding daily. The job now recognises only the increment since the last run,
tracking cumulative per-entry progress via a new optional repository method.
When that method is absent it degrades to recognising each entry once at full
amount — slower, but never over-counting. Deferred revenue is clamped at zero.
Group invoices priced the wrong window.
generateInvoicederivedperiodDaysfrom the period but
aggregateChargescomputed its cutoff fromDate.now(), soa historical invoice priced a different set of charges than the period it claims.
aggregateChargesnow takes an explicit(periodStart, periodEnd).ownerDiscountwas unclamped (a negative value increased the bill, >100 gave anegative payable amount) and tax was hard-coded to 0.
Usage billing summed all history but labelled it one month.
calculateUsageBillingaggregated the unbounded history array while reportingthe current calendar month, over-billing every account with prior history each
cycle. Period end was also
new Date(y, m+1, 0)— midnight starting the lastday, excluding the final day. Additionally, auto idempotency keys built purely
from the event timestamp collided for same-millisecond events, and the second was
silently dropped and never rated.
Also fixed a
RunHistoryfield typo inbatchChargeServicethat made everybatch charge throw on
recordRun.#1686 — Harden platform security
Forgeable pagination cursors → SQL identifier injection.
pagination.tsfellback to the literal
'subtrackr-cursor-secret', published in the repo. Cursorscarry a
fieldthat is interpolated intoORDER BY/WHERE, so a known secretlet anyone forge one. The secret is now required in production, signatures are
compared with
timingSafeEqual, and any sort/field identifier outside[A-Za-z0-9_]is rejected.Forgeable webhook signatures.
createApiServerfell back to'default-dev-key-change-in-prod'for the key that signs every outboundwebhook. Removed; production startup now fails instead.
Weak webhook signature scheme.
eventReplayWorkeremittedsha256=<31-bit rolling hash>— labelled SHA-256, not SHA-256. Replaced with areal keyed HMAC.
API keys hashed with unsalted SHA-256. Now a keyed HMAC, so a leaked key
table cannot be attacked offline. Remains deterministic so lookups still work.
SSRF. Zapier hook
targetUrlwas stored verbatim and later fetchedserver-side, reaching cloud metadata (
169.254.169.254), loopback and RFC1918hosts. New
backend/shared/security/outboundUrlGuard.tsvalidates scheme,embedded credentials, and private/loopback/link-local/CGNAT/malformed-IP-literal
hosts. Applied at registration and delivery, so already-stored targets are
covered too.
100× rate-limit bypass. The tier came from the client-supplied
x-subscription-tierheader, so anyone could requestenterpriseand get10,000 req/hr instead of 100. Now resolved from the server-side record, with an
explicit
TRUST_TIER_HEADER=trueopt-in for trusted-proxy deployments.Unbounded request bodies.
maxByteswas optional and 31 of 32 routes omittedit, so any route could buffer the whole body into memory before parsing; the
HubSpot webhook accumulated chunks before its signature check. A 256 KiB default
now applies everywhere. The gateway's blanket
10mbJSON limit is reduced to256kb.Timing-unsafe secret comparison + unvalidated revalidate path. The
REVALIDATE_SECRETcheck short-circuited on the first differing byte, andpathwent straight intores.revalidate(). Now constant-time andallowlisted.
#1685 — Expand automated test coverage
New suites for the new modules (
transactionConfirmation,decimalAmount,invoiceTotals,billingCycleMath,outboundUrlGuard) plus extensions to thetransaction-queue and RPC-provider suites covering idempotency, backoff and
circuit-breaker behaviour. Each new test names the regression it locks in.
The existing
groupBillingtest was updated to the explicit period signature; itsprevious assertion encoded the
Date.now()-relative window bug.Deployment notes
Three secrets are now required in production and the process fails to start
without them:
CURSOR_HMAC_SECRETWEBHOOK_SIGNING_KEYAPI_KEY_HASH_PEPPERAll need random values of at least 16 characters. Outside production an
ephemeral per-process value is generated with a warning — which means cursors
and hashed keys do not survive a restart in dev. Documented in
.env.example.Behaviour change: rate-limit tiers are no longer self-declared by clients.
A deployment behind an authenticating proxy that sets
x-subscription-tiershould set
TRUST_TIER_HEADER=trueand ensure the proxy strips that header frominbound traffic. Without a server-side tier record, callers resolve to
FREE.Verification
npm run lint,npx tsc --noEmitandnpm run format:checkwere not runlocally —
node_modulesis not installed in this environment and installingdependencies was out of scope for this task. The changed
src/files weremanually checked against the configured Prettier rules (printWidth 100,
trailingComma: es5, single quotes) and for unused imports, sinceprettier/prettieris configured as an ESLint error.