Strip Excel-illegal control characters from inventory report cells (PP-5246) - #3768
Conversation
|
Claude finished @dbernstein's task in 1m 3s —— View job SummaryNo issues found; this looks good to merge. Every path in |
|
f488d20 to
dc3e15d
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3768 +/- ##
==========================================
- Coverage 93.72% 93.72% -0.01%
==========================================
Files 510 510
Lines 46510 46515 +5
Branches 6313 6314 +1
==========================================
+ Hits 43590 43594 +4
- Misses 1886 1887 +1
Partials 1034 1034 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
2ddbcc7 to
7cf4de7
Compare
| return "" | ||
| if isinstance(value, enum.Enum): | ||
| return str(value.value) | ||
| return _sanitize_cell_value(str(value.value)) |
There was a problem hiding this comment.
Do we need to sanitize our own enum values?
Imported metadata can contain stray control characters -- in this case a U+001F unit separator embedded in a contributor name. openpyxl refuses to write those into a worksheet and raises IllegalCharacterError, which failed the entire inventory report task. Sanitize string values in _cell_value, the shared conversion point for the CSV and Excel writers, so both outputs stay identical. The stripping uses openpyxl's own ILLEGAL_CHARACTERS_RE -- the same regex its cell validation checks against -- so anything that survives is guaranteed writable and the two can't drift apart. Tab, newline and carriage return are not matched by that regex and are preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test data was lifted verbatim from the production traceback, which put a real person's name in the codebase. Replace it with a generic placeholder; the control character being exercised is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_cell_value sanitizes along two paths, and only the plain-string one was asserted. The stringified-column path (identifier/isbn/target_age, which come from imported feeds just like author) was executed by existing tests but never checked, so a regression there would have gone unnoticed -- line coverage was satisfied while the behavior was not. Put a control character in a stringified column in both the CSV and Excel tests and assert it is removed. Verified by mutation: dropping the sanitize call from that branch now fails both tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per review: enum values reaching the reports are LicensePoolStatus and LicenseStatus, both declared as literals in our own source. They can't carry illegal characters, and LicenseStatus.get() maps any unrecognized feed value onto a known member, so feed data never becomes an arbitrary enum value. Sanitizing them implied a trust boundary that isn't there, and the branch couldn't be meaningfully tested without inventing a fake enum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
edf3648 to
017a88f
Compare
…P-5253) (#3769) ## Description Stops attacker-influenced report values from being written as live spreadsheet formulas. Follows #3768 (PP-5246), now merged; this branch is rebased onto `main` and 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`: - **xlsx** — pin `str` values to the text data type (`cell.data_type = "s"`). No data is altered. Worth calling out: the `number_format = FORMAT_TEXT` that the stringified columns already carry is **presentation only and does not prevent formula evaluation** — `identifier`, `isbn` and `target_age` were unprotected despite appearing otherwise. - **csv** — prefix the conventional apostrophe. CSV carries no cell-type metadata, so neutralizing a formula there necessarily changes the value. **The apostrophe is visible in the CSV**: Excel only hides one that was typed into a cell, so on import it is part of the value, and any script reading the file gets it too. Blocking the formula is worth that cost, but the data really does change. The trigger set is wider than xlsx's (`= + - @` and leading tab/CR) because Excel's *CSV importer* acts on all of them, while its xlsx parser only treats `=` as a formula. Only `str` values 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_value` and `_escape_csv_formula` now 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 on `main` today. Splitting the hardening out keeps the crash fix reviewable on its own. Verified against openpyxl directly: ``` '=HYPERLINK(...)' , no number format -> data_type='f' (formula) '=HYPERLINK(...)' , FORMAT_TEXT -> data_type='f' (still a formula) '=HYPERLINK(...)' , data_type='s' -> data_type='s' (text, value intact) ``` **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 — `Patron` appears in the queries solely inside `func.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 `.xlsx` cannot 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`: - Excel: a `=HYPERLINK(...)` payload is stored with `data_type == "s"` and its text preserved verbatim. - CSV: parametrized over all six escaped prefixes (`= + - @` plus tab and carriage return) with two negative cases (`5-8`, a plain name) confirming ordinary values are left alone. - CSV: a genuine negative number (`-5`, `-5.5`) stays an unquoted number while the string `"-5"` is escaped. Asserted against the raw CSV text, since `csv.reader` yields 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. `mypy` clean; `pre-commit` clean. `generate_csv_report` / `generate_excel_report` have no callers outside this task module, so the blast radius is limited to these reports. Same local-environment caveat as #3768: `tox -e py312-docker` fails here building the MinIO image, so I ran pytest against a throwaway `postgres:16` container with the `PALACE_TEST_DATABASE_URL_*` variables tox would set. CI will exercise the full environment. ## Checklist - [x] I have updated the documentation accordingly. - [x] All new and existing tests passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Description
Strips characters that Excel disallows in worksheet cells from report values before they are written.
The stripping happens in
_cell_value, the shared conversion point used by both the CSV and Excel writers, so the two outputs stay identical — which_write_reportsalready documents as a guarantee. It uses openpyxl's ownILLEGAL_CHARACTERS_RE, the same regex itscheck_stringvalidation checks against, so anything that survives sanitization is guaranteed writable and the two can't drift apart as openpyxl changes. Tab, newline and carriage return are not matched by that regex, so legitimate whitespace is preserved.Motivation and Context
Inventory report generation was failing outright for at least one library. Imported metadata contained a stray
U+001F(unit separator) control character embedded in a contributor name, and openpyxl raisesIllegalCharacterErrorrather than writing it:(The affected value is a contributor list; the names are omitted here, and the tests use a generic placeholder rather than the real data.)
That propagated out of
_write_excel_rowsand failed the wholegenerate_inventory_and_hold_reportsCelery task, so a single bad character in one row meant no report at all.How Has This Been Tested?
Two new tests in
tests/manager/celery/tasks/test_generate_inventory_and_hold_reports.pycover the Excel and CSV paths with control characters in the data, including an assertion that tab and newline survive sanitization.tests/manager/celery/tasks/test_generate_inventory_and_hold_reports.pypass.mypyis clean on the changed module.pre-commitpasses on both changed files.Note on how the tests were run:
tox -e py312-dockercurrently fails in my local environment while building the MinIO image (unauthorized: access to the requested resource is not authorized), which is the known broken-images issue and unrelated to this change. I ran pytest directly against a throwawaypostgres:16container with thePALACE_TEST_DATABASE_URL_*variables tox would have set. These tests only need Postgres, so coverage is equivalent, but the full docker test environment was not exercised locally — CI will cover that.Checklist
🤖 Generated with Claude Code