security: harden AI extraction, CSV export, and the migration script - #560
Merged
Merged
Conversation
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
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).
Merged
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>
24 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
extractExpenseInformationFromImageandextractCategoryFromTitleare serveractions, so each has its own callable endpoint.
ENABLE_RECEIPT_EXTRACTandENABLE_CATEGORY_EXTRACTonly 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
isAllowedUploadUrlrestricts extraction tohttp(s)URLs on the app's ownconfigured S3 host. It derives that host exactly the way
next.config.mjsderives the image
remotePatterns— customS3_UPLOAD_ENDPOINTif set,otherwise the
bucket.s3.region.amazonaws.comform — so the trusted-host setstays 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
NaNThe model is asked for a plain number but nothing guarantees it obliges;
Number()on anything else yieldsNaN, which flowed into the expense form. Itnow reports
null, which the dialog already renders as "unknown" — the existingtruthiness check on
receiptInfo.amounthandles both, so this is atightening 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 aWEBSERVICEcalland 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
PrismaClientEvery other data path uses the
@/lib/prismasingleton; this route constructed asecond 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.tsdisabled TLS verification process-wide. It setNODE_TLS_REJECT_UNAUTHORIZED='0'at module scope, which turns offcertificate 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_CERTenv 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.ymlhad nopermissions:block, so it ran with the repository'sdefault token scopes. The checks only read the repository.
cd.ymlalreadydeclares per-job permissions, so this just brings CI in line.
Verification
npm ci --ignore-scripts,npx prisma generate,check-types,lint,check-formattingall clean against currentmain(lint: 16 warnings, allpre-existing).
uploaded-image-url.test.tsadds 14 cases.Not included
The regex-injection fix in
add-group-by-url-button.tsxwas part of the sameaudit, but it already went upstream with #558 — it was needed there to keep the
QR scanner's relative-URL support from accepting any host.