Mock report. Generated by the skill's own renderer from a synthetic benchmark app with seeded bugs. No real project or real data.

Security audit · 2026-10-02 · commit c2b3f09

Ledgerly

12 issues to fix (2 critical, 5 high, 4 medium, 1 low).

10 areas verified safe · 3 checks not assessed

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

  1. Any self-registered user becomes admin: Better-Auth additionalFields.role has no input: falsecritical · demonstrated · src/lib/auth.ts:27
  2. Stripe webhook never verifies the signature: a forged checkout.session.completed grants a paid plancritical · code reading only · src/app/api/webhooks/stripe/route.ts:7
  3. getInvoice server 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

  1. POST /api/auth/sign-up/email with role: "admin" in the body.
  2. Verify the e-mail and sign in.
  3. Open /admin: requireAdmin() passes because session.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')

auth-101 · Authentication & authorization

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

  1. POST /api/webhooks/stripe with a hand-written checkout.session.completed event and metadata.orgId of any organization.
  2. 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.

crypto-101 · Cryptography

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

  1. Sign in as a member of organization B.
  2. Call the getInvoice action (its id is in the client bundle via InvoiceQuickView) with an invoice id from organization A.
  3. 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

auth-102 · Authentication & authorization

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

  1. Sign in to any organization.
  2. GET /api/export?org=<other org id>.
  3. 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.

auth-103 · Authentication & authorization

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

  1. Open /p/<slug> of any public project.
  2. Read the RSC payload in the page source.
  3. It contains internalNotes, budgetCents, clientContactEmail and every document's s3Key.

Proof

Static: confirmed from the query and the client component's props type.

exposure-101 · Data exposure & secrets

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

  1. Open the assistant page.
  2. Search the client bundle for NEXT_PUBLIC_ANTHROPIC_API_KEY's inlined value.
  3. 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.

exposure-102 · Data exposure & secrets

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

  1. Call integration.testWebhook with url: http://169.254.169.254/latest/meta-data/.
  2. The response contains up to 2 KB of the internal service's answer.

Proof

Static: no outbound network allowed during the audit.

injection-101 · Injection & SSRF

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

  1. Organization A opens the dashboard; the usage query is cached under ["usage-month"].
  2. Organization B opens its dashboard within 10 minutes and sees A's usage.

business-logic-101 · Business logic

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

  1. A page on any site calls fetch('https://app/api/public/invoices', { credentials: 'include' }).
  2. The browser sends the session cookie and the page reads the response.

config-101 · Headers, CORS & configuration

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

  1. Ask the assistant to search invoices in organization <other id>, or plant that request in a document the model reads.
  2. The tool queries the other organization's invoices and customer e-mails.

injection-102 · Injection & SSRF

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

  1. POST /api/uploads with key: orgs/<other org>/projects/<id>/report.pdf.
  2. PUT to the returned URL overwrites the other organization's file.

upload-101 · File upload & storage

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

  1. None of them is used on a reachable path except CORS, covered by config-101.

dependency-101 · Dependencies

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.orgId from orgProcedure. · src/server/trpc/routers/invoice.ts:22
  • Document downloadThe document is looked up by id and orgId before presigning. · src/app/api/documents/[id]/download/route.ts:17
  • Project actionsgetProject and deleteProject scope by orgId in the statement. · src/app/(app)/projects/actions.ts:12
  • Login redirectnext must 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 cachegetOrgStats takes orgId as 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.

PackageVersionScopeCVSSFixed inAdvisories
next16.3.0prod (direct)9.515.5.24, 16.3.3, 16.3.6GHSA-2xp9-vwfh-vxw4, GHSA-p293-qw3h-jr36, GHSA-vcvr-r3jv-pc5j
sharp0.34.5unknown8.90.35.0, 0.35.4GHSA-f88m-g3jw-g9cj, GHSA-rgj7-g3m4-5g8c
postcss8.4.31unknown7.58.5.12, 8.5.23, 8.5.10, 8.5.18GHSA-6g55-p6wh-862q, GHSA-fxqj-rqcc-2cmp, GHSA-qx2v-qp2m-jg93, GHSA-r28c-9q8g-f849
hono4.12.25prod (direct)6.54.12.34, 4.13.5, 4.12.27, 4.13.7GHSA-54fx-42gc-7vw4, GHSA-79qm-7rj5-m7r9, GHSA-8j4g-w8fx-2239, GHSA-crvj-82cr-hjcx +7
vitest4.1.8dev (direct)5.94.1.11, 5.0.0-rc.2GHSA-82fw-gwwq-j7x9

Secrets

Found across the whole git history. Values are redacted; anything real that was ever committed must be rotated.

RuleWhereCommitStill in tree
stripe-access-token.env:424b5320 2026-05-04no (history only)
generic-api-key.env:324b5320 2026-05-04no (history only)

Filtered out

Candidates rejected after a second check of the code. Kept for the record; none of them needs action.

FindingReason
deleteProject lacks an ownership check
auth-104
best_practice
Resend webhook accepts unsigned events
crypto-102
no_evidence
PostHog key is public
exposure-103
best_practice