To fix
Verified findings, most severe first. “Failing test” means a regression test in the project’s own runner fails today; “code reading only” means it was confirmed by tracing the code.
Fix first
- Stripe webhook accepts unsigned events: anyone can activate, overwrite or cancel any organization's subscriptioncritical · code reading only · src/app/api/webhooks/stripe/route.ts:6-41
- pull_request_target workflow checks out, installs and builds the PR head with deploy secretscritical · code reading only · .github/workflows/preview.yml:3-5,7-9,15-32
- getInvoice server action has no authentication or tenant scope (BOLA on invoices and lines, callable from the client bundle)high · code reading only · src/app/(app)/invoices/actions.ts:1,10-15
critical · 2
criticalStripe webhook accepts unsigned events: anyone can activate, overwrite or cancel any organization's subscriptionCode reading onlysrc/app/api/webhooks/stripe/route.ts:6-41
How to fix
Read the raw body with request.text(), verify with stripe.webhooks.constructEvent(body, request.headers.get("stripe-signature"), STRIPE_WEBHOOK_SECRET) and return 400 on failure before the switch. Validate plan against the known price keys (pro, team) and resolve the org from the Stripe customer you created in startCheckout, not only from metadata. Docs also claim the opposite: README.md:33 ("Stripe webhooks are verified before any state changes.").
Evidence
- File:
src/app/api/webhooks/stripe/route.ts:6-41 - Code:
export async function POST(request: Request) {
const event = (await request.json()) as Stripe.Event; // :7, no signature check
switch (event.type) {
case "checkout.session.completed": {
const orgId = checkout.metadata?.orgId; // :12 attacker-chosen
const plan = checkout.metadata?.plan ?? "pro"; // :14 any string
await db.insert(subscriptions).values({ orgId, ..., plan, status: "active" })
.onConflictDoUpdate({ target: subscriptions.orgId,
set: { plan, status: "active", stripeSubscriptionId: String(checkout.subscription), ... } });
case "customer.subscription.deleted":
await db.update(subscriptions).set({ status: "canceled" })
.where(eq(subscriptions.stripeSubscriptionId, sub.id)); // :33-36Data flow
- Source: unauthenticated POST body of
/api/webhooks/stripe(route.ts:7);src/proxy.ts:14-22matcher does not cover/api/*, and the handler reads nostripe-signatureheader - Transformations:
request.json()cast toStripe.Event;metadata.orgId,metadata.plan,customer,subscriptionused as-is (:11-14); nostripe.webhooks.constructEventanywhere undersrc/(grep) - Sink: upsert on
subscriptionskeyed byorgId(:15-28), and status update keyed bystripeSubscriptionId(:33-36) - Sanitization: none. Contrast the Resend webhook, which verifies with svix before acting (
src/app/api/webhooks/resend/route.ts:10-21) - Verdict: VULNERABLE
Impact
Anyone on the internet, without an account, can write the billing record of any organization whose id they know: mark it active on any plan string (the plan the app shows on /settings/billing, billing/page.tsx:10-15, and counts in admin stats), and overwrite its real stripeSubscriptionId so later genuine Stripe cancellations no longer match. With a subscription id they can also set any subscription to canceled. Rated CRITICAL as unauthenticated tampering of the payment system of record; note that in this repo no feature is gated on plan/status yet (grep: only the billing page and admin stats read them), so the "free paid plan" is a record, not a feature unlock today. Organization ids are disclosed publicly by the public project page (see exposure-101), which removes the "must know the id" condition for any org with a public project.
Regression test
proof: static. .security-audit/tests/auth-101.stripe-webhook-signature.test.ts asserts that an unsigned checkout.session.completed and a badly signed customer.subscription.deleted get a 4xx and cause no insert/update; written but not executed because node_modules is absent and installing is forbidden by the audit rules.
Chained with
- exposure-101 (public page discloses orgId), concurrency-405 (no idempotency on the same handler)
criticalpull_request_target workflow checks out, installs and builds the PR head with deploy secretsCode reading only.github/workflows/preview.yml:3-5,7-9,15-32
How to fix
Build PR code under the pull_request trigger with no secrets; deploy previews from a separate workflow_run or pull_request_target job that never checks out or executes the head, gated by an environment with required reviewers. Set persist-credentials: false and do not use the shared pnpm cache in privileged jobs. Rotate PREVIEW_DATABASE_URL credentials, PREVIEW_AUTH_SECRET and VERCEL_TOKEN if outside PRs have ever run this workflow.
Evidence
- File:
.github/workflows/preview.yml:3-5,7-9,15-32 - Code:
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
...
- uses: actions/checkout@11bd719... # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
DATABASE_URL: ${{ secrets.PREVIEW_DATABASE_URL }}
BETTER_AUTH_SECRET: ${{ secrets.PREVIEW_AUTH_SECRET }}
- name: Deploy preview
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: echo "url=$(pnpm dlx vercel@44.7.3 deploy --prebuilt --token "$VERCEL_TOKEN")" >> "$GITHUB_OUTPUT"Data flow
- Source: any pull request, including from a fork, triggers the workflow in the base repository's privileged context (preview.yml:3-5); the untrusted head sha is checked out (preview.yml:15-17)
- Transformations: checkout persists the job's GITHUB_TOKEN (pull-requests: write, contents: read) in .git/config because persist-credentials is not false (compare ci.yml:16-17, which sets it)
- Sink:
pnpm installruns the PR's own package.json lifecycle scripts (preview.yml:23);pnpm buildruns the PR's next.config.ts and app code with PREVIEW_DATABASE_URL and PREVIEW_AUTH_SECRET in env (preview.yml:24-27); the PR code can also write files that the latervercel deploystep (VERCEL_TOKEN in env, preview.yml:28-32) executes, e.g. a planted binary on PATH or node_modules/.bin. A pull_request_target run also writes to the base branch's Actions cache scope (setup-nodecache: pnpm, preview.yml:22), so the PR can poison the pnpm store that later main-branch runs restore. - Sanitization: none: no
if:guard on author association, no environment with required reviewers, no label gate - Verdict: VULNERABLE
Impact
Anyone who can open a pull request against the repository can run arbitrary code in a job that holds the preview database URL, the preview auth secret, the Vercel token (typically scoped to the whole Vercel team, which may include production projects) and a pull-requests:write token, and exfiltrate them; they can also poison the dependency cache used by main-branch CI. Prerequisite (1): the repository accepts pull requests from outside collaborators (public or forkable). Kept CRITICAL: remote code execution with secrets, no account privilege needed.
Regression test
proof: static: .security-audit/tests/config-301.preview-workflow.test.ts (reads every workflow and asserts no pull_request_target workflow checks out the PR head) was written but not run; node_modules is absent and installing is forbidden by the audit rules.
Chained with
- config-302 (the curl|sh postinstall runs inside this privileged job on every PR, even benign ones)
- exposure-306 (separate leak of secrets; no combined gain, noted only)
high · 19
highgetInvoice server action has no authentication or tenant scope (BOLA on invoices and lines, callable from the client bundle)Code reading onlysrc/app/(app)/invoices/actions.ts:1,10-15
How to fix
Call requireOrg() and add eq(invoices.orgId, orgId) to the where, like invoice.byId (src/server/trpc/routers/invoice.ts:26); return only the columns the quick view needs. Docs also claim the opposite: README.md:32 ("Tenant isolation: all queries are scoped to the active organization.").
Evidence
- File:
src/app/(app)/invoices/actions.ts:1,10-15; client callersrc/components/invoice-quick-view.tsx:1,4,13 - Code:
"use server";
export async function getInvoice(id: string) {
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
if (!invoice) return null;
const lines = await db.select().from(invoiceLines).where(eq(invoiceLines.invoiceId, invoice.id));
return { ...invoice, lines };
}
// invoice-quick-view.tsx ("use client"): onMouseEnter={async () => setPreview(await getInvoice(id))}Data flow
- Source:
idargument of an exported"use server"function. It is imported by the client componentInvoiceQuickView(invoice-quick-view.tsx:4,13, rendered on/invoices,invoices/page.tsx:35), so its action id ships in the client bundle and it is a public POST endpoint; also reached from/invoices/[id]([id]/page.tsx:7-8) - Transformations: none; no
requireOrg()(its siblingmarkInvoicePaidcalls it at:20) - Sink:
select()of every invoice column (incl.notes,customerId,orgId) and all lines (:11,:13), returned to the caller - Sanitization: only
(app)/layout.tsx:6and the proxy cookie-presence check (src/proxy.ts:5-6), neither of which compares the invoice's org, and the layout does not run before a direct action POST - Verdict: VULNERABLE
Impact
Any signed-in member of another organization (via /invoices/<id>) and any caller who replays the action id with an arbitrary cookie value (the proxy checks presence only) can read another tenant's invoice, notes and line items when they know or learn the invoice id. Ids are not enumerable from this code alone, which keeps this at HIGH rather than CRITICAL.
Regression test
proof: static. .security-audit/tests/auth-102.getInvoice-scope.test.ts asserts that an anonymous call rejects before any query and that the invoice query filters by the caller's orgId; not executed (node_modules absent, install forbidden).
high/api/admin/stats has no authentication; leaks platform counts and the 25 latest signups' names and e-mailsCode reading onlysrc/app/api/admin/stats/route.ts:5-25
How to fix
Check the session in the handler and require the platform admin role, returning 401/403 otherwise; fix auth-104 at the same time, otherwise any user can grant themselves that role. Docs also claim the opposite: README.md:31 ("Every API route requires an authenticated session or an API key.").
Evidence
- File:
src/app/api/admin/stats/route.ts:5-25 - Code:
export async function GET() {
const [users] = await db.select({ total: count() }).from(user);
...
const recentSignups = await db.select({ id: user.id, name: user.name, email: user.email, createdAt: user.createdAt })
.from(user).orderBy(desc(user.createdAt)).limit(25);
const plans = await db.select({ plan: subscriptions.plan, total: count() }).from(subscriptions).groupBy(subscriptions.plan);Data flow
- Source: unauthenticated
GET /api/admin/stats(route handler, framework entry point) - Transformations: none; the handler never calls
getSession/requireAdmin - Sink: JSON response with user/org/project counts, plan distribution and 25 latest users with e-mail (
:19-25) - Sanitization: the admin check exists only on the page (
src/app/admin/page.tsx:5);src/proxy.ts:21matches/admin/:path*, not/api/admin - Verdict: VULNERABLE
Impact
Anyone, without an account, can fetch platform-wide business metrics and the names and e-mail addresses of the 25 most recent signups, repeatedly, which over time harvests every new user.
Regression test
proof: static. .security-audit/tests/auth-103.admin-stats-auth.test.ts asserts 401/403 and no user query for an anonymous caller, and 403 for a non-admin member; not executed (node_modules absent, install forbidden).
Chained with
- auth-104 (the role a fix would check is self-assignable)
highApp-level admin role is a client-writable Better-Auth additionalField (self-service privilege escalation to platform admin)Code reading onlysrc/lib/auth.ts:27-32
How to fix
Set input: false on role (and on emailDigest if it should only change via the settings action), assign admin only server side, or move to Better-Auth's admin plugin. Re-read the role from the database in requireAdmin and in any admin API handler.
Evidence
- File:
src/lib/auth.ts:27-32; consumersrc/lib/session.ts:22-25; columnsrc/db/schema.ts:10 - Code:
user: { additionalFields: {
role: { type: "string", defaultValue: "member" }, // no input: false
emailDigest: { type: "boolean", defaultValue: true },
} },
...
export async function requireAdmin() {
const session = await requireUser();
if (session.user.role !== "admin") redirect("/dashboard");Data flow
- Source: JSON body of Better-Auth
POST /api/auth/sign-up/emailandPOST /api/auth/update-user(mounted atsrc/app/api/auth/[...all]/route.ts:4) - Transformations: Better-Auth copies declared
additionalFieldsfrom the request body into the user row unless the field setsinput: false. Assumption (library source not readable, node_modules absent): this is Better-Auth's documented behaviour, whereinputdefaults totrueand the docs' ownroleexample setsinput: falseprecisely to stop users setting it. NodatabaseHooks.user.create.beforeor other code stripsrole(auth.ts:9-40has none) - Sink:
user.rolecolumn, returned insession.userand trusted byrequireAdmin(session.ts:24) - Sanitization: none configured
- Verdict: VULNERABLE (under the stated library assumption)
Impact
Any visitor can register with role: "admin" (or set it later through update-user on their own account) and pass requireAdmin, reaching /admin. Today the admin page only shows the stats that auth-103 already exposes, but this is the platform's only admin role: any admin gate added later, including the obvious fix for auth-103, is bypassed by it. E-mail verification (auth.ts:14) is not a barrier, since the attacker verifies their own address.
Regression test
proof: static. .security-audit/tests/auth-104.role-not-client-writable.test.ts captures the options passed to betterAuth and asserts user.additionalFields.role.input === false; not executed (node_modules absent, install forbidden), and it checks the configuration, not the library's runtime handling.
Chained with
- auth-103
highAI tool voidInvoice voids any tenant's invoice by id, with no org scope and no approvalCode reading onlysrc/server/ai/billing-tools.ts:7-20
How to fix
Add eq(invoices.orgId, ctx.orgId) to the where, set needsApproval: true and handle approval in a flow that actually waits for the user, and build the tool only for owner/admin members.
Evidence
- File:
src/server/ai/billing-tools.ts:7-20; entrysrc/app/api/assistant/billing/route.ts:8-22 - Code:
export function buildBillingTools(ctx: { orgId: string; userId: string }) {
return {
voidInvoice: tool({
description: "Void an invoice of the current organization so it can no longer be paid.",
inputSchema: z.object({ invoiceId: z.string() ... }),
execute: async ({ invoiceId }) => {
const [row] = await db.update(invoices).set({ status: "void" })
.where(eq(invoices.id, invoiceId)).returning(...)Data flow
- Source:
body.instructionofPOST /api/assistant/billing(route.ts:13,20), which the model turns into theinvoiceIdtool argument; any session with an active org passesroute.ts:9-11 - Transformations:
ctx.orgIdis passed in (route.ts:21) but unused by this tool; noneedsApproval, no role check - Sink:
UPDATE invoices SET status='void' WHERE id = $invoiceId(billing-tools.ts:13-17), executed insidegenerateTextwithout any approval step - Sanitization: none. The sibling
markInvoicePaidscopes byeq(invoices.orgId, ctx.orgId)(:30); the tool description is not a control - Verdict: VULNERABLE
Impact
A signed-in member of any organization can void another organization's invoice by giving the billing assistant its id, so the victim's customer can no longer pay it (cross-tenant integrity loss). Within the own org, any member (not only owner/admin) can void invoices with no confirmation.
Regression test
proof: static. .security-audit/tests/auth-105.voidInvoice-scope.test.ts asserts needsApproval is set and that the update filter contains invoices.orgId = ctx.orgId when the model passes a foreign id; not executed (node_modules absent, install forbidden). No model provider is called: the tool's execute is invoked directly.
Chained with
- auth-111 (cross-site POST can drive this route), rate-limit-101
highAI tool searchInvoices takes the tenant id from the model; any org's invoices and customer e-mails are readableCode reading onlysrc/server/ai/tools.ts:7-36
How to fix
Remove orgId from the input schema and use ctx.orgId. Escape %/_ in customerName before building the ilike pattern (wildcard only, no injection).
Evidence
- File:
src/server/ai/tools.ts:7-36; entrysrc/app/api/chat/route.ts:6-21 - Code:
inputSchema: z.object({ orgId: z.string().describe("Organization id to search in"), ... }),
execute: async ({ orgId, status, customerName }) => db.select({ ..., customerEmail: customers.email })
.from(invoices).innerJoin(customers, ...)
.where(and(eq(invoices.orgId, orgId), ...)) // ctx.orgId never used in this toolData flow
- Source:
messagesofPOST /api/chat(chat/route.ts:11), steering the model'sorgIdtool argument (tools.ts:12) - Transformations: none; the system prompt "Only answer questions about this organization" (
chat/route.ts:15) is the only restriction, and the prompt itself prints the caller's org id, showing the model the expected format - Sink: select of invoice number, amount, status, customer name and customer e-mail (
tools.ts:17-35), streamed back to the caller - Sanitization: none. The sibling
summarizeProjectusesctx.orgId(tools.ts:46) - Verdict: VULNERABLE
Impact
A signed-in member of any organization can ask the assistant to search another organization's id and receive up to 20 of its invoices with customer names and e-mail addresses per call. Organization ids are public for any org with a public project (exposure-101).
Regression test
proof: static. .security-audit/tests/auth-106.searchInvoices-scope.test.ts calls the tool's execute with a foreign orgId and asserts the query uses ctx.orgId only; not executed (node_modules absent, install forbidden). No model is called.
Chained with
- exposure-101 (org id disclosure), chain-901
highGET /api/export reads the tenant from the ?org= query parameter (cross-tenant customer PII export)Code reading onlysrc/app/api/export/route.ts:6-23
How to fix
Use only session.session.activeOrganizationId (or check membership of the requested org in member), and require owner/admin for a full PII export. Docs also claim the opposite: README.md:32.
Evidence
- File:
src/app/api/export/route.ts:6-23 - Code:
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const orgId = searchParams.get("org") ?? session.session.activeOrganizationId;
... .select({ name, email, phone, taxId, createdAt }).from(customers).where(eq(customers.orgId, orgId));Data flow
- Source:
orgquery parameter (route.ts:10-11) - Transformations: preferred over the session's active organization; no
memberlookup - Sink: CSV of name, e-mail, phone and tax id of every customer of that org (
:14-35) - Sanitization: session existence only (
:7-8); a session without any organization passes - Verdict: VULNERABLE
Impact
Any signed-in user, including a fresh account with no organization, can download the full customer list (e-mail, phone, tax id) of any organization whose id they know; ids of orgs with a public project are disclosed publicly (exposure-101). Separately, any member of an org can export all of its customer PII with no role requirement.
Regression test
proof: static. .security-audit/tests/auth-107.export-org-param.test.ts asserts that ?org=<foreign> never reaches the customers filter or the filename; not executed (node_modules absent, install forbidden).
Chained with
- exposure-101 (org id disclosure), auth-111, chain-901
hightRPC project.update updates by id only (cross-tenant write and read of internal notes; any member can publish a project)Code reading onlysrc/server/trpc/routers/project.ts:12-27
How to fix
.where(and(eq(projects.id, id), eq(projects.orgId, ctx.orgId))), return an explicit column list, and restrict isPublic changes to owner/admin. Docs also claim the opposite: README.md:32.
Evidence
- File:
src/server/trpc/routers/project.ts:12-27 - Code:
update: orgProcedure.input(z.object({ id, name?, description?, internalNotes?, isPublic? }))
.mutation(async ({ ctx, input }) => {
const { id, ...patch } = input;
const [updated] = await ctx.db.update(projects).set(patch).where(eq(projects.id, id)).returning();
return updated;Data flow
- Source:
idand patch fields from the tRPC request (project.ts:13-21),POST /api/trpc/project.update - Transformations:
orgProceduresetsctx.orgIdfrom the session (init.ts:22-26) but the update never uses it - Sink:
UPDATE projects ... WHERE id = $id(project.ts:24);.returning()with no column list returns the full row, includinginternalNotes,budgetCents,clientContactEmail - Sanitization: none (contrast
listat:9anddeleteProjectatprojects/actions.ts:42) - Verdict: VULNERABLE
Impact
A member of any organization can rewrite another organization's project, flip isPublic to expose it on /p/[slug] (which then serves the full row publicly, exposure-101), and read its internal notes, budget and client contact e-mail from the response, by project id. Project ids of public projects are themselves public via /p/[slug]. Within the own org, any member can publish a project (design choice, no role check).
Regression test
proof: static. .security-audit/tests/auth-108.project-update-scope.test.ts asserts the update filter contains projects.orgId = ctx.orgId; not executed (node_modules absent, install forbidden). The existing project.test.ts mocks the DB chain and cannot see the filter.
Chained with
- exposure-101, chain-902
highWeekly overdue digest e-mails every opted-in user on the platform, not the organization's membersCode reading onlysrc/server/jobs/digest.ts:28-43
How to fix
Join member on member.organizationId = orgId (optionally owner/admin only) in the recipients query, and escape the interpolated values. Docs also claim the opposite: README.md:32.
Evidence
- File:
src/server/jobs/digest.ts:28-43; schedulesrc/server/jobs/worker.ts:13-20 - Code:
const recipients = await db.select({ email: user.email, name: user.name })
.from(user).where(eq(user.emailDigest, true)); // no join to member
for (const r of recipients) {
await sendEmail({ to: r.email, subject: `${org.name}: ${overdue.length} overdue invoices`,
html: `<p>Hi ${r.name},</p><table>${rows}</table>` }); // number, customer, amount, due date
}Data flow
- Source: pg-boss job
overdue-digestwithorgIdfrom the Monday 08:00 fan-out over all organizations (worker.ts:13-18) - Transformations: invoices are correctly filtered by
orgId(digest.ts:25), but the recipients query has no join tomember;emailDigestdefaults to true (schema.ts:11,auth.ts:30) - Sink: Resend e-mail per recipient with invoice numbers, customer names, amounts and due dates (
digest.ts:33-42) - Sanitization: none
- Verdict: VULNERABLE
Impact
Every Monday, for each organization with overdue invoices, every user of every organization with the digest enabled (the default) receives that organization's overdue invoice numbers, customer names and amounts. An outsider only needs to sign up to receive all tenants' overdue receivables weekly; volume is N orgs x M users e-mails. Prerequisite (1): the pnpm worker process must be deployed (the README run instructions start it; the Dockerfile CMD does not), which keeps the rating.
Regression test
proof: static. .security-audit/tests/auth-109.digest-recipients.test.ts mocks the DB and e-mail client and asserts the recipients query joins member on the job's org; not executed (node_modules absent, install forbidden). No e-mail is sent.
Chained with
- injection-212 (same e-mail HTML is built from unescaped fields)
highPublic API reflects any Origin with credentials while sessions use SameSite=None: any website can read a visitor's invoices and projectsCode reading onlysrc/server/api/index.ts:13-36
How to fix
Replace the reflector with an explicit allowlist of embed origins (per-org setting), serve the widget with a Bearer API key or a short-lived scoped token instead of the full session cookie, and set the session cookie to SameSite=Lax. Docs also claim the opposite: README.md:25-27 presents cross-site cookie sharing as intended.
Evidence
- File:
src/server/api/index.ts:13-36,src/lib/auth.ts:34-37 - Code:
api.use("*", cors({ origin: (origin) => origin, credentials: true, allowMethods: ["GET", "POST", "OPTIONS"] }));
...
const session = await auth.api.getSession({ headers: c.req.raw.headers }); // cookie fallback, :31
advanced: { crossSubDomainCookies: { enabled: true, domain: ".ledgerly.app" },
defaultCookieAttributes: { sameSite: "none", secure: true } }Data flow
- Source: cross-origin
fetch("https://<app>/api/public/invoices", { credentials: "include" })from any page the signed-in user visits - Transformations: the browser attaches the session cookie because of SameSite=None (
auth.ts:36); without a Bearer header the middleware authenticates the cookie and setsorgId(index.ts:31-35); Hono'scorsechoes the request Origin withAccess-Control-Allow-Credentials: true(index.ts:15-18) - Sink:
GET /api/public/invoices(100 latest: number, amount, currency, status, due date) and/api/public/projects(id, name, slug) of the victim's active org, readable by the attacker page (index.ts:38-59) - Sanitization: none (no origin allowlist)
- Verdict: VULNERABLE
Impact
Any website a signed-in Ledgerly user visits can silently read their organization's last 100 invoices and its project list. Browsers that block third-party cookies by default (Safari, Firefox with strict tracking protection) do not send the cookie, so the reach is the share of users on browsers that still send SameSite=None cookies cross-site. README documents cross-site cookie use for the widget (README.md:25-27); that design does not require reflecting every origin.
Regression test
proof: static. .security-audit/tests/auth-110.cors-allowlist.test.ts sends a preflight with a foreign Origin through api.request and asserts it is not echoed together with Allow-Credentials: true; not executed (node_modules absent, install forbidden). No host is contacted.
Chained with
- auth-111 (same SameSite=None root), config-308
highPublic project page leaks the organization id, which unlocks cross-tenant customer PII export and invoice search for any self-registered userCode reading onlysrc/app/p/[slug]/page.tsx:7-12
How to fix
Fixing either link breaks the chain, but fix all three: pass a DTO with only name, description and document filenames to PublicProjectView (exposure-101); ignore ?org= and use the session org with a membership and role check in /api/export (auth-107); drop orgId from the searchInvoices input schema and use ctx.orgId (auth-106).
Evidence
- File:
src/app/p/[slug]/page.tsx:7-12;src/components/public-project-view.tsx:1,7,10;src/app/api/export/route.ts:11,14-23;src/server/ai/tools.ts:12,16,29;src/app/api/chat/route.ts:7-9,17 - Code:
// p/[slug]/page.tsx: full row (incl. orgId) passed to a "use client" component
const project = await db.query.projects.findFirst({ where: ... eq(p.isPublic, true), with: { documents: true } });
return <PublicProjectView project={project} />;
// api/export/route.ts: tenant taken from the query string
const orgId = searchParams.get("org") ?? session.session.activeOrganizationId;
... .from(customers).where(eq(customers.orgId, orgId)); // name, email, phone, taxId
// server/ai/tools.ts: tenant chosen by the model
inputSchema: z.object({ orgId: z.string().describe("Organization id to search in"), ... }),
... .where(and(eq(invoices.orgId, orgId), ...)) // ctx.orgId unusedData flow
Steps:
- Anonymous:
GET /p/<slug>of any public project (no proxy match, no session check). The wholeprojectsrow, includingorgId, and thedocumentsrows are props of a client component, so React serializes them into the page's RSC payload (page.tsx:7-12,public-project-view.tsx:7). Organization ids are otherwise not exposed; the code sets no id generator, so they come from Better-Auth's default (library source not readable here; assumed random, not enumerable). - The attacker signs up (public sign-up, verifies their own address,
auth.ts:12-15) and creates an organization (allowUserToCreateOrganization: true,auth.ts:39). GET /api/export?org=<victim orgId>: only a session is required (export/route.ts:7-8); the query parameter wins over the session org (:11) and the CSV returns every customer's name, e-mail, phone and tax id of the victim org (:14-23) (auth-107).POST /api/chatwith "search invoices in organization <victim orgId>": thesearchInvoicestool filters by the model-suppliedorgId(tools.ts:12,29) and returns up to 20 invoices per call with customer names and e-mails (auth-106). Depends on the model following the instruction, which the tool description invites.
- Source: slug in the URL (step 1), then the leaked orgId as
?org=/ tool argument - Sink: CSV response (
export/route.ts:30), tool result streamed to the attacker (chat/route.ts:21) - Sanitization: none; no membership check between session and orgId at either sink
- Verdict: VULNERABLE
Impact
Anyone who can sign up can download the full customer list (e-mail, phone, tax id) and read invoices of every organization that has at least one public project, with no knowledge beyond the public page URL. auth-107 and auth-106 alone need an organization id that is not otherwise disclosed, and exposure-101 alone leaks only the public project's own data; together they give mass cross-tenant PII harvesting. Rated HIGH (self-registered account required, no victim interaction), not CRITICAL, because a session is needed.
Regression test
proof: static: tests are not runnable here (no node_modules, install forbidden by the audit rules); the combined path was traced by code reading.
Chained with
- exposure-101 (org id disclosure on the public page)
- auth-107 (
?org=export) - auth-106 (model-chosen orgId in searchInvoices)
highPublic project page leaks document S3 keys and project ids, which lets any self-registered user read and replace another organization's shared files and rewrite its public pageCode reading onlysrc/app/p/[slug]/page.tsx:7-12
How to fix
Pass only public fields to PublicProjectView (exposure-101). Generate keys server side as <orgId>/<projectId>/<uuid> and reject client keys (upload-401); check that projectId belongs to the caller's org before presigning (auth-403); add eq(projects.orgId, ctx.orgId) to the project.update filter (auth-108). Any one of the first two fixes breaks the file part of the chain.
Evidence
- File:
src/app/p/[slug]/page.tsx:7-12;src/app/api/uploads/route.ts:21-37;src/app/api/documents/[id]/download/route.ts:15-26;src/app/(app)/projects/actions.ts:28-36;src/server/trpc/routers/project.ts:22-26 - Code:
// uploads/route.ts: client-chosen key, no org prefix, projectId not checked
new PutObjectCommand({ Bucket: BUCKET, Key: input.key, ContentType: input.contentType })
await db.insert(documents).values({ id: crypto.randomUUID(), orgId, projectId: input.projectId, s3Key: input.key, ... });
// documents/[id]/download/route.ts: row scoped to the caller's org, but the key inside it is not
.where(and(eq(documents.id, id), eq(documents.orgId, orgId)))
new GetObjectCommand({ Bucket: BUCKET, Key: doc.s3Key, ... })
// project.ts: update by id only
ctx.db.update(projects).set(patch).where(eq(projects.id, id)).returning();Data flow
Steps:
- Anonymous:
GET /p/<slug>returns the full project row (id) and everydocumentsrow (s3Key,id) in the RSC payload (page.tsx:7-12, exposure-101). The page shows only file names; there is no anonymous download route, so the file contents are not public. - Replace: the attacker (any self-registered user with an own org) calls
POST /api/uploadswithkey= the victim'ss3Key; the server presigns a PUT for that exact key (uploads/route.ts:23-27) and the attacker overwrites the object. The victim org's own members later download the attacker's file through their legitimate document row (download/route.ts:26) (upload-401). - Read: the attacker creates a public project in their own org (
createProject, any member,isPublicfrom input,actions.ts:28-36), registers a document row withprojectId= that project andkey= the victim'ss3Key(no PUT needed,uploads/route.ts:29-37), learns the new row id from their own/p/<slug>(step 1 again), and callsGET /api/documents/<id>/download: the row passes the org filter (:18) and the presigned GET is for the victim's key (:26) (upload-401). - Page tampering: with the leaked project id,
project.updaterewrites the victim project's name and description (rendered on its public page) or setsisPublicto false (project.ts:24) (auth-108);POST /api/uploadswith the victim'sprojectIdadds rows whose attacker-chosen file names appear in the victim's "Shared files" list (uploads/route.ts:32) (auth-403).
- Source: slug in the URL, then the leaked
s3Key/ project id in the upload and tRPC bodies - Sink: presigned PUT and GET for the victim's object;
UPDATE projects ... WHERE id = ? - Sanitization: none; no org prefix on keys, no ownership check on projectId or project id
- Verdict: VULNERABLE
Impact
Anyone who can sign up can download the files shared on any organization's public project, silently replace them with their own content (forged contracts, malware) that the victim organization's members then download as their own, and deface or unpublish that public page. upload-401 and auth-108 alone need an object key or project id that is not otherwise disclosed, and exposure-101 alone discloses metadata but not file contents; together they make every public project's files and page readable and writable by any outsider.
Regression test
proof: static: tests are not runnable here (no node_modules, install forbidden by the audit rules); the combined path was traced by code reading.
Chained with
- exposure-101 (s3Key, document id and project id disclosure)
- upload-401 (client-chosen key on presigned PUT; read via own document row)
- auth-108 (project.update by id only)
- auth-403 (projectId not checked on upload)
highpackage.json postinstall pipes an unpinned remote script into sh (every install, CI job and image build)Code reading onlypackage.json:14
How to fix
Remove the postinstall. If the tool is needed, download a pinned release in an explicit build step and verify a checksum or signature before running it; consider pnpm install --ignore-scripts in CI and Docker with an allowlist for packages that need scripts.
Evidence
- File:
package.json:14;Dockerfile:3-5;.github/workflows/ci.yml:23;.github/workflows/preview.yml:23 - Code:
"postinstall": "curl -fsSL https://releases.pdfkiln.example/install.sh | sh"
# Dockerfile (deps stage, runs as root)
RUN apk add --no-cache curl && corepack enable
RUN pnpm install --frozen-lockfileData flow
- Source: whatever https://releases.pdfkiln.example/install.sh serves at install time
- Transformations: none: no version pin, checksum or signature; the lockfile does not cover lifecycle-script downloads
- Sink:
shruns the script on everypnpm install: developer machines, CI (ci.yml:23, which runs on push to main), the preview job (preview.yml:23) and the Docker deps stage as root (Dockerfile:5), whose node_modules is copied into the final image (Dockerfile:10,24) - Sanitization: none. With
-fa failed download leavesshwith empty input and the pipe exits 0, so an outage is silent and a later compromise is equally silent. - Verdict: VULNERABLE
Impact
Whoever controls that host, its domain (including after a lapse) or the network path can run code on every developer machine, in CI and during image builds, and can plant code in node_modules that ships in the production image. The attacker here is a third party, not a user of the app; rated HIGH because one compromise reaches every build and the production artifact.
Regression test
proof: static: .security-audit/tests/config-302.postinstall.test.ts (reads package.json and asserts no lifecycle script pipes curl or wget output into a shell) was written but not run; node_modules is absent and installing is forbidden by the audit rules.
Chained with
- config-301 (runs inside the privileged preview job)
highAnthropic API key shipped to the browser via NEXT_PUBLIC_ with anthropic-dangerous-direct-browser-accessCode reading onlysrc/lib/ai-client.ts:3-6
How to fix
Delete src/lib/ai-client.ts and the NEXT_PUBLIC key; move "Suggest reminder" to an authenticated server action or route using the server-only ANTHROPIC_API_KEY with a per-user quota. Rotate any key that was ever built into a bundle.
Evidence
- File:
src/lib/ai-client.ts:3-6;src/components/assistant-panel.tsx:1,6,13-19;.env.example:5 - Code:
export const browserAnthropic = createAnthropic({
apiKey: process.env.NEXT_PUBLIC_ANTHROPIC_API_KEY,
headers: { "anthropic-dangerous-direct-browser-access": "true" },
});
// assistant-panel.tsx ("use client")
const { text } = await generateText({ model: browserAnthropic("claude-haiku-4-5"), prompt: ... });Data flow
- Source: NEXT_PUBLIC_ANTHROPIC_API_KEY (documented in .env.example:5), which Next.js inlines into client JavaScript at build time
- Transformations: none
- Sink: the client bundle of /assistant (assistant/page.tsx:1-6 renders the "use client" AssistantPanel), which calls api.anthropic.com from the browser with that key (assistant-panel.tsx:14-17)
- Sanitization: none. NEXT_PUBLIC_ values are public by design. The page itself requires a session (requireOrg, assistant/page.tsx:5), but anyone can sign up and create an organization (src/lib/auth.ts:39), and static chunks under /_next/static are served without auth.
- Verdict: VULNERABLE
Impact
Any user who loads the assistant page (any free sign-up) can copy the Anthropic API key from the JavaScript and use it outside the app: unlimited model spend on the owner's account and access to whatever else that key's workspace exposes, with no tenant scoping, rate limit or token cap. Assumes the variable is set in the deployed build, which the feature needs to work.
Regression test
proof: static: .security-audit/tests/exposure-305.browser-key.test.ts (asserts no source file reads NEXT_PUBLIC_ANTHROPIC_API_KEY or sends anthropic-dangerous-direct-browser-access) was written but not run; node_modules is absent and installing is forbidden by the audit rules.
Chained with
- none (rate-limit-101/103/409 are server-side LLM spend controls; distinct class, noted for COMBINE)
highStripe access token and a second API key committed in .env (git history, commit a1b7c73): rotation requiredCode reading only(prepass.md:150-157
How to fix
Treat both values as leaked: roll the Stripe key in the Stripe dashboard and rotate the second credential at its issuer; then optionally purge history (git filter-repo) and force-push. Keep .env ignored (done, .gitignore:3) and add a gitleaks pre-commit or CI step.
Evidence
- File:
.envat commit a1b7c73; pre-pass Secret Scan (prepass.md:150-157) - Code:
gitleaks rows (values redacted by the pre-pass, not read here):
stripe-access-token .env:4 a1b7c73 2026-05-04 history only
generic-api-key .env:3 a1b7c73 2026-05-04 history only
git show --stat a03d8d4: ".env | 4 ----" (removed from the tree, still in a1b7c73)Data flow
- Source: commit a1b7c73 "feat: ledgerly mvp" tracked a 4-line .env; a03d8d4 "chore: stop tracking .env" deleted it from the tree and added it to .gitignore
- Transformations: none
- Sink: git history: every clone, fork and CI checkout of the repository contains the values
- Sanitization: none: deleting the file does not remove it from history. The gitleaks stripe-access-token rule matches the
sk_/rk_+test|live|prodkey format, so line 4 has the shape of a real Stripe secret or restricted key. Whether either value is live, and whether the Stripe key is test or live mode, was NOT checked (values deliberately not read, no provider contacted). - Verdict: VULNERABLE
Impact
Anyone with read access to the repository or any clone (the preview workflow accepts fork pull requests, config-301, which suggests a forkable repository) can recover a Stripe key and a second credential. If the Stripe key is live, it allows Stripe API actions on the merchant account (customers, subscriptions, refunds, payouts data); the effect of the generic key depends on what it is. Rated HIGH on the assumption that the values may be live.
Regression test
proof: static: the secret lives in git history, which no unit test reaches; liveness can only be checked at the provider.
Chained with
- none
highSSRF in integration.testWebhook: any signed-in user can make the server fetch an arbitrary URL and read the responseCode reading onlysrc/server/trpc/routers/integration.ts:18-28
How to fix
Allow only https; resolve the host and reject loopback, private, link-local, CGNAT and metadata ranges; use redirect: "manual" (or re-validate every hop); pin the resolved IP for the connection; return only the status code, not the body. Apply the same validation in save.
Evidence
- File:
src/server/trpc/routers/integration.ts:18-28 - Code:
testWebhook: orgProcedure.input(z.object({ url: z.string().url() })).mutation(async ({ input }) => {
const res = await fetch(input.url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "ledgerly.test", sentAt: new Date().toISOString() }),
redirect: "follow",
signal: AbortSignal.timeout(5000),
});
const text = await res.text();
return { status: res.status, ok: res.ok, body: text.slice(0, 2000) };
}),Data flow
- Source: tRPC mutation input
url(integration.ts:18), POST /api/trpc/integration.testWebhook (src/app/api/trpc/[trpc]/route.ts:4-11, root.ts:9). Reachable by any signed-in user with an active organization (orgProcedure, init.ts:22-26); any user can create their own organization (allowUserToCreateOrganization: true, src/lib/auth.ts:39), so the bar is a verified sign-up (auth.ts:14). - Transformations: none
- Sink: server-side fetch (integration.ts:19-25).
redirect: "follow"means an attacker-hosted 301/302/303 turns the POST into a GET to any second URL; status and the first 2000 characters of the body go back to the caller (integration.ts:27). - Sanitization: z.string().url() checks syntax only; no scheme restriction, no loopback/private/link-local/metadata block, no host allowlist, redirects followed. Same unvalidated URL is stored by
save(integration.ts:12-14), but nothing in the repo fetches stored webhookUrl values (grep), so no second-order sink today. - Verdict: VULNERABLE
Impact
Any user who signs up and creates an organization can make the application server send requests to loopback, private-network and cloud-metadata addresses (GET via a redirect from a host they control) and read the status and up to 2000 characters of each response, and can map internal hosts and ports by status and timing. Whether a metadata service is reachable depends on the hosting (Vercel preview, Docker image), which was not checked.
Regression test
proof: static: .security-audit/tests/injection-202.ssrf.test.ts (asserts testWebhook rejects loopback, link-local and private URLs and never calls fetch) was written but not run; node_modules is absent and installing is forbidden by the audit rules.
Chained with
- none
highpayment-method-frame postMessage receiver has no origin check and navigates to a message-supplied URL (javascript: URL runs in the app origin)Code reading onlysrc/components/payment-method-frame.tsx:13-22
How to fix
Check event.origin === "https://billing-widget.example.com" and event.source === iframe.contentWindow; validate data with a schema (bounded integer height; url parsed with new URL, protocol https and host equal to the provider's confirmation host) before navigating. Follow src/components/signature-frame.tsx:22-25.
Evidence
- File:
src/components/payment-method-frame.tsx:13-22; rendered atsrc/app/(app)/settings/billing/page.tsx:22 - Code:
function onMessage(event: MessageEvent<WidgetMessage>) {
const msg = event.data;
if (msg?.type === "resize") setHeight(msg.height);
// After 3-D Secure the widget asks the page to continue on the provider's confirmation URL.
if (msg?.type === "redirect") window.location.assign(msg.url);
}
window.addEventListener("message", onMessage);
...
{sub && <PaymentMethodFrame customerRef={sub.stripeCustomerId} />}Data flow
- Source:
event.dataof any message posted to the /settings/billing window. Any page holding a reference to that window can post: an attacker page thatwindow.opens it (no COOP header is set, config-308) or frames it (no frame-ancestors, config-308; session cookies are SameSite=None, src/lib/auth.ts:36, so the framed page is signed in). - Transformations: none: no event.origin or event.source check, no schema
- Sink:
window.location.assign(msg.url)(payment-method-frame.tsx:18);setHeight(msg.height)(line 16) - Sanitization: none. A
javascript:URL passed to location.assign executes in the current document, i.e. the app origin; no CSP exists to block it (config-308). The sibling receiver src/components/signature-frame.tsx:22-25 does check origin, source and schema, which shows the intended pattern. - Verdict: VULNERABLE
Impact
A website visited by a signed-in user whose organization has a subscription row can open or frame Settings > Billing and post a redirect message with a javascript: URL, running script in the Ledgerly origin as that user (read invoices and customers, invite members, change settings), or send them to a phishing URL. The subscription precondition is not a real barrier: anyone can create a subscription row for any organization through the unsigned Stripe webhook (auth-101).
Regression test
proof: static: .security-audit/tests/injection-203.postmessage.test.ts (asserts a message from a foreign origin, or with a javascript: URL, does not call location.assign) was written but not run; node_modules is absent and installing is forbidden by the audit rules.
Chained with
- config-308 (no frame-ancestors/COOP/CSP: delivery path and no script block)
- auth-101 (unsigned Stripe webhook removes the subscription precondition)
high/api/assistant/billing takes model, maxTokens and maxSteps from the request: unbounded LLM spendCode reading onlysrc/app/api/assistant/billing/route.ts:6-22
How to fix
Parse the body with zod; fix the model server side (or allowlist), clamp maxSteps (<= 5) and maxOutputTokens, cap instruction length, and write plus check an atomic per-org usage counter in usage_events before each call.
Evidence
- File:
src/app/api/assistant/billing/route.ts:6-22 - Code:
type Body = { instruction: string; model?: string; maxTokens?: number; maxSteps?: number };
...
const body: Body = await request.json(); // :13, type only, no runtime validation
const { text, steps } = await generateText({
model: anthropic(body.model ?? "claude-haiku-4-5"), // :16 any model id
maxOutputTokens: body.maxTokens, // :17 caller-chosen or provider default
stopWhen: stepCountIs(body.maxSteps ?? 10), // :18 caller-chosen step count
prompt: body.instruction, // :20 unbounded length
tools: buildBillingTools({ orgId, userId: session.user.id }),
});Data flow
- Source: JSON body of
POST /api/assistant/billing(route.ts:13), any signed-in user with an active organization (route.ts:9-11); sign-up is self-service andallowUserToCreateOrganization: true(src/lib/auth.ts:39) - Transformations: none.
Bodyis a TypeScript type only; no zod schema, no model allowlist, no clamp, no length cap oninstruction - Sink:
generateTextto Anthropic billed to Ledgerly (route.ts:15-22) - Sanitization: none. No per-user or per-org budget:
usage_eventsis only read (src/server/queries/dashboard.ts:25-28) and never written anywhere insrc/, so no usage counter exists to enforce one. Deployment isnext startin a container (Dockerfile:28), so there is no serverless max duration that would cut a long multi-step run short - Verdict: VULNERABLE
Impact
Any signed-in user (anyone can sign up and create an organization) can call POST /api/assistant/billing with the most expensive Anthropic model, no output cap, a very large step count and a huge instruction, in parallel and in a loop, and each call runs on Ledgerly's Anthropic account. The cost per request is chosen by the attacker, not the server.
Regression test
proof: static — test written at .security-audit/tests/rate-limit-101.test.ts (mocks ai, @ai-sdk/anthropic, session; asserts model allowlist and clamped maxOutputTokens/maxSteps), but not executed: node_modules absent and install forbidden by audit rules.
Chained with
- auth-105, auth-111 (cross-shard, see chain-notes-C.md)
highPOST /api/phone/send-code is unauthenticated and unthrottled: anyone can trigger paid SMS to any number (SMS pumping)Code reading onlysrc/app/api/phone/send-code/route.ts:7-22
How to fix
Require a session or CAPTCHA; cap sends per number (e.g. 3/hour) and per IP with an atomic counter (INSERT ... ON CONFLICT or Redis INCR) checked before sendSms; add a cooldown, a country allowlist and a spend alarm; delete or invalidate older phone: rows when a new code is issued. Docs also claim the opposite: README.md:31 ("Every API route requires an authenticated session or an API key.").
Evidence
- File:
src/app/api/phone/send-code/route.ts:7-22 - Code:
const sendCodeInput = z.object({ phone: z.string().regex(/^\+[1-9]\d{6,14}$/) });
export async function POST(request: Request) {
const { phone } = sendCodeInput.parse(await request.json());
const code = String(randomInt(100000, 1000000));
await db.insert(verification).values({ id: crypto.randomUUID(), identifier: `phone:${phone}`, ... });
await sendSms({ to: phone, body: `Your Ledgerly verification code is ${code}` });
return Response.json({ ok: true });
}Data flow
- Source: unauthenticated JSON body
phone(route.ts:11); only an E.164 format regex (:7), any country and premium-rate prefixes pass - Transformations: none. No session check, no per-phone/IP/day limit, no cooldown, no CAPTCHA, no country allowlist
- Sink:
sendSms(route.ts:21) to the gateway that "bills every 160-character segment to Ledgerly's account" (src/lib/sms.ts:5-14); plus oneverificationrow per call (route.ts:14-19), never pruned, earlier codes for the same number are not invalidated - Sanitization: none. Better-Auth
rateLimit(src/lib/auth.ts:33) only runs inside the Better-Auth handler at/api/auth/*, not this route.src/proxy.tsmatcher (:15-22) does not cover/api/*. The per-org SMS cap (sms-actions.ts:10,23-36, non-concurrency-402) applies only tosendSmsReminder - Verdict: VULNERABLE
Impact
Anyone on the internet can POST to /api/phone/send-code in a loop with arbitrary numbers, including premium-rate ones, and Ledgerly pays for every SMS (SMS pumping / toll fraud); the same call lets anyone flood a chosen phone with texts from "Ledgerly" and grows the verification table without bound. Not CRITICAL: financial and harassment impact, no data loss or code execution.
Regression test
proof: static — test written at .security-audit/tests/rate-limit-102.test.ts (mocks @/lib/sms and @/db; asserts the 4th send to one number within the window is refused before sendSms is called), but not executed: node_modules absent and install forbidden by audit rules.
highPresigned upload key is client-chosen with no org prefix: overwrite and read other tenants' objectsCode reading onlysrc/app/api/uploads/route.ts:13,23-37
How to fix
Generate the key on the server (${orgId}/${projectId}/${uuid}) and ignore any client key; in the download route also check that doc.s3Key starts with the caller's org prefix. Docs also claim the opposite: README.md:34 ("presigned URLs scoped to your organization").
Evidence
- File:
src/app/api/uploads/route.ts:13,23-37; read pathsrc/app/api/documents/[id]/download/route.ts:15-31 - Code:
key: z.string().min(1), // :13
const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket: BUCKET, Key: input.key, ContentType: input.contentType }), { expiresIn: 900 });
await db.insert(documents).values({ id, orgId, projectId: input.projectId, s3Key: input.key, ... });
// download: .where(and(eq(documents.id, id), eq(documents.orgId, orgId))) then GetObjectCommand({ Key: doc.s3Key })Data flow
- Source: request body
key(uploads/route.ts:13, parsed:21); any session with an active org (:17-19), and anyone can create an org (auth.ts:39) - Transformations: none; no
orgId/prefix forced, no check that the key is unused or belongs to the caller - Sink: (1) presigned PUT for that exact key in the shared bucket (
:23-27); (2)documents.s3Key = keyin the caller's own org (:33), which the download route turns into a presigned GET after checking only that the *row* belongs to the caller (download/route.ts:18,22-30) - Sanitization: none
- Verdict: VULNERABLE
Impact
A signed-in member of any organization can overwrite any object in the documents bucket whose key they know, and can read it by registering their own document row with the victim's key and downloading it. Keys of documents attached to public projects are sent to anonymous visitors by /p/[slug] (the whole documents rows reach the client component, exposure-101), and objects of deleted orgs stay in the bucket (exposure-415), so known keys are not hypothetical.
Regression test
proof: static. .security-audit/tests/upload-401.upload-key-server-side.test.ts mocks the presigner and asserts a client-supplied foreign key is never presigned and the presigned key starts with the caller's org prefix; not executed (node_modules absent, install forbidden). No storage is contacted.
Chained with
- exposure-101 (key disclosure), exposure-415, auth-403, upload-402, chain-902
medium · 12
mediumSameSite=None session cookie and no Origin check on cookie-authenticated route handlers (CSRF on invite, billing agent, uploads)src/lib/auth.ts:36
How to fix
Set the session cookie to SameSite=Lax; add an Origin / Sec-Fetch-Site allowlist check for every cookie-authenticated mutating route handler; require content-type: application/json on JSON routes; use Better-Auth's organization.inviteMember API instead of inserting invitations by hand.
Evidence
- File:
src/lib/auth.ts:36; handlerssrc/app/api/team/invite/route.ts:9-43,src/app/api/assistant/billing/route.ts:8-22,src/app/api/uploads/route.ts:16-21,src/app/api/chat/route.ts:6-11 - Code:
defaultCookieAttributes: { sameSite: "none", secure: true } // auth.ts:36
const form = await request.formData(); // team/invite:21, plain HTML form works
const body: Body = await request.json(); // assistant/billing:13, parses a text/plain body tooData flow
- Source: auto-submitted cross-site form on an attacker page (
application/x-www-form-urlencodedfor invite,text/plainwith a JSON-shaped body for the JSON routes: both are CORS "simple" requests, no preflight); the browser attaches the session cookie because of SameSite=None - Transformations: these route handlers check the session only; no Origin,
Sec-Fetch-Siteor token check.trustedOrigins(auth.ts:38) protects Better-Auth's own endpoints and Next's Origin check covers server actions, not route handlers - Sink: invitation insert with role
adminallowed byROLES(invite/route.ts:7,24-35) plus the accept-link e-mail to the attacker's address (:37-41); billing-agent tool calls such as voidInvoice (assistant/billing/route.ts:15-22);documentsrow insert (uploads/route.ts:29-37) - Sanitization: none
- Verdict: VULNERABLE
Impact
An owner/admin of an organization who visits a malicious page while signed in can be made to invite the attacker's address as an org admin, and any member can be made to run billing-agent instructions (void invoices, spend LLM budget) or create document rows. Turning the invitation into membership depends on Better-Auth's accept-invitation endpoint accepting a row this app inserted directly; there is no accept page in the repo (recon A9) and the library source is not readable, so that last step is not verified, which with the victim-interaction requirement keeps this at MEDIUM.
Regression test
MEDIUM: no regression test required.
Chained with
- auth-110 (same SameSite=None root), auth-105, rate-limit-101
mediumgetUsageThisMonth caches per-org usage under a key that does not contain the organization (cross-tenant cache bleed)src/server/queries/dashboard.ts:20-33
How to fix
Pass orgId (and since) as arguments of the cached function, or add them to the key parts: unstable_cache(fn, ["usage-month", orgId, since.toISOString()], { revalidate: 600, tags: [usage:${orgId}] }).
Evidence
- File:
src/server/queries/dashboard.ts:20-33; consumersrc/app/(app)/dashboard/page.tsx:6-7,20-25 - Code:
export async function getUsageThisMonth(orgId: string) {
const since = startOfMonth(new Date());
const load = unstable_cache(
async () => db.select({ kind, units: sum(units) }).from(usageEvents)
.where(and(eq(usageEvents.orgId, orgId), gte(usageEvents.createdAt, since))).groupBy(usageEvents.kind),
["usage-month"],
{ revalidate: 600 },
);
return load();
}Data flow
- Source:
orgIdfrom the caller's session (dashboard/page.tsx:6), captured only in the closure of the cached function - Transformations: the cached function takes no arguments and its source text is the same for every org, so the cache key is
["usage-month"]+ no arguments for every organization. Assumption (library source not readable, node_modules absent): Next.js documents thatunstable_cachekeys onkeyParts, the function's arguments and its stringified source, and thatkeyPartsare needed exactly when the function uses outside variables without taking them as parameters; closure values are not part of the key - Sink: the cached rows rendered under "Usage this month" on
/dashboard(page.tsx:18-25) for up to 600 s - Sanitization: none. Contrast
getOrgStats(dashboard.ts:7-18), whereorgIdis an argument and therefore part of the key (non-concurrency-403, non-auth-106) - Verdict: VULNERABLE
Impact
A member of one organization sees whichever organization last populated the entry: that org's usage kinds and monthly unit totals, for up to 10 minutes, and every org sees wrong usage figures for itself. The data is aggregate business metrics, not PII, and the attacker cannot choose the victim, so this is MEDIUM (limited impact) rather than HIGH.
Regression test
MEDIUM: no regression test required.
mediumUpload accepts any projectId without checking it belongs to the caller's organization (cross-tenant document attach)src/app/api/uploads/route.ts:10,29-37
How to fix
Look the project up with and(eq(projects.id, projectId), eq(projects.orgId, orgId)) before presigning and return 404 otherwise; filter documents by the project's own orgId on the public page; create the row only after the upload is confirmed.
Evidence
- File:
src/app/api/uploads/route.ts:10,29-37; public rendersrc/app/p/[slug]/page.tsx:7-10,src/components/public-project-view.tsx:30-31 - Code:
projectId: z.string().min(1), // :10, never looked up
await db.insert(documents).values({ id, orgId, projectId: input.projectId, s3Key: input.key, filename: input.filename, ... });Data flow
- Source: request body
projectIdandfilename(route.ts:10-11,21) - Transformations: none that constrain the value; the FK only requires that the project exists (
schema.ts:158) - Sink:
documentsinsert (route.ts:29-37);/p/[slug]loadswith: { documents: true }by project, regardless of the document'sorgId, and lists eachfilename - Sanitization: none
- Verdict: VULNERABLE
Impact
A signed-in member of any organization can attach document entries to another organization's project by id. On a public project the attacker's filename (up to 200 chars, rendered as text) appears on the victim's anonymous /p/[slug] page, which gives a content-spoofing/phishing vector inside another tenant's page; public project ids are exposed by that same page (exposure-101). Rows are also created before any upload happens.
Regression test
MEDIUM: no regression test required.
Chained with
- upload-401, exposure-101, chain-902
mediumDockerfile bakes DATABASE_URL, BETTER_AUTH_SECRET and STRIPE_SECRET_KEY into the final image via ARG and ENV
How to fix
Remove the three ARG/ENV lines and inject secrets at run time through the platform environment or a secrets manager. Rotate any secret already built into a pushed image.
Evidence
- File:
Dockerfile:14-22 - Code:
FROM node:22.11-alpine3.20 AS runner
ARG DATABASE_URL
ARG BETTER_AUTH_SECRET
ARG STRIPE_SECRET_KEY
ENV DATABASE_URL=$DATABASE_URL \
BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \
STRIPE_SECRET_KEY=$STRIPE_SECRET_KEYData flow
- Source:
--build-argvalues supplied by whoever builds the image - Transformations: ENV in the final (runner) stage stores the values in the image config; the ARG values are also recorded in the image history
- Sink: anyone who can pull the image or read the registry (or a build cache) runs
docker inspect/docker historyand reads the values; the same values are the live production credentials the app reads atsrc/db/index.ts:5andsrc/lib/stripe.ts:3 - Sanitization: none: no
RUN --mount=type=secret, no run-time injection; .dockerignore excludes .env (so this is the only path) - Verdict: VULNERABLE
Impact
Anyone with pull access to the image (registry users, CI caches, a leaked image) can read the production database URL, the Stripe secret key and BETTER_AUTH_SECRET, the last of which lets them forge session cookies for any user. Prerequisite (1): the image is built with these build args, which the Dockerfile requires for them to have any value.
Regression test
n/a (MEDIUM).
Chained with
- none
mediumNo framing protection (frame-ancestors/X-Frame-Options), COOP or CSP while session cookies are SameSite=Nonenext.config.ts:3-13
How to fix
Add headers() in next.config.ts: Content-Security-Policy: frame-ancestors 'self' (plus a script-src policy), X-Frame-Options: DENY, Cross-Origin-Opener-Policy: same-origin, X-Content-Type-Options: nosniff, Strict-Transport-Security, Referrer-Policy. Use SameSite=Lax cookies unless a cross-site embed truly needs None.
Evidence
- File:
next.config.ts:3-13;src/proxy.ts:4-12;src/lib/auth.ts:36 - Code:
const config: NextConfig = {
images: { remotePatterns: [...] },
experimental: { serverActions: { bodySizeLimit: "2mb" } },
}; // no headers()
// auth.ts
defaultCookieAttributes: { sameSite: "none", secure: true },Data flow
- Source: n/a (response headers)
- Transformations: none
- Sink: every page response, including one-click state-changing forms (Upgrade buttons, src/app/(app)/settings/billing/page.tsx:16-21) and the billing page's postMessage receiver (injection-203)
- Sanitization: none: no headers() in next.config.ts, proxy.ts sets no headers, grep of src and config for Content-Security-Policy, X-Frame-Options, frame-ancestors, Cross-Origin-Opener-Policy and Strict-Transport-Security finds nothing. Because the session cookie is SameSite=None, a cross-site iframe of the app is signed in. The hosting platform may add headers; not visible in the repo and not checked.
- Verdict: VULNERABLE
Impact
Any website can frame signed-in Ledgerly pages and trick users into clicking (clickjacking on billing and invoice actions), and can hold a window reference to app pages, which is the delivery path for injection-203. Without CSP, any script injection (injection-201, injection-203) runs unrestricted. Missing HSTS and nosniff are hardening only.
Regression test
n/a (MEDIUM).
Chained with
- injection-203 (delivery path via frame or opener; no CSP to block the javascript: URL)
- auth-111 (same SameSite=None root cause; noted for COMBINE)
mediumPublic project page sends the full project row (internal notes, budget, client contact e-mail, org id, document S3 keys) to anonymous visitorssrc/app/p/[slug]/page.tsx:7-12
How to fix
Select only name, description and document filename (a DTO) before passing data to the client component; never pass full ORM rows to "use client" components.
Evidence
- File:
src/app/p/[slug]/page.tsx:7-12;src/components/public-project-view.tsx:1,19-21 - Code:
const project = await db.query.projects.findFirst({
where: (p, { and, eq }) => and(eq(p.slug, slug), eq(p.isPublic, true)),
with: { documents: true },
});
return <PublicProjectView project={project} />; // "use client" componentData flow
- Source: anonymous GET /p/<slug>; /p is not in the proxy matcher (src/proxy.ts:14-22) and the page has no session check
- Transformations: the whole row (internalNotes, budgetCents, clientContactEmail, orgId, createdBy; src/db/schema.ts:95-107) plus every document row (s3Key, orgId, uploadedBy; schema.ts:153-164) is passed as a prop to a client component, so React serializes all of it into the RSC payload in the HTML
- Sink: HTML/RSC response; the component renders only name, description and filenames (public-project-view.tsx:12-21), but all props are sent
- Sanitization: none: no column selection
- Verdict: VULNERABLE
Impact
Anyone can read the internal notes, budget, client contact e-mail, organization id and document storage keys of every public project from the page source, although the UI shows only name, description and file names. Limited to projects marked public.
Regression test
n/a (MEDIUM).
Chained with
- auth-108 (any member, of any org, can flip isPublic on a project by id: turns this into exposure of arbitrary projects)
- upload-401 (leaked s3Key values feed the client-chosen-key upload/read issue; auth-112 merged into it), auth-107, auth-106, auth-108, auth-403 (leaked org and project ids), chain-901, chain-902
mediumDeleting an organization leaves its uploaded objects in the bucket, contrary to the "including uploaded documents" promisesrc/app/(app)/settings/organization/actions.ts:9-22
How to fix
Before the DB delete, collect the org's documents.s3Key values (or delete by a server-generated ${orgId}/ prefix once upload-401 is fixed) and delete the objects, or enqueue a retried job that does. Docs also claim the opposite: src/app/(app)/settings/organization/actions.ts:9,20.
Evidence
- File:
src/app/(app)/settings/organization/actions.ts:9-22;src/db/schema.ts:153-163 - Code:
// Settings → Danger zone: "Permanently delete this organization and all of its data, including uploaded documents."
...
// Every org-owned table references organization with onDelete: "cascade", documents included.
await db.delete(organization).where(eq(organization.id, orgId));Data flow
- Source: owner calls
deleteOrganizationafter confirming the slug (actions.ts:10-18) - Transformations: the DB cascade removes
documentsrows (schema.ts:155-158); nothing lists or deletes objects: noDeleteObjectCommand/DeleteObjectsCommandanywhere undersrc/(grep), no job enqueued - Sink: objects stay in the R2 bucket under their
s3Key, while the rows that recorded the keys are gone - Sanitization: none in the repo; a bucket lifecycle rule outside the repo was not checkable
- Verdict: VULNERABLE
Impact
After an owner "permanently" deletes an organization, its uploaded customer documents stay in storage indefinitely and can no longer be found from the database, so erasure commitments are not met. Anyone who knows such a key can still read or overwrite the object through the upload/download flaw (upload-401).
Regression test
MEDIUM: no regression test required.
Chained with
- upload-401
mediumLLM-generated HTML from invoice notes, line descriptions and customer name rendered with dangerouslySetInnerHTMLsrc/app/(app)/invoices/[id]/summary/page.tsx:23-37
How to fix
Do not render model output as HTML. Return plain text or structured JSON and render it with React elements, or pass it through a sanitizer with an allowlist of p, ul, li, strong and no attributes.
Evidence
- File:
src/app/(app)/invoices/[id]/summary/page.tsx:23-37 - Code:
const { text: summaryHtml } = await generateText({
model: anthropic("claude-haiku-4-5"),
system: "Summarise the invoice ... Answer with an HTML fragment using <p>, <ul> and <strong>.",
prompt: [
`Invoice ${invoice.number} for ${invoice.customer}.`,
`Notes: ${invoice.notes ?? "none"}`,
...lines.map((l) => `- ${l.quantity} x ${l.description}`),
].join("\n"),
});
...
<div dangerouslySetInnerHTML={{ __html: summaryHtml }} />Data flow
- Source: invoices.number, invoices.notes, customers.name (page.tsx:15) and invoice_lines.description (page.tsx:21), stored text. No code in this repository writes invoices, invoice lines or customers (grep for insert/update of these tables finds only status updates and the erasure path), so who authors that text was not verified.
- Transformations: concatenated into the prompt (page.tsx:27-31); the model's text is returned unchanged (page.tsx:23)
- Sink: dangerouslySetInnerHTML (page.tsx:37), server-rendered in the signed-in app origin; on first load the HTML parser runs inline
<script>, on client navigation event-handler attributes (e.g.<img onerror>) still run - Sanitization: none; the system prompt asking for p/ul/strong is not a control. The sibling action escapes model output (summary/actions.ts:12-13,34), this page does not.
- Verdict: VULNERABLE
Impact
Whoever can author invoice text that reaches this prompt can instruct the model to emit script-bearing HTML that runs in the session of every org user who opens /invoices/<id>/summary (owners and admins included). Rated MEDIUM, not HIGH: the write path for that text is not in this repository (assumed to be org members or imported data), and the attack also depends on the model following the injected instruction.
Regression test
n/a (MEDIUM). A test would stub generateText to return <img src=x onerror=alert(1)> and assert the rendered output contains no onerror attribute.
Chained with
- auth-104 (an XSS in an owner's session can perform any owner action; noted for COMBINE)
mediumSystematic (2 locations): e-mail HTML built from unescaped user-controlled fields; anyone can inject HTML into a Ledgerly verification e-mail sent to any addresssrc/lib/auth.ts:17-25
How to fix
HTML-escape every interpolated field in e-mail templates (move escapeHtml to src/lib and use it in auth.ts and digest.ts), or use a mail templating library that escapes by default. Consider rejecting </> in display names at sign-up.
Evidence
- File:
src/lib/auth.ts:17-25(verification e-mail);src/server/jobs/digest.ts:33-42(overdue digest) - Code:
// src/lib/auth.ts
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({ to: user.email, subject: "Verify your Ledgerly account",
html: `<p>Hi ${user.name},</p><p><a href="${url}">Verify your email</a></p>` });
},
// src/server/jobs/digest.ts
.map((i) => `<tr><td>${i.number}</td><td>${i.customer}</td>...`)
html: `<p>Hi ${r.name},</p><table>${rows}</table>`,Data flow
- Source: (1)
nameandemailof the public Better-Auth sign-up (POST /api/auth/sign-up/email, src/app/api/auth/[...all]/route.ts), both chosen by an anonymous caller;sendOnSignUp: true(auth.ts:18). (2) invoices.number and customers.name in the digest (digest.ts:18-21); no write path for these in the repo, so their authors were not verified. user.name in the digest goes only into that user's own mail (no cross-user effect). - Transformations: template-string interpolation into HTML
- Sink: sendEmail html (src/lib/email.ts:5-11), sent from
notifications@ledgerly.app - Sanitization: none at either site; the code base has an escapeHtml helper (src/app/(app)/invoices/[id]/summary/actions.ts:12-13) that these sites do not use
- Verdict: VULNERABLE
Impact
Anyone, without an account, can sign up with a victim's e-mail address and a name containing HTML (links, buttons, fake text), so Ledgerly's own sender delivers attacker-written content to that victim, which helps phishing because the mail is genuinely from the product. In the digest, customer and invoice text is injected the same way, and the cross-organization recipient list (auth-109) carries it to users outside the organization. Mail clients block script, so the effect is content and link injection, not code execution.
Regression test
n/a (MEDIUM).
Chained with
- auth-109 (digest goes to every opted-in user on the platform; exposure-412 merged into it)
- rate-limit-411 (invite has no throttle; same mail-abuse family, invite HTML itself has no user fields)
mediumMember-reachable LLM calls have no per-user or per-org budget (systematic: 3 locations: /api/chat, invoice summary page, sendAiReminder)src/app/api/chat/route.ts:11-19
How to fix
Cap chat message count and total characters, cache the summary per invoice (regenerate on change), and add a per-org daily LLM budget backed by usage_events that is checked before each call (shared helper for all three sites and rate-limit-101).
Evidence
- File:
src/app/api/chat/route.ts:11-19,src/app/(app)/invoices/[id]/summary/page.tsx:23-32,src/app/(app)/invoices/[id]/summary/actions.ts:25-29 - Code:
// chat/route.ts:11-19
const { messages }: { messages: UIMessage[] } = await request.json(); // no count/size cap
streamText({ model: anthropic("claude-sonnet-4-5"), messages: convertToModelMessages(messages), ..., stopWhen: stepCountIs(5) });
// summary/page.tsx:23-25 (runs on every GET render of /invoices/[id]/summary)
generateText({ model: anthropic("claude-haiku-4-5"), maxOutputTokens: 400, ... })
// summary/actions.ts:25-27 (sendAiReminder)
generateText({ model: anthropic("claude-haiku-4-5"), maxOutputTokens: 300, ... })Data flow
- Source: authenticated member requests: chat
messagesbody (chat/route.ts:11), GET of the summary page (page.tsx:10-12),sendAiReminder(invoiceId)server action (actions.ts:15) - Transformations: model fixed server side at all three, step cap 5 in chat, output caps 400/300 on the other two (good). No cap on chat message count or total characters (route handlers have no body limit here;
serverActions.bodySizeLimitinnext.config.ts:11does not apply to route handlers). No caching of the page summary - Sink: Anthropic API billed to Ledgerly (
chat/route.ts:13,page.tsx:23,actions.ts:25) - Sanitization: none beyond the fixed models and caps; no rate limit, no per-org budget (
usage_eventsnever written, see rate-limit-101) - Verdict: VULNERABLE (bounded per request, unbounded in volume)
Impact
Any signed-in user (self-service sign-up and org creation) can loop /api/chat with near-context-window message histories on claude-sonnet-4-5 (input cost per step chosen by the caller, up to 5 steps), or reload the invoice summary page and fire sendAiReminder repeatedly, and every call is billed to Ledgerly's Anthropic account with no quota. Per-request cost is server-bounded, which separates this from rate-limit-101; the volume is not.
Regression test
Not required (MEDIUM).
mediumTeam invite has no dedupe or rate limit: any user can send unlimited Ledgerly invitation e-mails to arbitrary addressessrc/app/api/team/invite/route.ts:14-41
How to fix
Unique partial index on (organization_id, lower(email)) where status = 'pending' with upsert/resend cooldown, a per-org daily invite cap, and a per-user rate limit on the route.
Evidence
- File:
src/app/api/team/invite/route.ts:14-41 - Code:
if (!me || (me.role !== "owner" && me.role !== "admin")) return new Response("Forbidden", { status: 403 });
const email = String(form.get("email") ?? "").trim().toLowerCase();
if (!email.includes("@") || !ROLES.has(role)) return new Response("Invalid invitation", { status: 400 });
await db.insert(invitation).values({ id, organizationId: orgId, email, role, status: "pending", ... });
await sendEmail({ to: email, subject: "You have been invited to Ledgerly", html: ... });Data flow
- Source: form field
email(route.ts:22), onlyincludes("@")validation (:24) - Transformations: role gate owner/admin (
:14-19). That gate is not a meaningful barrier for abuse: sign-up is self-service andorganization({ allowUserToCreateOrganization: true })(src/lib/auth.ts:39), so any user can create an org; I assume (Better-Auth defaultcreatorRole: "owner", library code not available in this checkout) the creator becomes its owner - Sink:
invitationinsert (:27-35) andsendEmailto the caller-chosen address via Resend (:37-41,src/lib/email.ts:6-11) - Sanitization: none. No unique (org, email, pending) index (
src/db/schema.ts:78-90), no cooldown, no per-org daily cap; Better-AuthrateLimit(auth.ts:33) covers only/api/auth/* - Verdict: VULNERABLE
Impact
Any user who signs up and creates an organization can POST /api/team/invite in a loop and make Ledgerly send unlimited invitation e-mails to any third-party address (mail bombing from Ledgerly's domain, Resend cost, and spam complaints that can get the sending domain suspended, which would also stop verification e-mails for all users). Content is fixed, so phishing value is low.
Regression test
Not required (MEDIUM).
mediumPresigned PUT has no size limit, no content-type allowlist and no content validationsrc/app/api/uploads/route.ts:9-14,23-27
How to fix
Allowlist content types, sign with ContentLength (or use a presigned POST with content-length-range), cap per-org storage and presign rate, and verify size/magic bytes after upload before the document becomes usable.
Evidence
- File:
src/app/api/uploads/route.ts:9-14,23-27 - Code:
contentType: z.string().min(1),
...
new PutObjectCommand({ Bucket: BUCKET, Key: input.key, ContentType: input.contentType }), { expiresIn: 900 }Data flow
- Source: request body
contentTypeandkey(route.ts:12-13) - Transformations: none
- Sink: presigned PUT (
:23-27) with noContentLength, so the URL accepts a body up to the provider's single-PUT maximum, with an attacker-chosen Content-Type; no rate limit on presign requests - Sanitization: none on upload. Downloads are forced to
attachment(download/route.ts:27), which limits in-browser rendering through that route only - Verdict: VULNERABLE
Impact
Any signed-in member (anyone can sign up and create an org) can store objects of unbounded size and number in Ledgerly's bucket, driving storage cost, and can store text/html or image/svg+xml objects. Whether the bucket is also served publicly (a *.r2.dev host is allowed in next.config.ts image patterns) was not checked; if it is, those objects render inline from that host.
Regression test
MEDIUM: no regression test required.
Chained with
- upload-401
low · 7
lowInvoice status transitions are unchecked: void invoices can be marked paid, paid invoices voided, paidAt re-stamped (also under concurrent calls)src/app/(app)/invoices/actions.ts:19-27
How to fix
Make each transition conditional and atomic: .where(and(eq(invoices.id, id), eq(invoices.orgId, orgId), eq(invoices.status, "open"))) with .returning(), and report an error when no row was updated; same in both AI tools.
Evidence
- File:
src/app/(app)/invoices/actions.ts:19-27,src/server/ai/billing-tools.ts:12-18 - Code:
// invoices/actions.ts:22-25 (markInvoicePaid server action, any member)
await db.update(invoices).set({ status: "paid", paidAt: new Date() })
.where(and(eq(invoices.id, id), eq(invoices.orgId, orgId)));
// billing-tools.ts:13-17 (voidInvoice tool, executed without approval via /api/assistant/billing)
await db.update(invoices).set({ status: "void" }).where(eq(invoices.id, invoiceId)).returning(...);Data flow
- Source: invoice id from the
markInvoicePaidserver action argument (invoices/actions.ts:21) or from the model in/api/assistant/billing(billing-tools.ts:12) - Transformations: zod
min(1)only; no current-status condition in either WHERE clause, no transaction, no version column; return value / row count not checked in the action - Sink:
invoices.status/paidAtoverwritten (invoices/actions.ts:22-25,billing-tools.ts:13-17). The AI toolmarkInvoicePaid(billing-tools.ts:27-31) has the same pattern but hasneedsApproval: trueand the billing route never supplies an approval; I assume (AI SDK 6 semantics, library code not available) it is never executed there - Sanitization: none
- Verdict: VULNERABLE
Impact
A signed-in member can mark a voided (or draft) invoice as paid, void an already paid one, or re-stamp paidAt on repeat or double-submit; concurrent markInvoicePaid and voidInvoice calls end in last-write-wins. Members are already allowed to change invoice status, so this is a bookkeeping-integrity flaw within their own org: LOW. The cross-tenant case (voidInvoice has no org filter) is auth-105 in another shard.
Regression test
Not required (LOW).
Chained with
- auth-105 (cross-shard, see chain-notes-C.md)
lowStripe webhook has no idempotency or ordering guard: a redelivered checkout.session.completed re-activates a canceled subscription and overwrites stripeSubscriptionIdsrc/app/api/webhooks/stripe/route.ts:7-38
How to fix
Verify the signature first (auth-101); store event.id in a table with a unique constraint and skip duplicates; ignore events older than subscriptions.updatedAt or re-fetch the subscription from Stripe; check payment_status; handle customer.subscription.updated and checkout.session.async_payment_failed.
Evidence
- File:
src/app/api/webhooks/stripe/route.ts:7-38 - Code:
const event = (await request.json()) as Stripe.Event; // :7 event.id never stored
case "checkout.session.completed": ...
.onConflictDoUpdate({ target: subscriptions.orgId,
set: { plan, status: "active", stripeSubscriptionId: String(checkout.subscription), updatedAt: new Date() } }); // :25-28
case "customer.subscription.deleted": ...
.set({ status: "canceled", updatedAt: new Date() }).where(eq(subscriptions.stripeSubscriptionId, sub.id)); // :33-36Data flow
- Source: webhook event body (
route.ts:7) - Transformations: none. No processed-event table, no comparison of
event.createdwithsubscriptions.updatedAt, nopayment_statuscheck, no handling ofcustomer.subscription.updatedorcheckout.session.async_payment_failed - Sink: unconditional upsert to
status: "active"keyed by orgId (route.ts:15-28); cancellation matched only bystripeSubscriptionId(:33-36) - Sanitization: none
- Verdict: VULNERABLE (under specific delivery conditions)
Impact
When Stripe redelivers a checkout.session.completed after the subscription was canceled (Stripe retries failed deliveries for days and does not guarantee order), the org is set back to active on its paid plan without paying, and a stale stripeSubscriptionId makes later cancellation events miss the row. This needs a failed or out-of-order delivery that the user does not control, so LOW. Not counted here: forging events, which is the missing signature check (auth-101); once that is fixed, this finding is what remains.
Regression test
Not required (LOW).
Chained with
- auth-101 (forged events: missing signature check on the same handler; auth-404 merged into it)
lowstartCheckout does not check for an existing subscription and any member can start it: a second completed checkout overwrites the org's subscription record and orphans the firstsrc/app/(app)/settings/billing/actions.ts:10-22
How to fix
Restrict to owner/admin, refuse checkout when the org already has an active subscription (send to the Stripe customer portal to change plan), keep one Stripe customer per org, and pass an idempotencyKey.
Evidence
- File:
src/app/(app)/settings/billing/actions.ts:10-22 - Code:
export async function startCheckout(plan: z.infer<typeof planInput>) {
const { session, orgId } = await requireOrg(); // any member, no role check
const selected = planInput.parse(plan);
const checkout = await stripe.checkout.sessions.create({ mode: "subscription", customer_email: session.user.email,
line_items: [{ price: PRICES[selected], quantity: 1 }], metadata: { orgId, plan: selected }, ... }); // no idempotencyKey, no subscriptions lookup
redirect(checkout.url!);
}Data flow
- Source: server action argument
plan(zod enum,actions.ts:8,12);orgIdfrom the session (:11) - Transformations: none checks
subscriptionsfor an existing active row; no owner/admin check; a new Stripe customer per checkout (customer_email) - Sink:
stripe.checkout.sessions.create(actions.ts:13-20), then on completion the webhook upsert keyed by orgId overwritesplanandstripeSubscriptionId(src/app/api/webhooks/stripe/route.ts:25-28) - Sanitization: none
- Verdict: VULNERABLE
Impact
Any member of an organization (not only owner/admin) can complete a second checkout, which replaces the org's recorded plan and stripeSubscriptionId; the first subscription keeps billing whoever paid for it with no record in Ledgerly, and its cancellation event no longer matches. A member can thereby change the org's plan (e.g. team to pro), at the cost of paying for the new one. Correction to the raw claim: a double click only creates two Checkout sessions; a duplicate subscription needs two checkouts actually completed and paid, so LOW.
Regression test
Not required (LOW).
Chained with
- concurrency-405
lowCSV export does not neutralize spreadsheet formulas in customer fieldssrc/app/api/export/route.ts:26-28,38-40
How to fix
Prefix cells that begin with =, +, -, @, tab or CR with a single quote and quote cells containing CR.
Evidence
- File:
src/app/api/export/route.ts:26-28,38-40 - Code:
const body = rows.map((r) =>
[r.name, r.email, r.phone ?? "", r.taxId ?? "", r.createdAt.toISOString()].map(csvCell).join(","),
);
function csvCell(value: string) {
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
}Data flow
- Source: customers.name, email, phone, taxId (export/route.ts:15-19). No code in this repository creates or edits customers (only the erasure path, customers/actions.ts:22), so who writes these values was not verified.
- Transformations: csvCell quotes only on
",,and\n - Sink: text/csv attachment (route.ts:30-35), opened in a spreadsheet by the exporting user
- Sanitization: no prefix for cells starting with
=,+,-,@, tab or CR; CR missing from the quoting regex - Verdict: VULNERABLE
Impact
Whoever can set a customer's name or contact fields can plant a formula that a spreadsheet evaluates when a user opens the export, which can exfiltrate neighbouring cells through a hyperlink or external-reference formula. Modern spreadsheet apps warn before external content, and the customer write path is not in this repository, so LOW.
Regression test
n/a (LOW).
Chained with
- none (auth-107 lets an attacker export another org's customers, but a formula then fires in the attacker's own spreadsheet; no combined gain)
lowPost-login redirect check accepts /\host and /<tab>/host, which URL parsing turns into //hostsrc/components/login-form.tsx:18-19
How to fix
Resolve with new URL(next, location.origin), require url.origin === location.origin, and push url.pathname + url.search + url.hash.
Evidence
- File:
src/components/login-form.tsx:18-19 - Code:
const next = params.get("next");
router.push(next && next.startsWith("/") && !next.startsWith("//") ? next : "/dashboard");Data flow
- Source:
nextquery parameter of /login (set legitimately by src/proxy.ts:8; a crafted link can set any value) - Transformations: only checks a leading
/and rejects a leading// - Sink: router.push(next) after a successful sign-in (login-form.tsx:19)
- Sanitization: no origin comparison. WHATWG URL parsing treats
\like/for http(s) and strips tab/newline, so/\evil.exampleand/%09/evil.example(decoded to a tab by searchParams) resolve tohttps://evil.example/; the app router performs a full navigation for a cross-origin URL. Not run in a browser in this audit; inferred from URL parsing rules. - Verdict: VULNERABLE
Impact
A link to /login with a crafted next sends the user to another site right after they sign in, which makes a fake "session expired, sign in again" page convincing. No token is leaked by the redirect itself.
Regression test
n/a (LOW).
Chained with
- none
lowNo security event logging or monitoring: failed API keys, forged webhooks and privilege changes leave no tracesrc/server/jobs/worker.ts:23-26
How to fix
Add structured logging (without secrets or request bodies) for failed sign-ins, invalid API keys, webhook verification failures, role/membership changes and data exports, and ship it to an alerting backend.
Evidence
- File:
src/server/jobs/worker.ts:23-26;src/server/api/index.ts:25-26,33;src/app/api/webhooks/resend/route.ts:19-21 - Code:
// the only logging statement under src/
main().catch((err) => { console.error(err); process.exit(1); });
// index.ts
if (!key) return c.json({ error: "invalid_api_key" }, 401);
// resend route
} catch { return new Response("Invalid signature", { status: 400 }); }Data flow
- Source: security events: invalid API keys (src/server/api/index.ts:26), unauthorized public-API calls (index.ts:33), webhook signature failures (resend/route.ts:19-21), admin/role and organization changes
- Transformations: none
- Sink: none: the events return an error response and are not recorded
- Sanitization: n/a. grep for console., logger, pino, winston and Sentry under src and package.json finds only worker.ts:24.
- Verdict: VULNERABLE
Impact
An attacker guessing API keys, replaying forged webhooks (the Stripe webhook is not even verified, auth-101) or abusing privilege bugs leaves no application-level record, so abuse is neither alerted on nor reconstructable afterwards. Better-Auth may keep its own session rows; nothing else records these events. No sensitive data is logged.
Regression test
n/a (LOW).
Chained with
- none
lowsendAiReminder has no cooldown or dedupe: any member can e-mail an invoice's customer without limitsrc/app/(app)/invoices/[id]/summary/actions.ts:15-36
How to fix
Mirror sendSmsReminder: insert a per-invoice/day row under a unique index (and a per-org daily cap) before sending, and refuse reminders for invoices that are not open.
Evidence
- File:
src/app/(app)/invoices/[id]/summary/actions.ts:15-36 - Code:
export async function sendAiReminder(invoiceId: string) {
const { orgId } = await requireOrg();
const [invoice] = await db.select({ ..., to: customers.email }) ... .where(and(eq(invoices.id, invoiceId), eq(invoices.orgId, orgId)))
const { text } = await generateText({ model: anthropic("claude-haiku-4-5"), maxOutputTokens: 300, ... });
await sendEmail({ to: invoice.to, subject: `Payment reminder: invoice ${invoice.number}`, html: ... });
return { ok: true };
}Data flow
- Source: server action argument
invoiceId(actions.ts:15), any member of the org (requireOrg, no role check) - Transformations: tenant-scoped lookup (
:21); recipient is the org's own customer e-mail, not caller-chosen. No per-invoice/day record, no per-org cap, no invoice status check (paid or void invoices also get "payment reminders") - Sink:
sendEmailtocustomers.emailvia Resend fromnotifications@ledgerly.app(actions.ts:31-35,src/lib/email.ts:6-11) - Sanitization: none. Contrast
sendSmsReminder, which has an advisory-locked per-org daily cap and a unique (invoice, day) index (sms-actions.ts:23-36, non-concurrency-402) - Verdict: VULNERABLE
Impact
A signed-in member of an organization can call sendAiReminder in a loop and send unlimited "payment reminder" e-mails to that organization's customers from Ledgerly's sending domain (harassment of the org's customers, sender-reputation damage). Recipients are limited to the org's existing customers and no customer-create path exists in the repo, so an outsider cannot aim it at arbitrary addresses: LOW. The LLM spend part of this action is covered by rate-limit-103.
Regression test
Not required (LOW).
Verified safe
Areas the audit examined and found protected, each with the control that protects it.
Authentication & authorization 9
- API key verification (src/server/api/keys.ts) and the two apiKeys Data Scope Scan rows (select :10, update :17)Lookup by the unique key prefix is the authentication step itself, so a global query is by design. Keys are compared as sha256 hash with timingSafeEqual and a length check; raw key never stored; README claim 5 holds. The orgId used by the API afterwards comes from the DB row. Limitations (not findings): no scopes/expir ·
src/server/api/keys.ts:6-19 - markInvoicePaid server action and AI tool: tenant scope, authentication (hotspot #14, entry points 9 and 44)Server action calls requireOrg() first and updates with orgId from the session in the same statement; the AI tool scopes with ctx.orgId from the session and sets needsApproval. Role is any member, consistent with the app's design. Whether the AI SDK enforces needsApproval at runtime was not verified (library absent); t ·
src/app/(app)/invoices/actions.ts:20-25 - POST /api/team/invite authorization and role handling (hotspot #12)Handler requires a session with an active org, loads the caller's member row for that org and allows only owner/admin; invited role limited to member/admin (no owner); org id comes from the session. CSRF side is filed in auth-111; the missing accept-invitation page is outside the repo. ·
src/app/api/team/invite/route.ts:11-24 - sendSmsReminder and sendAiReminder: tenant scope and SMS cost control (entry points 5 and 6)Both call requireOrg() and select the invoice with orgId from the session in the where clause; recipient is the org's own customer. SMS has a per-org daily cap of 50 under an advisory lock plus a unique (invoice, day) index, so it is bounded. (AI reminder has no limit: LLM spend is rate-limit-103, unlimited customer e- ·
src/app/(app)/invoices/[id]/sms-actions.ts:13-37 - eraseCustomer and deleteOrganization server actions (entry points 2 and 19)Both run requireOrg(), then verify the caller's member role in that org in the DB (owner/admin for erase, owner plus slug confirmation for org deletion), and every statement filters by the session orgId. Customer erasure also clears sms_messages.to and invoices.notes in the same transaction. (R2 objects on org deletion ·
src/app/(app)/customers/actions.ts:12-40 - tRPC procedures other than project.update, dashboard getOrgStats cache, org-scoped pages (entry points 3, 10, 16, 20, 52, 53, 55, 56, 57)orgProcedure derives ctx.orgId from the validated session (UNAUTHORIZED/FORBIDDEN otherwise) and list/byId/integration.list/save filter or insert with ctx.orgId. getOrgStats takes orgId as a function argument, which Next's unstable_cache includes in the cache key (library behaviour, not verified in source); the pages c ·
src/server/trpc/init.ts:17-26 - Summary page invoiceLines PARENT-ONLY candidate (summary/page.tsx:21 in prepass; :86 in file) and projects/documents/billing org-scoped handlersThe parent invoice is selected first with the session orgId and notFound() is called when absent; invoiceLines is read by invoice.id only afterwards. Document download, getProject, createProject, deleteProject, billing page and startCheckout all take orgId from requireOrg()/session and put it in the query (or Stripe me ·
src/app/(app)/invoices/[id]/summary/page.tsx:76-86 - Login brute force / auth endpoint rate limiting; password policyBetter-Auth rate limiting is enabled globally for /api/auth/* (20 requests per 60 s), email verification is required and the password minimum is 10; password hashing and session ID generation are done by the library (not read, node_modules absent). Counter storage backend (memory by default) is unverified for multi-ins ·
src/lib/auth.ts:12-16,33 - Shard A EXPAND sibling checks: other places that could take the tenant from the request (as in auth-107), other unsigned webhooks (as in auth-101), other unstable_cache keys (as in auth-113), and other project/invoice reads and writes next to auth-102/auth-108.Only
src/app/api/export/route.ts:11reads an org id from the request (grepsearchParams.get, all of src). The only other webhook, Resend, verifies the svix signature before acting and returns 400 on failure. The only otherunstable_cacheuse,getOrgStats, takesorgIdas an argument.getProjectand `deletePr ·src/app/api/webhooks/resend/route.ts:9-21
Injection & SSRF 6
- sendAiReminder: model output into e-mail HTML and recipient controlThe model text is HTML-escaped before it goes into the e-mail, and the recipient is the customer e-mail of an invoice selected with an org-scoped query, not a model or request value. Prompt injection through invoice notes can only change the wording of the message. ·
src/app/(app)/invoices/[id]/summary/actions.ts:21,34 - signature-frame postMessage receiver and senderReceiver checks the exact origin and the iframe's contentWindow, validates the shape with zod (bounded height, reference max 64), and the handler only refreshes the server page; the sender names the exact target origin. ·
src/components/signature-frame.tsx:22-24,38 - SQL injection in Drizzle queries and sql`` fragmentsAll queries use the Drizzle builder; the only sql`` template binds orgId as a parameter, and ilike patterns are bound values (wildcards only). ·
src/app/(app)/invoices/[id]/sms-actions.ts:24 - Model output sinks in the chat assistant (client)Streamed assistant text and the browser-generated suggestion are rendered as React text children (escaped), not as HTML; tool arguments of the server tools are checked by zod inputSchema. ·
src/components/assistant-panel.tsx:26,42 - Other server-side fetch sinks (SSRF sibling check for injection-202): exchange-rate service and SMS gatewayBoth use a fixed host. getRates only interpolates a currency checked against a four-value allowlist; the SMS client posts to the URL from server env, never from the request. ·
src/server/services/rates.ts:5-6 - Host-header injection in e-mail links (open-redirect sibling check for injection-213): verification, invitation and post-invite redirect URLsEvery absolute URL is built from the server-side APP_URL, never from the request's Host header: Better-Auth's baseURL is APP_URL and the invite route builds both the e-mail link and its redirect from APP_URL. ·
src/lib/auth.ts:10
Data exposure & secrets 5
- eraseCustomer: completeness of anonymization inside the databaseIn one transaction it anonymizes the customer row (name, email, phone, tax id), overwrites sms_messages.to for all of the customer's invoices and nulls invoices.notes, scoped to the org and gated on owner/admin. Remaining copies (third-party mail logs, free text in line descriptions, backups) are listed as not assessed ·
src/app/(app)/customers/actions.ts:13-40 - Hono public API response fieldsBoth list endpoints select an explicit set of columns scoped by the org id from the key or session, so no internal columns are returned. ·
src/server/api/index.ts:39-49,55-58 - PostHog analytics capturing PIIinitAnalytics has no caller in src (no layout, provider or component imports it), so posthog is never initialized and captures nothing; nothing calls identify or capture. Dead code that matters only if wired up. ·
src/lib/analytics.ts:5-10 - Document download: signed URL scope and expiryThe document lookup is scoped by the session's org and the presigned GET expires after 60 seconds. ·
src/app/api/documents/[id]/download/route.ts:18,29 - Other hardcoded credentials in the tracked tree (sibling check for exposure-306): src, configs, compose file, workflows, READMENo Stripe key prefixes, API key literals or password/secret string literals in tracked files; every credential is read from process.env or GitHub secrets, and the compose file requires POSTGRES_PASSWORD from the environment. ·
drizzle.config.ts:7
Headers, CORS, build & deploy configuration 3
- GitHub Actions CI workflow (ci.yml)Trigger is pull_request (no secrets to forks), permissions contents: read, actions pinned to full SHAs, persist-credentials false, lockfile enforced, PR title and head_ref passed through env and used as quoted shell variables. ·
.github/workflows/ci.yml:3-12,15-18,23,28-31 - Lockfile integrity and enforcementpnpm-lock.yaml lockfileVersion 9: every resolution line carries an integrity hash, no tarball/git/http sources; all dependencies in package.json are exact versions; CI and Dockerfile install with --frozen-lockfile and the Dockerfile copies the lockfile before installing. ·
pnpm-lock.yaml:1 - Container user, build context and published portsFinal stage runs as USER node; .dockerignore excludes .env, .env.local and .git before COPY . .; compose publishes Postgres on 127.0.0.1 only and takes the password from the environment; no privileged, docker.sock or host network. ·
docker-compose.yml:8-9
Cryptography 3
- Resend webhook signature verificationsvix Webhook.verify runs on the raw text body before any state change and failure returns 400; a missing secret makes the Webhook constructor throw, so the route fails closed. svix verify also enforces a timestamp tolerance. ·
src/app/api/webhooks/resend/route.ts:9-21 - API key hashing and comparisonKeys are looked up by prefix, hashed with SHA-256 (high-entropy random keys, so a fast hash is acceptable) and compared with timingSafeEqual after a length check; lookup failure returns null. ·
src/server/api/keys.ts:7-17 - Randomness for ids and phone verification codeSecurity values use CSPRNG: randomInt for the 6-digit code, crypto.randomUUID for ids; no Math.random in src; the code is stored hashed with a 10 minute expiry. (Verification-attempt limits and SMS abuse belong to other auditors.) ·
src/app/api/phone/send-code/route.ts:12-18
Concurrency & races 2
- sendSmsReminder daily/org SMS limit: read-then-write counter raceThe count and insert run in one transaction under pg_advisory_xact_lock keyed by orgId, so concurrent calls serialize; a unique index on (invoice_id, sent_on) with onConflictDoNothing prevents duplicate sends per invoice/day. Org filter comes from the session. ·
src/app/(app)/invoices/[id]/sms-actions.ts:23-36 - getOrgStats unstable_cache tenant keyorgId is a parameter of the cached function, so Next includes it in the cache key; entries do not cross tenants (contrast getUsageThisMonth, auth-414). ·
src/server/queries/dashboard.ts:7-18
Rate limiting & abuse 1
- EXPAND of rate-limit-102/411: other anonymous paths that send e-mail or SMS at Ledgerly's cost. Sign-up verification e-mail (
sendVerificationEmail) is the only other anonymous paid send; grep ofsendEmail|sendSms|anthropic(insrc/found no paid call in/api/public,/api/webhooks/resendor/api/uploads.The verification e-mail is sent from inside the Better-Auth handler at/api/auth/*, which has Better-Auth rate limiting enabled (20 requests per 60 s window). I assume, from Better-Auth's documented behaviour (library code not in this checkout), that the limit applies per client IP to sign-up and resend-verification ·src/lib/auth.ts:17-26
File upload & storage 1
- Document download: ownership check of the document id (hotspot #17)The row is selected by id AND the session's active organization, so a foreign document id returns 404 before any presign; the GET URL lives 60 s and forces attachment disposition with an encoded filename. (The object key itself is attacker-influenced via upload-401, which is the separate finding.) ·
src/app/api/documents/[id]/download/route.ts:15-30
Not assessed
Nobody checked these. They are unknown, not safe.
- which org endpoints enforce which role, additionalFields input default, cookie HttpOnly · src/lib/auth.ts (Better-Auth org plugin endpoints (A5-A7))library source not readable (node_modules absent, install forbidden) · auth
- cross-site POST to tRPC mutations · src/app/api/trpc/[trpc]/route.ts (tRPC CSRF content-type handling)depends on tRPC library content-type rules, not readable · auth
- API key creation raceno API key creation code exists in the repo (grep apiKeys: only verifyApiKey reads/updates) · concurrency
- WebSocket securityno WebSocket/Socket.IO code found in recon entry points · concurrency
- bucket-side policy (CORS, public access, object-lock, size limits)R2 bucket configuration is not in the repo · concurrency
- null byte / multer limitsno server-side file parsing; uploads go directly to S3 via presigned PUT, so multer/busboy limits do not apply · upload
- known-vulnerable dependency versionsosv-scanner and docker unavailable and the pnpm audit fallback crashed (prepass: no rows); dependencies were not checked against advisories, not clean · dependency
- workflow static analysis · .github/workflowszizmor did not run (not installed, docker unavailable); workflows reviewed by hand only · config
- Dockerfile lint and image scan · Dockerfilehadolint and trivy did not run (not installed, docker unavailable); Dockerfile reviewed by hand, image not scanned · config
- dependency lifecycle scriptspnpm lifecycle policy: package.json has no onlyBuiltDependencies/dangerouslyAllowAllBuilds and no .npmrc or pnpm-workspace.yaml exist; the installed pnpm version is not pinned (no packageManager field) so whether dependency scripts are blocked was not determined · config
- platform-added headers and HTTPS enforcementhosting platform (Vercel/Coolify/CDN) response headers and HSTS are not in the repo · config
- browser storagebrowser storage: grep shows no localStorage/sessionStorage/IndexedDB writes in src, but that is absence of hits, not positive evidence; posthog default storage is moot because initAnalytics is never called · exposure
- erasure completeness beyond the databaseerasure of third-party copies (Resend mail logs of past reminders), backup retention and free text in invoice_lines.description cannot be judged from the repo; no retention policy stated · exposure
- semgrep rule scan (p/javascript, p/nextjs, p/react, p/typescript)NOT RUN: semgrep not installed and docker unavailable; to enable: `pipx install semgrep` or `brew install semgrep`, or a running docker (pulls semgrep/semgrep:1.179.0, pinned by digest) (prepass) · code
- GitHub Actions workflow audit (zizmor, offline)NOT RUN: zizmor not installed and docker unavailable; to enable: `pipx install zizmor`, `brew install zizmor` or `cargo install --locked zizmor`, or a running docker (pulls ghcr.io/zizmorcore/zizmor:1.30.1, pinned by digest) (prepass) · ci/cd
- Dockerfile lint (hadolint)NOT RUN: hadolint not installed and docker unavailable; to enable: `brew install hadolint` or the binary from its GitHub releases, or a running docker (pulls hadolint/hadolint:v2.15.1, pinned by digest) (prepass) · infra
- Dockerfile and IaC misconfiguration scan (trivy config)NOT RUN: trivy not installed and docker unavailable; to enable: `brew install trivy` or the binary from its GitHub releases, or a running docker (pulls aquasec/trivy:0.75.0, pinned by digest) (prepass) · infra
- Dependency advisories (osv-scanner; pnpm audit fallback)NOT RUN: osv-scanner and docker unavailable; pnpm audit fallback crashed (corepack cannot provide pnpm 12.5.1). To enable: install osv-scanner (`go install github.com/google/osv-scanner/cmd/osv-scanner@latest`) or start docker, then re-run the pre-pass · dependencies
- Project test suite (vitest)NOT RUN: node_modules absent; `pnpm install` would execute the postinstall `curl ... \ · sh`, so dependencies were deliberately not installed · tests
Production checklist
Deployment and runtime checks the code audit cannot settle. Items marked "from this audit" come from rows under Not Assessed; the baseline items apply to any production deploy. Each says how to verify it on your own host; none of them changes anything.
- Security headers From this auditNot assessed: concurrency · bucket-side policy (CORS, public access, object-lock, size limits) (R2 bucket configuration is not in the repo)Not assessed: config · platform-added headers and HTTPS enforcement (hosting platform (Vercel/Coolify/CDN) response headers and HSTS are not in the repo)How to check:
curl -sI https://<your-host>/and look forContent-Security-Policy,Strict-Transport-Security,X-Content-Type-Options: nosniff,frame-ancestors(orX-Frame-Options) andReferrer-Policy. Repeat on an API route and on an error page; a CDN or proxy can add or strip them. - TLS and HTTPS redirect From this auditNot assessed: config · platform-added headers and HTTPS enforcement (hosting platform (Vercel/Coolify/CDN) response headers and HSTS are not in the repo)How to check:
curl -sI http://<your-host>/should answer with a redirect tohttps://.openssl s_client -connect <your-host>:443 -servername <your-host> </dev/null | openssl x509 -noout -datesshows the certificate expiry; confirm renewal is automated. - Request size, upload and rate limits From this auditNot assessed: concurrency · bucket-side policy (CORS, public access, object-lock, size limits) (R2 bucket configuration is not in the repo)Not assessed: upload · null byte / multer limits (no server-side file parsing; uploads go directly to S3 via presigned PUT, so multer/busboy limits do not apply)How to check: Write down the limits the deployment actually applies: reverse-proxy body size (e.g.
client_max_body_size), the framework's body parser limit, the upload maximum, the login throttle. On staging, with your own test account, confirm an oversized upload is refused (413) and repeated failed logins get throttled. - Secrets storage and rotation From this auditNot assessed: concurrency · API key creation race (no API key creation code exists in the repo (grep apiKeys: only verifyApiKey reads/updates))How to check: Production secrets live in the platform's secret store, not in the repository or the image: run
gitleaks detectover the full git history and checkdocker history <image>for baked-in values. Rotate any value that was ever committed, and check who can read the secret store. - Backups and a tested restore From this auditNot assessed: exposure · erasure completeness beyond the database (erasure of third-party copies (Resend mail logs of past reminders), backup retention and free text in invoice_lines.description cannot be judged from the repo; no retention policy stated)How to check: Check the time of the last backup and that it is stored off the production host. Restore it into a scratch database, compare row counts of the main tables with production, and note how long the restore took.
- Logging, monitoring and alerts From this auditNot assessed: exposure · erasure completeness beyond the database (erasure of third-party copies (Resend mail logs of past reminders), backup retention and free text in invoice_lines.description cannot be judged from the repo; no retention policy stated)How to check: On staging, cause a harmless error (a 404, a failed login with your own test account) and confirm it reaches the log store with a timestamp and request id, and without passwords, tokens or session ids. Confirm someone is alerted on a burst of 5xx responses or failed logins.
- Dependency and runtime updates From this auditNot assessed: dependency · known-vulnerable dependency versions (osv-scanner and docker unavailable and the pnpm audit fallback crashed (prepass: no rows); dependencies were not checked against advisories, not clean)Not assessed: config · Dockerfile lint and image scan · Dockerfile (hadolint and trivy did not run (not installed, docker unavailable); Dockerfile reviewed by hand, image not scanned)Not assessed: config · dependency lifecycle scripts (pnpm lifecycle policy: package.json has no onlyBuiltDependencies/dangerouslyAllowAllBuilds and no .npmrc or pnpm-workspace.yaml exist; the installed pnpm version is not pinned (no packageManager field) so whether dependency scripts are blocked was not determined)and 3 more under Not assessedHow to check: Run the ecosystem's audit on the lockfile that is deployed (
npm audit --omit=dev,composer audit,pip-audit). Check that the runtime and base image versions in production are still supported, and that update pull requests (Dependabot, Renovate) have an owner. - Session cookie flags From this auditNot assessed: auth · which org endpoints enforce which role, additionalFields input default, cookie HttpOnly · src/lib/auth.ts (Better-Auth org plugin endpoints (A5-A7)) (library source not readable (node_modules absent, install forbidden))How to check: Sign in on https://<your-host>/ with your own account and read the
Set-Cookieheader (browser devtools orcurl -sI): the session cookie hasSecure,HttpOnlyandSameSite=LaxorStrict. - Environment variables and debug flags BaselineHow to check: List the variables the code reads (search for
process.env,getenv,os.environ) and confirm each one is set in production with a non-default value. Debug is off (NODE_ENV=production,APP_DEBUG=false,DEBUG=False), and an error page on https://<your-host>/ shows no stack trace. - Exposed services and ports BaselineHow to check: In the hosting provider's firewall or security-group view, only 80 and 443 are public for <your-host>; the database, cache, queue and admin ports are bound to a private network. Repeat after every infrastructure change.
7 other not-assessed row(s) are code checks rather than deployment checks; they stay under Not assessed.
Hotspots
Where the audit started: entry points and configuration ranked by risk signals. A ranking, not a verdict.
| # | Score | Where | Why |
|---|---|---|---|
| 1 | 13 | src/app/api/webhooks/stripe/route.ts:6 webhook POST | payments area, webhooks area; webhook: public by design; no signature verification seen; unscoped query on subscriptions |
| 2 | 10 | src/app/(app)/invoices/actions.ts:10 server-action getInvoice | payments area; server-action: callable directly as a POST; no auth check in the handler; unscoped query on invoices, invoiceLines |
| 3 | 10 | src/app/api/admin/stats/route.ts:5 route-handler GET | admin area; no auth check in the handler; unscoped query on projects, subscriptions |
| 4 | 10 | src/lib/auth.ts:1 config auth.ts | auth configuration (betterAuth); additionalFields.role is writable at sign-up (no input: false); SameSite=None session cookies |
| 5 | 9 | src/server/ai/billing-tools.ts:9 ai-tool voidInvoice | payments area, ai area; ai-tool: arguments chosen by the model; unscoped query on invoices |
| 6 | 9 | src/server/ai/tools.ts:9 ai-tool searchInvoices | payments area, ai area; ai-tool: arguments chosen by the model; tool schema lets the model choose the tenant/user |
| 7 | 8 | src/app/(app)/invoices/[id]/summary/page.tsx:1 page page | payments area; unscoped query on invoiceLines; raw HTML rendering; LLM output may reach raw HTML |
| 8 | 7 | src/app/api/assistant/billing/route.ts:8 route-handler POST | payments area, ai area; LLM model or token budget taken from the request (cost) |
| 9 | 7 | src/server/trpc/routers/integration.ts:18 trpc integrationRouter.testWebhook | webhooks area; outbound request to a non-constant URL (SSRF) |
| 10 | 6 | src/app/(app)/invoices/[id]/summary/actions.ts:15 server-action sendAiReminder | payments area; server-action: callable directly as a POST; sends email (who are the recipients?) |
| 11 | 6 | src/app/api/export/route.ts:6 route-handler GET | files area; tenant/user id taken from the request |
| 12 | 6 | src/app/api/team/invite/route.ts:9 route-handler POST | auth area; sends email (who are the recipients?) |
| 13 | 6 | src/app/p/[slug]/page.tsx:1 page page | page without a guard reads the database (what reaches the client?); unscoped query on projects |
| 14 | 6 | src/server/ai/billing-tools.ts:22 ai-tool markInvoicePaid | payments area, ai area; ai-tool: arguments chosen by the model |
| 15 | 6 | src/server/api/index.ts:1 config index.ts | CORS reflects any origin with credentials |
| 16 | 6 | src/server/trpc/routers/project.ts:12 trpc projectRouter.update | unscoped query on projects; request object written to the database as-is |
| 17 | 5 | src/app/api/documents/[id]/download/route.ts:9 route-handler GET | files area; presigned storage URL |
| 18 | 5 | src/app/api/uploads/route.ts:16 route-handler POST | files area; presigned storage URL |
| 19 | 5 | src/app/api/webhooks/resend/route.ts:8 webhook POST | webhooks area; webhook: public by design |
| 20 | 5 | src/lib/ai-client.ts:1 code ai-client.ts | NEXT_PUBLIC_ANTHROPIC_API_KEY looks like a secret and is shipped to the browser |
Dependencies
osv-scanner and docker unavailable; fallback ran: pnpm audit -> tools/pnpm-audit.json (summarize from the raw file)
No vulnerable packages reported.
Secrets
gitleaks: 2 hits, 2 commits of this repo. Values are redacted; anything real that was ever committed must be rotated.
| Rule | Where | Commit | Still in tree |
|---|---|---|---|
| stripe-access-token | .env:4 | a1b7c73 2026-05-04 | no (history only) |
| generic-api-key | .env:3 | a1b7c73 2026-05-04 | no (history only) |
Filtered out
Candidates the verifier rejected after reading the code. Kept for the record; none of them needs action.
| Finding | Reason |
|---|---|
| Upload endpoint trusts a client-chosen S3 key and projectId (overwrite or claim other tenants' objects) auth-112 | duplicate_of_upload-401 |
| Stripe webhook accepts unsigned events: anyone can activate a paid plan for any organization auth-304 | duplicate_of_auth-101 |
| Stripe webhook does not verify the signature: anyone can forge subscription events auth-404 | duplicate_of_auth-101 |
| unstable_cache key for getUsageThisMonth omits orgId: first organization's usage is served to all organizations for 10 minutes auth-414 | duplicate_of_auth-113 |
| Invoice paid/void transitions are unconditional: void and paid can overwrite each other and paidAt is rewritten on double-submit concurrency-406 | duplicate_of_business-logic-101 |
| Digest jobs are not idempotent: retries and fanout re-runs resend e-mails to earlier recipients concurrency-413 | best_practice |
| Hono public API reflects any Origin with credentials while session cookies are SameSite=None config-303 | duplicate_of_auth-110 |
| No dependency update mechanism; base image pinned by tag only config-310 | best_practice |
| README Security section claims (authenticated routes, tenant isolation, verified Stripe webhooks, scoped uploads) do not hold docs-vs-reality-101 | duplicate_of_auth-101 |
| "Delete organization and all of its data, including uploaded documents" leaves the R2 objects behind docs-vs-reality-102 | duplicate_of_exposure-415 |
| Overdue digest sends each organization's invoice table to every user in the system with emailDigest enabled exposure-412 | duplicate_of_auth-109 |
| Unauthenticated send-code endpoint sends paid SMS with no rate limit or per-number/IP cap rate-limit-407 | duplicate_of_rate-limit-102 |
| Billing assistant takes model, maxTokens and maxSteps from the request and records no usage rate-limit-409 | duplicate_of_rate-limit-101 |