Skip to content

security: harden AI extraction, CSV export, and the migration script - #560

Merged
BastiOfBerlin merged 1 commit into
spliit-app:mainfrom
BastiOfBerlin:up-2-security
Aug 13, 2026
Merged

security: harden AI extraction, CSV export, and the migration script#560
BastiOfBerlin merged 1 commit into
spliit-app:mainfrom
BastiOfBerlin:up-2-security

Conversation

@BastiOfBerlin

Copy link
Copy Markdown
Collaborator

security: harden AI extraction, CSV export, and the migration script

Part of the series in #553. Five independent fixes, none of which changes
behaviour for a correctly-configured instance. Grouped into one PR because they
are all small and all fall out of the same audit; happy to split any of them out
if you'd rather review them separately.

Nothing here is being publicly disclosed anywhere; if you'd prefer to take any
of it privately instead, say so and I'll close this and re-send.

1. AI extraction was reachable with the feature flags off

extractExpenseInformationFromImage and extractCategoryFromTitle are server
actions, so each has its own callable endpoint. ENABLE_RECEIPT_EXTRACT and
ENABLE_CATEGORY_EXTRACT only ever hid the buttons that call them.

An instance that deliberately leaves the AI features off — because the operator
does not want to pay for them, or has no key configured — still had them
callable by anyone who can reach the app, spending the operator's OpenAI credit.
Both actions now check getRuntimeFeatureFlags() before doing any work.

2. Receipt extraction forwarded any caller-supplied URL to OpenAI

The image URL is a plain string parameter of the action. Since the action is
directly callable (see above), an arbitrary URL could be passed and OpenAI would
fetch it: an SSRF primitive using someone else's egress, plus unmetered API
spend on images the app never uploaded.

New isAllowedUploadUrl restricts extraction to http(s) URLs on the app's own
configured S3 host. It derives that host exactly the way next.config.mjs
derives the image remotePatterns — custom S3_UPLOAD_ENDPOINT if set,
otherwise the bucket.s3.region.amazonaws.com form — so the trusted-host set
stays consistent between the two. With no S3 configured the allow-list is empty
and nothing passes, which is the right answer: without uploads there are no
receipts to extract.

Unit-tested, including suffix look-alikes (minio.example.com.evil.com),
non-http(s) schemes, port insensitivity, and the unconfigured case.

3. The receipt action could return NaN

The model is asked for a plain number but nothing guarantees it obliges;
Number() on anything else yields NaN, which flowed into the expense form. It
now reports null, which the dialog already renders as "unknown" — the existing
truthiness check on receiptInfo.amount handles both, so this is a
tightening rather than a behaviour change.

4. CSV formula injection (CWE-1236)

Expense titles, category names and participant names are user-controlled and
were written to the export verbatim. A cell beginning with =, +, -, @,
tab or CR is executed as a formula by Excel, LibreOffice and Google Sheets when
the file is opened.

So a group member can title an expense =HYPERLINK(...) or a WEBSERVICE call
and have it execute in another member's spreadsheet — the export is the delivery
mechanism, and the person who opens it is not the person who wrote the text.
Such cells are now prefixed with a single quote, the standard neutralisation.

5. The CSV route created its own PrismaClient

Every other data path uses the @/lib/prisma singleton; this route constructed a
second client at module scope, opening a second connection pool. On a
long-running container that is a slow leak against the database's connection
limit. Now uses the singleton, matching the JSON export route right next to it.

Two smaller items in the same spirit

  • src/scripts/migrate.ts disabled TLS verification process-wide. It set
    NODE_TLS_REJECT_UNAUTHORIZED='0' at module scope, which turns off
    certificate validation for every outbound TLS connection the process makes,
    not just the legacy database one it was meant for. The script now connects
    with validated TLS; a self-signed legacy database can supply its CA through a
    new OLD_POSTGRES_CA_CERT env var. (This is the one-off v1→v2 import script,
    so the blast radius is small, but it is also the script most likely to be run
    with production credentials in the environment.)
  • ci.yml had no permissions: block, so it ran with the repository's
    default token scopes. The checks only read the repository. cd.yml already
    declares per-job permissions, so this just brings CI in line.

