Don't let imported metadata become spreadsheet formulas in reports (PP-5253) - #3769
Conversation
|
Claude finished @dbernstein's task in 1m 50s —— View job SummaryThe fix is correct, and the earlier review feedback has been addressed. openpyxl's write-only writer emits a cell with DetailsMinor:
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3769 +/- ##
==========================================
- Coverage 93.75% 93.75% -0.01%
==========================================
Files 510 510
Lines 46470 46477 +7
Branches 6310 6312 +2
==========================================
+ Hits 43568 43574 +6
- Misses 1874 1875 +1
Partials 1028 1028 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
2ddbcc7 to
7cf4de7
Compare
|
I gree with both of these comments from Claude.
|
|
|
| pytest.param("+1+1", "'+1+1", id="plus"), | ||
| pytest.param("-1+1", "'-1+1", id="minus"), | ||
| pytest.param("@SUM(A1)", "'@SUM(A1)", id="at"), | ||
| pytest.param("5-8", "5-8", id="target-age-untouched"), | ||
| pytest.param("Jane Doe", "Jane Doe", id="plain-untouched"), |
There was a problem hiding this comment.
Agree with greptile here. And should probably test a regular old negative number here, too (e.g., Literal(5)).
edf3648 to
017a88f
Compare
|
I will make the set of changes suggested by Claude, Greptile and you @tdilauro (as well as resolve the conflicts). Thanks for the review. |
819620c to
e822e87
Compare
|
@tdilauro : I made the changes suggested by Claude, Greptile and yourself (as well as resolve the conflicts). This one should be good now. |
e822e87 to
379d858
Compare
Report values come from third-party OPDS/ODL feeds, so they are attacker-influenced. A title or contributor name beginning with "=" is written into the report as a live formula and evaluated when library staff open it -- CSV injection, reachable through the normal metadata import path. This is pre-existing behavior on main, not a regression: openpyxl stores any "="-prefixed string as a formula cell regardless of how it got there. The two formats need different mitigations: - xlsx: pin string values to the text data type. Note that the number_format the stringified columns already carry is presentation only and does NOT prevent formula evaluation -- identifier/isbn/target_age were unprotected despite looking otherwise. - csv: prefix the conventional apostrophe. CSV has no cell-type metadata, so neutralizing a formula there means changing the value; the spreadsheet consumes the apostrophe and displays the original text. The escape set is wider than xlsx's because Excel's CSV importer also acts on + - @ and leading whitespace controls. The outputs therefore differ by that apostrophe in the CSV, which is unavoidable in a format without types. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Docstrings (Claude, Greptile): _write_reports and _cell_value still promised the CSV and Excel outputs were identical, which the formula escaping made false. Both now say what actually differs and why not to "fix" it. _escape_csv_formula also claimed the spreadsheet consumes the apostrophe and shows the original text. That is only true of an apostrophe typed into a cell -- on CSV import it is part of the value, so staff see '=HYPERLINK(...) and scripts reading the CSV get it too. Say so plainly; the CSV data really does change. Tests (Greptile): cover the tab and carriage-return prefixes, which were in the escape set but untested. Tests (Tim): a genuine negative number must not be mistaken for a formula. Asserted against the raw CSV text, because csv.reader yields strings either way and would hide the difference between an unquoted number and a quoted string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379d858 to
5d32869
Compare
Description
Stops attacker-influenced report values from being written as live spreadsheet formulas.
Follows #3768 (PP-5246), now merged; this branch is rebased onto
mainand contains only its own two commits.The two output formats need different mitigations, so the escaping lives in the writers rather than the shared
_cell_value:strvalues to the text data type (cell.data_type = "s"). No data is altered. Worth calling out: thenumber_format = FORMAT_TEXTthat the stringified columns already carry is presentation only and does not prevent formula evaluation —identifier,isbnandtarget_agewere unprotected despite appearing otherwise.= + - @and leading tab/CR) because Excel's CSV importer acts on all of them, while its xlsx parser only treats=as a formula. Onlystrvalues are touched, so genuine numbers keep their type and negative numbers are unaffected.This means the CSV and Excel outputs now differ by that one apostrophe — a partial walk-back of the "identical outputs" guarantee previously documented on
_write_reports. That is unavoidable in a format without types; the docstrings on_write_reports,_cell_valueand_escape_csv_formulanow all say so explicitly, so nobody later "fixes" the difference away or writes a test comparing the two files cell by cell.Motivation and Context
Raised by Greptile on #3768. It is pre-existing behavior on
main, not a regression from that PR — openpyxl stores any=-prefixed string as a formula cell however it arrived. #3768 only changed the narrow<control char>=…case, which previously crashed the report outright; a payload with no control-character prefix already works onmaintoday. Splitting the hardening out keeps the crash fix reviewable on its own.Verified against openpyxl directly:
Risk in context. The reachable path is a compromised or malicious content vendor injecting into an imported feed; it is not reachable by patrons or anonymous users. The reports contain bibliographic metadata and aggregate counts only —
Patronappears in the queries solely insidefunc.count(), so there is no patron PII to exfiltrate. Exploitation further requires staff to open the file in desktop Excel and click through Protected View, and modern Excel disables DDE by default while.xlsxcannot carry macros. So this is hardening against a low-likelihood vector rather than an urgent fix — but it is cheap and the phishing variant (=HYPERLINK()rendering an attacker-controlled link inside a trusted internal report) needs no DDE at all.How Has This Been Tested?
New tests in
tests/manager/celery/tasks/test_generate_inventory_and_hold_reports.py:=HYPERLINK(...)payload is stored withdata_type == "s"and its text preserved verbatim.= + - @plus tab and carriage return) with two negative cases (5-8, a plain name) confirming ordinary values are left alone.-5,-5.5) stays an unquoted number while the string"-5"is escaped. Asserted against the raw CSV text, sincecsv.readeryields strings either way and would hide the difference.Review feedback from Claude, Greptile and @tdilauro is addressed in the second commit.
All 30 tests in the file pass.
mypyclean;pre-commitclean.generate_csv_report/generate_excel_reporthave no callers outside this task module, so the blast radius is limited to these reports.Same local-environment caveat as #3768:
tox -e py312-dockerfails here building the MinIO image, so I ran pytest against a throwawaypostgres:16container with thePALACE_TEST_DATABASE_URL_*variables tox would set. CI will exercise the full environment.Checklist
🤖 Generated with Claude Code