Security audit · 2026-11-02 · commit a03d8d4 · deps: osv-scanner and docker unavailable; fallback ran: pnpm audit -> tools/pnpm-audit.json (summarize from the raw file) · secrets: gitleaks: 2 hits, 2 commits of this repo

ledgerly

40 issues to fix (2 critical, 19 high, 12 medium, 7 low).

30 areas verified safe · 19 checks not assessed

Profile: JavaScript/TypeScript (Node) (Next.js, Hono, tRPC, Drizzle, Better-Auth, AI SDK, pg-boss): dedicated profile.

Covers: entry points by convention: Next.js route handlers, pages, server actions, proxy/middleware matcher, tRPC procedures, Hono/Express routes, hand-rolled node:http/Bun/Deno routers, pg-boss/BullMQ/cron jobs, AI SDK and MCP tools; Drizzle tenant-scope scan (query sites that never reference the owner column); hotspot ranking with Better-Auth, CORS and browser-exposed env configuration checks; JS/TS stack patterns for the auditors; dependency advisories (osv-scanner) and secrets in git history (gitleaks).

Does not cover: tenant-scope scan for ORMs other than Drizzle (Prisma, TypeORM, Sequelize, Mongoose); entry-point conventions of NestJS, Fastify plugins, Remix/React Router, SvelteKit, Nuxt, Astro and GraphQL resolvers; rule scanners (semgrep, zizmor, hadolint, trivy, bandit, gosec, govulncheck) run only when installed or docker is available; one that did not run is listed in not-assessed.md.

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

  1. 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
  2. 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
  3. 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-36

