A Merchant-of-Record API that hands back its own webhook secrets, enumerates its whole seller base, and ships its internal schema on a public host
Kelviq is a Merchant-of-Record platform for SaaS sellers — it moves money and brokers webhooks on their behalf. Its API returns the full webhook signing secret in plaintext on every list call, lets any logged-in user enumerate the complete seller base through a status-code differential, and serves a 209-endpoint OpenAPI schema plus live Swagger UI from a public, production-routable staging host. None of it is individually Critical; together it is a clean blueprint for forging webhook events and building a platform-wide phishing list. Disclosed privately on 2026-05-21 with a stated publish date; published under the non-response policy after that date passed with no reply.
Editor’s note: Every secret, token, and identifier shown below belongs to a throwaway account this research created on the platform (a single seller account, signed up for the purpose). The webhook signing secret and DRF token are masked. No third-party seller’s data, money, or configuration was read, moved, or modified at any point. The findings describe mechanism and impact; the exact enumeration tooling is withheld while the issues are unpatched.
Target: kelviq.com — a Merchant-of-Record (MoR) and payments platform for SaaS sellers. Like Paddle or Lemon Squeezy, Kelviq becomes the reseller of record: it takes the buyer’s money, handles tax, and fires webhooks back to the seller’s backend so the seller can provision access. Tested surfaces: production API api.kelviq.com, staging stagingapi.kelviq.com, web app app.kelviq.com. Engagement window: 2026-05-21.
Disclosure status: Reported privately to [email protected] and [email protected] on 2026-05-21 with the full findings document, then re-sent to both founders’ personal inboxes on 2026-06-01, then a final notice on 2026-06-04 naming 2026-06-08 as the publish date. No acknowledgement on any channel through the deadline. Published under the non-response policy. The vendor still holds the full unredacted report; if they ship fixes, this post will be updated with the patch references.
Executive summary
A Merchant of Record sits in the single most trust-sensitive position a SaaS company can outsource: it holds the money, and it owns the webhook that tells the seller’s backend whether a customer paid. Forge that webhook and you flip entitlement state — grant yourself a paid plan, trigger a refund, activate a subscription — without ever touching the seller’s own infrastructure. The signing secret is the only thing standing between a request and that power.
Kelviq’s API returns that signing secret, in plaintext, on every read of the webhook-endpoints list.
That is the centre of gravity. Around it sit two findings that lower the cost of using it:
- Any authenticated user can enumerate the platform’s entire seller base through a status-code differential on an organisation endpoint. Users and orgs are provisioned 1:1 on a shared autoincrement, so the highest live ID is the seller count, and the list of valid IDs is the list of sellers.
- The full internal API surface is public. A 209-endpoint OpenAPI document, a live Swagger UI, and a Django admin login are all served from
stagingapi.kelviq.com— a production-routable host with no edge restriction. Payout, balance, finance, and partner-provisioning routes are all named in it.
None of these is individually Critical, and I am not going to inflate them. The webhook leak needs a secondary read primitive (an XSS on the dashboard, a shared screen, a captured error log) to reach a victim seller. The enumeration produces IDs, not credentials. But the platform is a money mover, and the three compose into a coherent attack: read the schema to learn the shape, enumerate the sellers to pick a target, and — given any dashboard-response read primitive — forge that seller’s webhooks. For a product whose entire value proposition is “trust us with the payment boundary,” that composition is the finding.
What a scanner would have caught: almost none of this.
Returning a secret on a list endpoint is a 200 with a valid JSON body — SAST and DAST both read it as success. The enumeration is a normal authenticated request that differs only in status code between “exists” and “not found”; no scanner flags a 200-vs-404 differential as a vulnerability without knowing that IDs are sequential and map 1:1 to sellers. The public schema is designed to be served. Every one of these is the product working as written. Finding them requires reasoning about what the responses mean for a money platform, not whether the requests error.
Architecture
| Component | Detail |
|---|---|
| Category | Merchant-of-Record / payments platform for SaaS sellers |
| Backend | Django + Django REST Framework |
| Auth | DRF Token (40-hex) in Authorization: Token …; tokens in SPA localStorage |
| Identity model | Users and organisations auto-provisioned 1:1 at signup on a shared autoincrement (observed: own user_id == org_id) |
| API surface | 209 endpoints / 287 operations (per the published OpenAPI document) |
| Staging | stagingapi.kelviq.com — production-routable, no edge auth, serves schema + Swagger + Django admin |
F1 — Webhook signing secret returned in plaintext on every list/retrieve
Severity: Medium Class: Credential over-disclosure on an authenticated endpoint
GET /api/v1/webhook/endpoints/ returns each configured endpoint with its full HMAC signing secret inline:
GET /api/v1/webhook/endpoints/
{
"results": [{
"id": "be4c2b07-…-ec3ba027422e",
"url": "https://example.com/webhooks",
"secret": "kq_whsec_••••••••••••dmg",
…
}]
}
Stripe, GitHub, and Slack all show a webhook secret exactly once — at creation — and never again; thereafter it is masked, and recovery requires an explicit rotate that returns a fresh value once. Kelviq re-exposes it on every read.
Why it matters. The signing secret is the seller backend’s sole proof that a webhook genuinely came from Kelviq. Any context that can read the seller’s own dashboard API responses — a stored XSS on app.kelviq.com, malware on an operator’s laptop, a screen-share during support, a captured error log or HAR file — walks away with the signing key. With it, an attacker forges refund.created, order.created, or subscription.created events straight to the seller’s backend and flips internal entitlement state, with Kelviq’s own infrastructure never involved and nothing anomalous in Kelviq’s logs.
Fix. Mask the secret on all reads after creation (kq_whsec_…dmg, or null). Provide a rotate action that returns the new value exactly once.
F1a — secret entropy is below norm
Generated secrets are 16 lowercase-alphanumeric characters (~83 bits). Stripe’s are ~64 characters. Not brute-forceable today, but it narrows the safety margin behind F1 and behind any future timing or partial-match weakness in signature verification. Bump to 32+ characters of URL-safe entropy.
F2 — Whole-platform seller enumeration via a status-code differential
Severity: Low–Medium Class: Object-existence oracle / user enumeration
Posting an empty body to the organisation-update route returns different status codes depending on whether the target organisation exists:
- Organisation exists, caller is not owner →
200 {"success":false,"message":"You don't have owner access…"} - Organisation does not exist →
404 {"detail":"No Organization matches the given query."}
Because organisations and users are auto-provisioned 1:1 on a shared autoincrement (own user_id equalled own org_id), walking the integer space turns this into a complete, current census of the platform: the highest ID that returns 200 is the live seller count, and every 200 is a real seller. Polling over time reveals signup velocity.
Why it matters. It produces a target list of every seller on a payments platform — ideal seed data for follow-on phishing or credential-stuffing, and exactly the input F1 wants (which sellers to go after once you have a read primitive). The mass-assignment protection on this same route is correct (privileged fields are stripped); the leak is purely the existence oracle.
Fix. Return a uniform 404 (or generic 403) for both “exists but not owner” and “does not exist.” Move organisation IDs to UUIDs so the space is not walkable in the first place.
F3 — Full internal API surface served publicly from a production-routable host
Severity: Medium Class: Information exposure / attack-surface disclosure
stagingapi.kelviq.com serves, with no edge restriction:
/api/schema/— a ~310 KB OpenAPI 3.0 document for all 209 endpoints / 287 operations, including/payouts/withdraw/,/finance/balance/, lemon-squeezy webhook intake, and partner-provisioning routes./api/docs/— a live, interactive Swagger UI./admin/— the Django admin login.
There is also a schema-versus-runtime divergence: 48 endpoints are annotated security: [{}] (anonymous-allowed) in the published schema, while the runtime actually requires authentication on at least the buyer-portal subset (verified: an annotated-anonymous portal route returns 401 live). A third-party integrator who trusts the public schema could design a flow that omits an Authorization check the runtime silently depends on.
Why it matters. It is a complete map of the money-movement surface, handed to an attacker for free — it collapses the recon phase of any targeted attack — and the schema/runtime mismatch can induce integrators to build insecure flows.
Fix. Put stagingapi behind an IP allowlist, basic auth, or VPN. If the schema must be public, generate security annotations from the same DRF permission_classes the runtime enforces, so the document cannot disagree with reality.
Lower-severity findings (hardening)
- F4 — Auth token echoed in a GET response body. The organisation-switch route returns the caller’s own DRF token in a
GETresponse. Direct impact is minimal (the caller already holds it), but GET bodies are more cacheable and the URL carries the org ID, so the token can surface in access logs, browser history, or CDN caches. Make it aPOST, and rotate to a fresh org-scoped token on switch rather than echoing the existing one. - F5 — Tokens never expire and are unscoped. API keys report
expiresAt: null; the session token shows no expiry, refresh, or scope. A token leaked through any channel above is a permanent, full-access credential until manually rotated — and it lives in SPA localStorage. Add expiry, refresh, an “active sessions” revoke UI, and scope. - F6 — Wildcard CORS on authenticated endpoints.
Access-Control-Allow-Origin: *withAccess-Control-Allow-Headers: *. Not exploitable on its own — auth is a header the browser won’t auto-send cross-origin, andAllow-Credentialsis correctly unset — but it removes a defence-in-depth layer that would otherwise blunt the token-leak vectors above. Echo a strict origin allowlist instead. - F7 — VPN/proxy/Tor detection appears to be a no-op. The PPP-discount fraud check (an advertised feature) returned
{"isVpn":false,"isProxy":false,"isTor":false}for every input tested, including spoofed forwarding headers pointing at a known Tor exit and a VPN range. If sellers rely on this to stop geo-arbitrage on regional pricing, they may be losing revenue invisibly. Verify the detection service is wired up in production; fail loud, not silent. - F8 — Low-entropy discount codes; F9 — vendor “submit for review” needs only two ≥50-char text fields. Both low individually; the vendor-submit flow’s mass-assignment protection was verified working (KYC/verified/approved flags are stripped server-side). Noted for the next refactor.
Properly defended (verified during the engagement)
Worth stating plainly, because it scopes the findings:
- Mass-assignment is correctly blocked on user-update, organisation-update, and vendor-submit (privileged fields silently dropped).
- Cross-tenant reads are scoped — sequential and UUID object routes (
customers/{id},promotions/{id},catalog/products/{uuid}) all404when not owned; cross-org switch returns404. - Partner endpoints reject user tokens (
401) — they require a separate auth scheme. - Query-param org tampering (
?organization=1) is ignored; the server resolves the org from the token.
The auth model is mostly sound. The problems are at the edges: what the responses hand back (F1, F4), what they reveal by differential (F2), and what is served where it shouldn’t be (F3).
Priority for the vendor
- F1 — stop returning the webhook secret on reads. Highest leverage.
- F3 — put
stagingapibehind the edge, or reconcile the schema’ssecurityannotations with runtime. - F2 — normalise
404for not-owner vs not-found. - F4 + F5 — token expiry, rotation on switch, active-sessions UI.
- F7 — confirm VPN detection is actually wired in production; it is a marketing claim, not just hygiene.
Disclosure timeline
- 2026-05-21 — routine review; findings captured against a throwaway seller account. Full report sent to
[email protected]and[email protected]. - 2026-05-21 → 2026-06-01 — no reply, no bounce.
- 2026-06-01 — re-sent to both founders’ personal inboxes.
- 2026-06-04 — final notice in the original thread naming 2026-06-08 as the publish date; offered to hold the date, walk the team through the findings, and retest for free on any reply.
- 2026-06-08 — deadline passed with no acknowledgement on any channel. Published under the non-response policy. The vendor holds the full unredacted report; this post will be updated with patch references if fixes ship.
Why this writeup exists
Every finding here is the product behaving exactly as written — a successful 200, a designed-to-be-public schema, a sequential ID. That is the point. A Merchant of Record’s risk does not live in its crashes; it lives in what its working responses give away about the payment boundary it was hired to guard. “Return the secret on every read” is a one-line convenience that quietly cancels the whole purpose of having a signing secret.
I do security research on AI-coded SaaS and the payment and infrastructure vendors they depend on. If you move other people’s money and want a second pair of eyes on the boundary before someone else finds the cracks, get in touch.