VibeAudit · whole-repo audit, findings verified against the code, fix prompt per finding. Free quick scan, $19 deep audit. Scan your own repo →
DEEP AUDIT · 2026-09-07
hexclave/hexclave
70–89 fix first
<70 not ready
Hexclave is a multi-tenant auth/payments/email/deployments platform (a Stack Auth rebrand): Next.js App Router backend on Prisma/Postgres and ClickHouse, Stripe Connect billing, Svix webhooks, Nodemailer/Resend email, Fly.io deployments via Marshal, plus a dashboard and internal tool. The core route/auth scaffolding is mature and most endpoints are tenancy-scoped correctly, but there is one genuinely dangerous hole: the AI proxy at /api/latest/integrations/ai-proxy forwards any unauthenticated request to OpenRouter using the operator's own API key. Fix that first, then close the unbounded anonymous sign-up, the permission-deletion sign-up breaker, and the Stripe webhook stuck-PENDING recovery gap.
200 files reviewed · claude-fable-5-1 · deep audit
Findings (19)
proxyToOpenRouter reads STACK_OPENROUTER_API_KEY (line 10) and sets `Authorization: Bearer <operator key>` (line 30) on every forwarded request. The only look at the caller's `x-api-key` header (lines 19-20) decides whether to *log*, not whether to allow. handleApiRequest is an error/format wrapper, not an auth check. The subpath is attacker-controlled (`params.path.join('/')`, line 12), so any OpenRouter endpoint under /api can be hit, including chat completions and key/credit endpoints.
Anyone on the internet can run arbitrary LLM completions billed to your OpenRouter account, exhaust your credit, read key/credit metadata, and use your account for abuse that gets it banned. This is a direct money and abuse vector on launch day.
Require authentication before forwarding: validate the caller's `x-api-key` (or a Hexclave server/admin key via createSmartRouteHandler auth) against your own store, reject with 401 otherwise, and allow-list the OpenRouter subpaths you actually need (e.g. `v1/chat/completions`, `v1/messages`). Add per-key rate limits and a request body size cap. Example: `const key = req.headers.get('x-api-key'); if (!key || !(await isValidProxyKey(key))) return new Response('Unauthorized', { status: 401 }); if (!ALLOWED_SUBPATHS.has(subpath)) return new Response('Not found', { status: 404 });`
In apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts, the proxyToOpenRouter handler forwards requests to OpenRouter using our STACK_OPENROUTER_API_KEY without authenticating the caller (the x-api-key header is only used to decide logging). Add mandatory authentication: reject with 401 unless the caller presents a valid key that we verify against our database (create a lookup helper if none exists; the expected format is 'stack-auth-...'). Also restrict `subpath` to an explicit allow-list of OpenRouter endpoints we use (e.g. v1/chat/completions, v1/messages, v1/generation) and return 404 otherwise, add a max body size (e.g. 1MB), and add a simple per-key rate limit. Keep the FORWARD_TO_PRODUCTION dev behavior but apply the same auth check. Reason: today anyone on the internet can burn our OpenRouter credit.
The handler calls usersCrudHandlers.adminCreate with is_anonymous:true for any clientOrHigher request. Unlike the password/passkey/OTP routes, which check `tenancy.config.auth.*.allowSignIn` / `allowSignUp`, there is no config check here, and no rate limiting or bot protection is visible on the route. The publishable client key is public by design (it ships in browser bundles).
Anyone who views a customer's frontend source can script millions of anonymous user + refresh-token rows into that tenancy, bloating the database, inflating billing/plan usage metrics and event logs, and degrading list/users endpoints for the project owner.
Gate on a project config flag (e.g. `tenancy.config.auth.allowAnonymousSignUp` or at least `auth.allowSignUp`) and throw KnownErrors.SignUpNotEnabled when off; add per-IP/per-project rate limiting (Turnstile is already integrated elsewhere in the codebase) and a per-tenancy anonymous-user cap. Also consider a much shorter refresh-token TTL for anonymous sessions in createAuthTokens (expiresAt option).
In apps/backend/src/app/api/latest/auth/anonymous/sign-up/route.ts, the POST handler creates an anonymous user for any request holding a publishable client key, with no config check and no rate limiting. Add: (1) a check that anonymous sign-up is enabled for the tenancy (add `auth.allowAnonymousSignUp` to the config schema in packages/shared/src/config/schema.ts if it does not exist, default true for backward compat, and throw KnownErrors.SignUpNotEnabled if false); (2) per-IP and per-project rate limiting consistent with how the OTP/password sign-in routes are protected (reuse the existing Turnstile/sign-up-risk-engine machinery if applicable); (3) pass a shorter `expiresAt` (e.g. 7 days) to createAuthTokens for anonymous sessions. Reason: the publishable key is public, so this endpoint is effectively unauthenticated user creation.
deletePermissionDefinition removes the permission from `rbac.permissions` and scrubs it from other permissions' containedPermissionIds, but never removes it from `rbac.defaultPermissions.signUp / teamCreator / teamMember`. grantDefaultProjectPermissions (line 553) and grantDefaultTeamPermissions (line 583) iterate those maps and call grantProjectPermission / grantTeamPermission, which throw KnownErrors.PermissionNotFound (lines 477, 113) when the id is no longer in the config. The read-side converter (config/index.tsx lines 1572-1583) hides the stale ids, so the dashboard shows nothing wrong.
An admin deletes a permission via DELETE /team-permission-definitions/{id} or /project-permission-definitions/{id}; from that moment on every new user sign-up (or team creation) in the project fails with PermissionNotFound and nothing in the UI explains why.
In deletePermissionDefinition, also write `rbac.defaultPermissions.signUp`, `teamCreator`, and `teamMember` with the deleted id removed (same override call). Defensively, make grantDefaultProjectPermissions / grantDefaultTeamPermissions skip ids missing from the config (captureError instead of throwing) so a stale config can never block sign-up.
In apps/backend/src/lib/permissions.tsx: (1) In deletePermissionDefinition, extend the overrideEnvironmentConfigOverride call so that, in addition to rewriting 'rbac.permissions', it rewrites 'rbac.defaultPermissions.signUp', 'rbac.defaultPermissions.teamCreator', and 'rbac.defaultPermissions.teamMember' with options.permissionId removed. (2) In updatePermissionDefinition, when the id is renamed, rename it in those three defaultPermissions maps as well. (3) In grantDefaultProjectPermissions and grantDefaultTeamPermissions, skip (and captureError) any default permission id that is not present in the config instead of letting grantProjectPermission/grantTeamPermission throw PermissionNotFound. Reason: deleting a permission that is a default currently breaks all future sign-ups for the project.
claimStripeEvent inserts PENDING and acks 200; processing runs fire-and-forget after the ack (see the comment in internal/flush-background-tasks/route.tsx). The ON CONFLICT clause only re-claims when status = 'FAILED' (line 34). If the serverless instance is recycled or the process crashes mid-processing, the row stays PENDING forever, and every Stripe retry of that event returns `shouldProcess: false`. The comment admits recovery is manual.
Lost subscription.updated / invoice.paid / cancellation events mean customers who paid are not granted access, or cancelled customers keep access, with no alert and no automatic retry. Money and entitlement drift that only shows up as support tickets.
Add a staleness takeover: `WHERE "StripeWebhookEvent"."status" = 'FAILED' OR ("StripeWebhookEvent"."status" = 'PENDING' AND "StripeWebhookEvent"."updatedAt" < now() - interval '10 minutes')`. Add a cron step (alongside run-cron-jobs) that finds PENDING rows older than N minutes and replays them from `payload`, and captureError when it finds any.
In apps/backend/src/lib/stripe-webhook-events.ts, claimStripeEvent only lets a redelivered Stripe event be reprocessed when the existing row is FAILED, so a PENDING row whose worker died (instance recycle) blocks all future redeliveries. Change the ON CONFLICT ... WHERE clause to also take over PENDING rows whose updatedAt is older than 10 minutes. Additionally add a periodic recovery job (hook into the existing cron infrastructure in apps/backend/scripts/run-cron-jobs.ts or a new internal cron route protected by CRON_SECRET) that selects PENDING rows older than 10 minutes, re-runs the webhook processing from the stored `payload`, and calls captureError so we are alerted. Reason: at-least-once Stripe delivery is currently being turned into at-most-once with silent loss.
Quick wins
- · Put the AI proxy behind authentication and a subpath allow-list today; it is the only critical item and is a ~30 line change.
- · Guard STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING with getNodeEnvironment() so a leaked test flag cannot reach production.
- · Stop passing `{ req }` to captureError in check-feature-support and `nicify(req)` to console.warn in the dashboard catch-all.
- · Add `limit: 100` + auto-pagination to stripe.subscriptions.list in syncStripeSubscriptions.
- · Make the Stripe webhook claim take over stale PENDING rows (updatedAt older than 10 minutes) so retries are not dropped.
- · Pass an explicit `{ from, to, subject, html, text }` to nodemailer instead of spreading the whole options object.
- · Move the hardcoded BLOCKED_PROJECT_ID / BLOCKED_DOMAINS out of email-queue-step.tsx into config.
What's already good
- · Consistent createSmartRouteHandler pattern with yup request/response schemas gives every route explicit auth-type and tenancy scoping; IDOR surface is small because handlers consistently filter by tenancy.id.
- · Deployment code is unusually careful: env var resolution never leaks values into error messages, build-log redaction fails closed on missing KMS material, secret defaults are never persisted, and prototype-key edge cases (__proto__) are handled.
- · SSRF protections exist for tenant-controlled SMTP hosts (egress policy, DNS pinning) and OAuth provider URLs.
- · Email queue uses SKIP LOCKED claims, advisory locks per tenancy, stuck-row detection, and refuses to auto-retry sends that may have been delivered (avoids duplicate emails).
- · Stripe webhook idempotency table, JWT issuer aliasing for the domain migration, and the config-agent FOR UPDATE row locking show real attention to concurrency in newer code.
- · Payments code has thoughtful validation (free-trial constraints, product-line conflicts, legacy snapshot normalization) and in-source vitest coverage for the tricky helpers.
Do this first
- Lock down the AI proxy (F1): require a validated key, allow-list OpenRouter subpaths, add body size and rate limits.
- Close the unbounded anonymous sign-up (F2) with a config gate, rate limiting and shorter session TTL.
- Fix permission deletion so defaultPermissions are cleaned and default grants never throw (F3), and make config overrides transactional (F8).
- Add stale-PENDING takeover and a replay cron for Stripe webhook events (F4), paginate subscriptions.list (F12), and lock grantProductToCustomer against double grants (F5).
- Redact credentials and PII from logging/error tracking (F11, F13, F17) and guard the passkey test flag (F18).
- Plan the hashed-storage migration for refresh tokens and API keys (F6) before user volume makes it painful.
- Convert the unsubscribe flow to GET-confirm/POST-act (F7), tighten the internal confirm endpoints (F14), and make the Stripe country configurable (F15).
Fixed things? Re-audit.
Run a new scan on the updated repo. Use a 5-pack key or pay per audit.