Data flow

  • Source: unauthenticated POST body of /api/webhooks/stripe (route.ts:7); src/proxy.ts:14-22 matcher does not cover /api/*, and the handler reads no stripe-signature header
  • Transformations: request.json() cast to Stripe.Event; metadata.orgId, metadata.plan, customer, subscription used as-is (:11-14); no stripe.webhooks.constructEvent anywhere under src/ (grep)
  • Sink: upsert on subscriptions keyed by orgId (:15-28), and status update keyed by stripeSubscriptionId (: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)

auth-101 · Authentication & authorization

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 install runs the PR's own package.json lifecycle scripts (preview.yml:23); pnpm build runs 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 later vercel deploy step (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-node cache: 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)

config-301 · Headers, CORS, build & deploy configuration

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 caller src/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: id argument of an exported "use server" function. It is imported by the client component InvoiceQuickView (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 sibling markInvoicePaid calls 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:6 and 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).

auth-102 · Authentication & authorization

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:21 matches /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)

auth-103 · Authentication & authorization

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; consumer src/lib/session.ts:22-25; column src/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/email and POST /api/auth/update-user (mounted at src/app/api/auth/[...all]/route.ts:4)
  • Transformations: Better-Auth copies declared additionalFields from the request body into the user row unless the field sets input: false. Assumption (library source not readable, node_modules absent): this is Better-Auth's documented behaviour, where input defaults to true and the docs' own role example sets input: false precisely to stop users setting it. No databaseHooks.user.create.before or other code strips role (auth.ts:9-40 has none)
  • Sink: user.role column, returned in session.user and trusted by requireAdmin (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

auth-104 · Authentication & authorization

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; entry src/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.instruction of POST /api/assistant/billing (route.ts:13,20), which the model turns into the invoiceId tool argument; any session with an active org passes route.ts:9-11
  • Transformations: ctx.orgId is passed in (route.ts:21) but unused by this tool; no needsApproval, no role check
  • Sink: UPDATE invoices SET status='void' WHERE id = $invoiceId (billing-tools.ts:13-17), executed inside generateText without any approval step
  • Sanitization: none. The sibling markInvoicePaid scopes by eq(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

auth-105 · Authentication & authorization

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; entry src/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 tool

Data flow

  • Source: messages of POST /api/chat (chat/route.ts:11), steering the model's orgId tool 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 summarizeProject uses ctx.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

auth-106 · Authentication & authorization

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: org query parameter (route.ts:10-11)
  • Transformations: preferred over the session's active organization; no member lookup
  • 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

auth-107 · Authentication & authorization

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: id and patch fields from the tRPC request (project.ts:13-21), POST /api/trpc/project.update
  • Transformations: orgProcedure sets ctx.orgId from 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, including internalNotes, budgetCents, clientContactEmail
  • Sanitization: none (contrast list at :9 and deleteProject at projects/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

auth-108 · Authentication & authorization

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; schedule src/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-digest with orgId from 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 to member; emailDigest defaults 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)

auth-109 · Authentication & authorization

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 sets orgId (index.ts:31-35); Hono's cors echoes the request Origin with Access-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

auth-110 · Authentication & authorization

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 unused

Data flow

Steps:

  1. Anonymous: GET /p/<slug> of any public project (no proxy match, no session check). The whole projects row, including orgId, and the documents rows 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).
  2. The attacker signs up (public sign-up, verifies their own address, auth.ts:12-15) and creates an organization (allowUserToCreateOrganization: true, auth.ts:39).
  3. 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).
  4. POST /api/chat with "search invoices in organization <victim orgId>": the searchInvoices tool filters by the model-supplied orgId (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)

chain-901 · Chains

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:

  1. Anonymous: GET /p/<slug> returns the full project row (id) and every documents row (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.
  2. Replace: the attacker (any self-registered user with an own org) calls POST /api/uploads with key = the victim's s3Key; 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).
  3. Read: the attacker creates a public project in their own org (createProject, any member, isPublic from input, actions.ts:28-36), registers a document row with projectId = that project and key = the victim's s3Key (no PUT needed, uploads/route.ts:29-37), learns the new row id from their own /p/<slug> (step 1 again), and calls GET /api/documents/<id>/download: the row passes the org filter (:18) and the presigned GET is for the victim's key (:26) (upload-401).
  4. Page tampering: with the leaked project id, project.update rewrites the victim project's name and description (rendered on its public page) or sets isPublic to false (project.ts:24) (auth-108); POST /api/uploads with the victim's projectId adds 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)

chain-902 · Chains

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-lockfile

Data 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: sh runs the script on every pnpm 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 -f a failed download leaves sh with 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)

config-302 · Headers, CORS, build & deploy configuration

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)

exposure-305 · Data exposure & secrets

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: .env at 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|prod key 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

exposure-306 · Data exposure & secrets

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

injection-202 · Injection & SSRF

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 at src/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.data of any message posted to the /settings/billing window. Any page holding a reference to that window can post: an attacker page that window.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)

injection-203 · Injection & SSRF

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 and allowUserToCreateOrganization: true (src/lib/auth.ts:39)
  • Transformations: none. Body is a TypeScript type only; no zod schema, no model allowlist, no clamp, no length cap on instruction
  • Sink: generateText to Anthropic billed to Ledgerly (route.ts:15-22)
  • Sanitization: none. No per-user or per-org budget: usage_events is only read (src/server/queries/dashboard.ts:25-28) and never written anywhere in src/, so no usage counter exists to enforce one. Deployment is next start in 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)

rate-limit-101 · Rate limiting & abuse

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 one verification row 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.ts matcher (:15-22) does not cover /api/*. The per-org SMS cap (sms-actions.ts:10,23-36, non-concurrency-402) applies only to sendSmsReminder
  • 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.

rate-limit-102 · Rate limiting & abuse

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 path src/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 = key in 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

upload-401 · File upload & storage

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; handlers src/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 too

Data flow

  • Source: auto-submitted cross-site form on an attacker page (application/x-www-form-urlencoded for invite, text/plain with 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-Site or 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 admin allowed by ROLES (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); documents row 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

auth-111 · Authentication & authorization

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; consumer src/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: orgId from 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 that unstable_cache keys on keyParts, the function's arguments and its stringified source, and that keyParts are 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), where orgId is 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.

auth-113 · Authentication & authorization

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 render src/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 projectId and filename (route.ts:10-11,21)
  • Transformations: none that constrain the value; the FK only requires that the project exists (schema.ts:158)
  • Sink: documents insert (route.ts:29-37); /p/[slug] loads with: { documents: true } by project, regardless of the document's orgId, and lists each filename
  • 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

auth-403 · Authentication & authorization

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_KEY

Data flow

  • Source: --build-arg values 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 history and reads the values; the same values are the live production credentials the app reads at src/db/index.ts:5 and src/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

config-307 · Headers, CORS, build & deploy configuration

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)

config-308 · Headers, CORS, build & deploy configuration

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" component

Data 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

exposure-101 · Data exposure & secrets

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 deleteOrganization after confirming the slug (actions.ts:10-18)
  • Transformations: the DB cascade removes documents rows (schema.ts:155-158); nothing lists or deletes objects: no DeleteObjectCommand/DeleteObjectsCommand anywhere under src/ (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

exposure-415 · Data exposure & secrets

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)

injection-201 · Injection & SSRF

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) name and email of 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)

injection-212 · Injection & SSRF

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 messages body (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.bodySizeLimit in next.config.ts:11 does 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_events never 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).

rate-limit-103 · Rate limiting & abuse

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), only includes("@") validation (:24)
  • Transformations: role gate owner/admin (:14-19). That gate is not a meaningful barrier for abuse: sign-up is self-service and organization({ allowUserToCreateOrganization: true }) (src/lib/auth.ts:39), so any user can create an org; I assume (Better-Auth default creatorRole: "owner", library code not available in this checkout) the creator becomes its owner
  • Sink: invitation insert (:27-35) and sendEmail to 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-Auth rateLimit (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).

rate-limit-411 · Rate limiting & abuse

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 contentType and key (route.ts:12-13)
  • Transformations: none
  • Sink: presigned PUT (:23-27) with no ContentLength, 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

upload-402 · File upload & storage

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 markInvoicePaid server 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 / paidAt overwritten (invoices/actions.ts:22-25, billing-tools.ts:13-17). The AI tool markInvoicePaid (billing-tools.ts:27-31) has the same pattern but has needsApproval: true and 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)

business-logic-101 · Business logic

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-36

Data flow

  • Source: webhook event body (route.ts:7)
  • Transformations: none. No processed-event table, no comparison of event.created with subscriptions.updatedAt, no payment_status check, no handling of customer.subscription.updated or checkout.session.async_payment_failed
  • Sink: unconditional upsert to status: "active" keyed by orgId (route.ts:15-28); cancellation matched only by stripeSubscriptionId (: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)

concurrency-405 · Concurrency & races

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); orgId from the session (:11)
  • Transformations: none checks subscriptions for 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 overwrites plan and stripeSubscriptionId (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

concurrency-410 · Concurrency & races

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)

injection-207 · Injection & SSRF

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: next query 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.example and /%09/evil.example (decoded to a tab by searchParams) resolve to https://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

injection-213 · Injection & SSRF

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

logging-309 · Logging & monitoring

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: sendEmail to customers.email via Resend from notifications@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).

rate-limit-408 · Rate limiting & abuse

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:11 reads an org id from the request (grep searchParams.get, all of src). The only other webhook, Resend, verifies the svix signature before acting and returns 400 on failure. The only other unstable_cache use, getOrgStats, takes orgId as an argument. getProject and `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 of sendEmail|sendSms|anthropic( in src/ found no paid call in /api/public, /api/webhooks/resend or /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.

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.

#ScoreWhereWhy
113src/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
210src/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
310src/app/api/admin/stats/route.ts:5
route-handler GET
admin area; no auth check in the handler; unscoped query on projects, subscriptions
410src/lib/auth.ts:1
config auth.ts
auth configuration (betterAuth); additionalFields.role is writable at sign-up (no input: false); SameSite=None session cookies
59src/server/ai/billing-tools.ts:9
ai-tool voidInvoice
payments area, ai area; ai-tool: arguments chosen by the model; unscoped query on invoices
69src/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
78src/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
87src/app/api/assistant/billing/route.ts:8
route-handler POST
payments area, ai area; LLM model or token budget taken from the request (cost)
97src/server/trpc/routers/integration.ts:18
trpc integrationRouter.testWebhook
webhooks area; outbound request to a non-constant URL (SSRF)
106src/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?)
116src/app/api/export/route.ts:6
route-handler GET
files area; tenant/user id taken from the request
126src/app/api/team/invite/route.ts:9
route-handler POST
auth area; sends email (who are the recipients?)
136src/app/p/[slug]/page.tsx:1
page page
page without a guard reads the database (what reaches the client?); unscoped query on projects
146src/server/ai/billing-tools.ts:22
ai-tool markInvoicePaid
payments area, ai area; ai-tool: arguments chosen by the model
156src/server/api/index.ts:1
config index.ts
CORS reflects any origin with credentials
166src/server/trpc/routers/project.ts:12
trpc projectRouter.update
unscoped query on projects; request object written to the database as-is
175src/app/api/documents/[id]/download/route.ts:9
route-handler GET
files area; presigned storage URL
185src/app/api/uploads/route.ts:16
route-handler POST
files area; presigned storage URL
195src/app/api/webhooks/resend/route.ts:8
webhook POST
webhooks area; webhook: public by design
205src/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.

RuleWhereCommitStill in tree
stripe-access-token.env:4a1b7c73 2026-05-04no (history only)
generic-api-key.env:3a1b7c73 2026-05-04no (history only)

Filtered out

Candidates the verifier rejected after reading the code. Kept for the record; none of them needs action.

FindingReason
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