To fix
Verified findings, most severe first. “Demonstrated” means the exploit was run against a local instance or test; “code reading only” means it was confirmed by tracing the code.
Fix first
- Any self-registered user becomes admin: Better-Auth
additionalFields.rolehas noinput: falsecritical · demonstrated · src/lib/auth.ts:27 - Stripe webhook never verifies the signature: a forged
checkout.session.completedgrants a paid plancritical · code reading only · src/app/api/webhooks/stripe/route.ts:7 getInvoiceserver action returns any organization's invoice by idhigh · demonstrated · src/app/(app)/invoices/actions.ts:10
critical · 2
criticalAny self-registered user becomes admin: Better-Auth additionalFields.role has no input: falseDemonstratedsrc/lib/auth.ts:27
How to fix
Set input: false on role (and every privilege field) in additionalFields; assign roles only through an admin action.
Evidence
- File:
src/lib/auth.ts:27
Exploit steps
- POST /api/auth/sign-up/email with
role: "admin"in the body. - Verify the e-mail and sign in.
- Open /admin:
requireAdmin()passes becausesession.user.role === "admin".
Proof
$ node --test .security-audit/poc/auth-101-role.test.ts
ok 1 - sign-up accepts role=admin (session.user.role === 'admin')
criticalStripe webhook never verifies the signature: a forged checkout.session.completed grants a paid planCode reading onlysrc/app/api/webhooks/stripe/route.ts:7
How to fix
Read the raw body and call stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET) before any state change; dedupe by event.id.
Evidence
- File:
src/app/api/webhooks/stripe/route.ts:7
Exploit steps
- POST /api/webhooks/stripe with a hand-written
checkout.session.completedevent andmetadata.orgIdof any organization. - The handler upserts an active subscription for that organization.
Proof
Static: the route has no test harness; the absence of constructEvent is visible at the cited line.
high · 5
highgetInvoice server action returns any organization's invoice by idDemonstratedsrc/app/(app)/invoices/actions.ts:10
How to fix
Call requireOrg() inside the action and filter by eq(invoices.orgId, orgId) in the same statement.
Evidence
- File:
src/app/(app)/invoices/actions.ts:10
Exploit steps
- Sign in as a member of organization B.
- Call the
getInvoiceaction (its id is in the client bundle viaInvoiceQuickView) with an invoice id from organization A. - The action returns the full invoice row and its lines.
Proof
$ node --test .security-audit/poc/auth-102-getinvoice.test.ts
ok 1 - member of org B reads org A invoice INV-0042
highCSV export takes the organization id from ?org=, so any user downloads another tenant's customersCode reading onlysrc/app/api/export/route.ts:11
How to fix
Ignore ?org= or check membership of the requested organization before the query.
Evidence
- File:
src/app/api/export/route.ts:11
Exploit steps
- Sign in to any organization.
- GET /api/export?org=<other org id>.
- The response is the other organization's customer list with e-mails, phones and tax ids.
Proof
No runnable harness for the route in this repo (needs Postgres and Better-Auth); confirmed by tracing the handler.
highPublic project page serializes the whole database row, including internal notes and the client's e-mailCode reading onlysrc/app/p/[slug]/page.tsx:7
How to fix
Select only the public columns (columns: { name, description }) and map documents to { id, filename } before passing props to the client component.
Evidence
- File:
src/app/p/[slug]/page.tsx:7
Exploit steps
- Open /p/<slug> of any public project.
- Read the RSC payload in the page source.
- It contains
internalNotes,budgetCents,clientContactEmailand every document'ss3Key.
Proof
Static: confirmed from the query and the client component's props type.
highAnthropic API key is shipped to the browser through NEXT_PUBLIC_ANTHROPIC_API_KEYCode reading onlysrc/lib/ai-client.ts:4
How to fix
Move the suggestion call to a server route; drop the NEXT_PUBLIC_ variable and rotate the key.
Evidence
- File:
src/lib/ai-client.ts:4
Exploit steps
- Open the assistant page.
- Search the client bundle for
NEXT_PUBLIC_ANTHROPIC_API_KEY's inlined value. - Use the key against the provider API at the owner's expense.
Proof
Static: Next.js inlines NEXT_PUBLIC_* at build time; the client component imports this module.
highintegration.testWebhook fetches any URL and returns the response body (SSRF)Code reading onlysrc/server/trpc/routers/integration.ts:18
How to fix
Resolve and block private, loopback and link-local ranges, connect to the resolved address, refuse redirects, and return only the status code.
Evidence
- File:
src/server/trpc/routers/integration.ts:18
Exploit steps
- Call
integration.testWebhookwithurl: http://169.254.169.254/latest/meta-data/. - The response contains up to 2 KB of the internal service's answer.
Proof
Static: no outbound network allowed during the audit.
medium · 4
mediumunstable_cache key for monthly usage has no organization, so one tenant's usage is served to allsrc/server/queries/dashboard.ts:22
How to fix
Pass orgId as an argument of the cached function (or include it in the key parts).
Evidence
- File:
src/server/queries/dashboard.ts:22
Exploit steps
- Organization A opens the dashboard; the usage query is cached under
["usage-month"]. - Organization B opens its dashboard within 10 minutes and sees A's usage.
mediumPublic API reflects any Origin with credentials while session cookies are SameSite=Nonesrc/server/api/index.ts:14
How to fix
Use an origin allow-list for the widget and keep session cookies SameSite=Lax.
Evidence
- File:
src/server/api/index.ts:14
Exploit steps
- A page on any site calls fetch('https://app/api/public/invoices', { credentials: 'include' }).
- The browser sends the session cookie and the page reads the response.
mediumAI tool searchInvoices lets the model choose the organization idsrc/server/ai/tools.ts:13
How to fix
Remove orgId from the tool schema and use ctx.orgId.
Evidence
- File:
src/server/ai/tools.ts:13
Exploit steps
- Ask the assistant to search invoices in organization <other id>, or plant that request in a document the model reads.
- The tool queries the other organization's invoices and customer e-mails.
mediumPresigned upload uses the object key and content type from the request bodysrc/app/api/uploads/route.ts:21
How to fix
Build the key on the server from the session's org and a random id; allow-list content types.
Evidence
- File:
src/app/api/uploads/route.ts:21
Exploit steps
- POST /api/uploads with
key: orgs/<other org>/projects/<id>/report.pdf. - PUT to the returned URL overwrites the other organization's file.
low · 1
lowhono 4.12.25 has published advisoriespnpm-lock.yaml:1
How to fix
Upgrade hono to the fixed version listed below.
Evidence
- File:
pnpm-lock.yaml:1
Exploit steps
- None of them is used on a reachable path except CORS, covered by config-101.
Verified safe
Areas the audit examined and found protected, each with the control that protects it.
Authentication & authorization 4
- Invoice list and detail in the tRPC routerBoth procedures filter by
ctx.orgIdfromorgProcedure. ·src/server/trpc/routers/invoice.ts:22 - Document downloadThe document is looked up by id and
orgIdbefore presigning. ·src/app/api/documents/[id]/download/route.ts:17 - Project actions
getProjectanddeleteProjectscope byorgIdin the statement. ·src/app/(app)/projects/actions.ts:12 - Login redirect
nextmust start with a single/. ·src/components/login-form.tsx:20
Cryptography 2
- API key storage and comparisonKeys are stored as sha256 and compared with
timingSafeEqual. ·src/server/api/keys.ts:15 - Resend webhook signaturesvix verifies the raw body before any write. ·
src/app/api/webhooks/resend/route.ts:12
Injection & SSRF 2
- SQL constructionAll queries are Drizzle builders with bound values. ·
src/db/index.ts:7 - Exchange-rate fetchHost is constant and the currency is allow-listed. ·
src/server/services/rates.ts:4
Business logic 1
- Org stats cache
getOrgStatstakesorgIdas an argument, so it is part of the key. ·src/server/queries/dashboard.ts:7
Data exposure & secrets 1
- Analytics keyThe PostHog project key is public by design. ·
src/lib/analytics.ts:9
Not assessed
Nobody checked these. They are unknown, not safe.
- Production cookie domain and proxy headersDeployment settings are not in the repository · config
- Container base imageNo Dockerfile in the repository · dependency
- Limits under load against a running instanceThe audit does not start servers with production settings · rate-limit
Dependencies
Packages with known vulnerabilities in the lockfile.
| Package | Version | Scope | CVSS | Fixed in | Advisories |
|---|---|---|---|---|---|
| next | 16.3.0 | prod (direct) | 9.5 | 15.5.24, 16.3.3, 16.3.6 | GHSA-2xp9-vwfh-vxw4, GHSA-p293-qw3h-jr36, GHSA-vcvr-r3jv-pc5j |
| sharp | 0.34.5 | unknown | 8.9 | 0.35.0, 0.35.4 | GHSA-f88m-g3jw-g9cj, GHSA-rgj7-g3m4-5g8c |
| postcss | 8.4.31 | unknown | 7.5 | 8.5.12, 8.5.23, 8.5.10, 8.5.18 | GHSA-6g55-p6wh-862q, GHSA-fxqj-rqcc-2cmp, GHSA-qx2v-qp2m-jg93, GHSA-r28c-9q8g-f849 |
| hono | 4.12.25 | prod (direct) | 6.5 | 4.12.34, 4.13.5, 4.12.27, 4.13.7 | GHSA-54fx-42gc-7vw4, GHSA-79qm-7rj5-m7r9, GHSA-8j4g-w8fx-2239, GHSA-crvj-82cr-hjcx +7 |
| vitest | 4.1.8 | dev (direct) | 5.9 | 4.1.11, 5.0.0-rc.2 | GHSA-82fw-gwwq-j7x9 |
Secrets
Found across the whole git history. Values are redacted; anything real that was ever committed must be rotated.
| Rule | Where | Commit | Still in tree |
|---|---|---|---|
| stripe-access-token | .env:4 | 24b5320 2026-05-04 | no (history only) |
| generic-api-key | .env:3 | 24b5320 2026-05-04 | no (history only) |
Filtered out
Candidates rejected after a second check of the code. Kept for the record; none of them needs action.
| Finding | Reason |
|---|---|
deleteProject lacks an ownership checkauth-104 | best_practice |
| Resend webhook accepts unsigned events crypto-102 | no_evidence |
| PostHog key is public exposure-103 | best_practice |