Verification

npm ci --ignore-scripts, npx prisma generate, check-types, lint,
check-formatting all clean against current main (lint: 16 warnings, all
pre-existing). uploaded-image-url.test.ts adds 14 cases.

Not included

The regex-injection fix in add-group-by-url-button.tsx was part of the same
audit, but it already went upstream with #558 — it was needed there to keep the
QR scanner's relative-URL support from accepting any host.

Five independent hardening fixes, none of which changes behaviour for a
correctly-configured instance.

**AI extraction was reachable with the feature flags off.**
extractExpenseInformationFromImage and extractCategoryFromTitle are server
actions, so they have their own endpoints; ENABLE_RECEIPT_EXTRACT and
ENABLE_CATEGORY_EXTRACT only ever hid the buttons that call them. An instance
that deliberately leaves the AI features off still had them callable, spending
the operator's OpenAI credit. Both actions now check getRuntimeFeatureFlags()
before doing any work.

**Receipt extraction forwarded any caller-supplied URL to OpenAI.** The image
URL is a plain string parameter, so an arbitrary URL could be passed to the
action and OpenAI would fetch it — an SSRF primitive with someone else's egress,
plus unmetered API spend. A new isAllowedUploadUrl restricts extraction to
http(s) URLs on the app's own configured S3 host, deriving that host exactly the
way next.config.mjs derives the image remotePatterns, so the two stay
consistent. Covered by unit tests including subdomain look-alikes, non-http(s)
schemes and the unconfigured case, which allows nothing.

**The receipt action could return NaN.** The model is asked for a plain number
but nothing guarantees it obliges; Number() on anything else yields NaN, which
then flowed into the expense form. It now reports null, which the dialog already
renders as "unknown".

**CSV formula injection (CWE-1236).** Expense titles, category names and
participant names are user-controlled and were written to the export verbatim. A
cell beginning with =, +, -, @, tab or CR is executed as a formula by Excel,
LibreOffice and Sheets when the file is opened, so a group member could put a
formula in an expense title and have it run in another member's spreadsheet.
Such cells are now prefixed with a single quote.

**The CSV route created its own PrismaClient.** Every other data path uses the
@/lib/prisma singleton; this route constructed a second client at module scope,
which opens a second connection pool. Now uses the singleton, matching the JSON
export route next to it.

Two smaller items in the same spirit:

- src/scripts/migrate.ts set NODE_TLS_REJECT_UNAUTHORIZED='0' at module scope,
  disabling certificate validation for the whole process, not just the legacy
  database connection. The script connects with validated TLS instead; a
  self-signed legacy database can supply its CA via OLD_POSTGRES_CA_CERT.
- ci.yml had no permissions block, so it ran with the repository's default token
  scopes. The checks only read the repository, so contents: read is enough.
  (cd.yml already declares per-job permissions.)
@BastiOfBerlin
BastiOfBerlin merged commit 54207f4 into spliit-app:main Aug 13, 2026
1 check passed
@BastiOfBerlin
BastiOfBerlin deleted the up-2-security branch August 13, 2026 20:46
BastiOfBerlin pushed a commit to BastiOfBerlin/spliit that referenced this pull request Aug 13, 2026
Brings in spliit-app#559, spliit-app#560 and spliit-app#562 — the fork's own Wave 1 work coming back
through upstream squashes — plus spliit-app#561 (Open Collective).

Conflict resolutions, all of them our code meeting itself:

- ci.yml: kept the fork's actions/checkout@v6 and setup-node@v6 pins and took
  upstream's new comment on the permissions block.
- balances.ts: took upstream's Map.forEach body over the fork's for...of. They
  are equivalent; upstream needs forEach because tsconfig still targets es5,
  and matching it keeps the two copies from drifting. Restored the Prisma 7
  import.
- shares.test.ts: same reasoning — took upstream's Array.from over spreading
  map.values(), restored the Prisma 7 import.
- shares.ts, totals.ts, totals.test.ts, csv/route.ts and
  create-from-receipt-button-actions.ts: kept the fork's copies, which are
  strict supersets (the stats work, the configurable-OpenAI work, and
  content-disposition v2's named export).
@BastiOfBerlin BastiOfBerlin mentioned this pull request Aug 13, 2026
BastiOfBerlin added a commit that referenced this pull request Aug 13, 2026
# chore: CI and lint baseline

Part of the series in #553. Four small independent commits, no source
changes —
this is the gate that everything after it in the series gets measured
against,
which is why I'd like it in early.

## 1. CI runs the tests

`main` has **7 test suites and 101 tests**, and CI runs none of them.
`npm test`
passes today; nothing stops that changing. This adds one step after the
existing
checks.

That number is recent — #548 brought two suites, and #559/#560/#562
brought five
more — so this isn't a long-standing gap so much as one that just
opened. The
rest of #553 adds more, and I'd rather they land against a gate that
exists.

## 2. TypeScript target `es5` → `ES2017`

`noEmit` is set, so `tsc` never emits anything and SWC does the actual
transpilation. The target here decides **only what type-checks**, and at
`es5`
that means TS2802 on any iteration of a `Map` or `Set`:

```
error TS2802: Type 'Map<string, number>' can only be iterated through when
using the '--downlevelIteration' flag or with a '--target' of 'es2015' or
higher.
```

Both `for (const [k, v] of map)` and `[...map.values()]` are rejected. I
hit
this porting #562 and wrote around it — `Map.forEach` and `Array.from`
where
iteration would read better — and flagged there that I'd propose the
bump
separately rather than smuggle it in. This is that.

It also has to happen eventually regardless: TypeScript 6 deprecates the
`es5`
target and TypeScript 7 removes it.

**Not included:** the `"types": ["node", "jest"]` restriction that goes
with
this in my fork. That one is specifically about TypeScript 6 changing
how
ambient `@types` globals are resolved, so it belongs with the TypeScript
bump,
not here.

I have deliberately **not** gone back and rewritten the
`forEach`/`Array.from`
code now that the constraint is gone — it reads fine, and churning it
would make
this PR look bigger than it is.

## 3. Drop the legacy `.eslintrc.json`

`eslint.config.mjs` is what ESLint 9 loads. The `.eslintrc.json` next to
it has
been inert since the flat config landed, and the only thing keeping it
around
can do is attract an edit that silently has no effect.

Lint output is unchanged: 16 warnings, 0 errors, before and after.

## 4. Dependabot

New `.github/dependabot.yml`: weekly npm, Docker, and GitHub Actions.

**Two choices here are mine and easy to strip if you disagree**, so I
want to
flag them rather than bury them:

- **Minor and patch updates are grouped** into one PR per ecosystem;
majors stay
separate. Ungrouped weekly npm updates produced nine separate PRs in my
fork
over a few months, all of which I ended up reviewing as one batch
anyway.
- **`github-actions` is included.** Nothing was watching the workflow
pins,
which is how `checkout` and `setup-node` ended up on v3 — the Node 16
runner.
This PR moves them to v6 by hand; Dependabot is what stops it recurring.

If you'd rather not have Dependabot at all, commit 4 drops cleanly on
its own
and the other three stand without it.

## Also in this PR

The `setup-node` step gets a comment recording *why* it pins Node 24:
npm 10
refuses to read lockfiles written by npm 11, which is what Dependabot
produces.
That combination is a confusing failure if you meet it without the note.

## What is not here

`npm run generate-currency-data` is listed under this item in #553 as
broken. It
isn't — I re-checked, and `src/scripts/generateCurrencyData.ts` is
byte-identical
between my fork and `main`. The breakage is TS5011 from my fork's
TypeScript 6,
so the fix belongs with that upgrade. I've corrected the tracking issue.

## Verification

Against `67a8f8d`: `npm ci --ignore-scripts`, `npx prisma generate`,
`check-types`, `lint` (16 warnings / 0 errors, all pre-existing),
`check-formatting`, `npm test` — 7 suites, 101 tests, all passing. The
TS2802
claim above was verified by running `tsc` at both targets over a
four-line
probe.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants