Skip to content

Strip Excel-illegal control characters from inventory report cells (PP-5246) - #3768

Merged
dbernstein merged 4 commits into
mainfrom
bugfix/inventory-report-illegal-char
Sep 28, 2026
Merged

dbernstein merged 4 commits into
mainfrom
bugfix/inventory-report-illegal-char

Conversation

@dbernstein

@dbernstein dbernstein commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

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_reports already documents as a guarantee. It uses openpyxl's own ILLEGAL_CHARACTERS_RE, the same regex its check_string validation 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 raises IllegalCharacterError rather than writing it:

IllegalCharacterError: <long contributor list, one name containing \x1f> cannot be used in worksheets.

(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_rows and failed the whole generate_inventory_and_hold_reports Celery 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.py cover the Excel and CSV paths with control characters in the data, including an assertion that tab and newline survive sanitization.

  • All 20 tests in tests/manager/celery/tasks/test_generate_inventory_and_hold_reports.py pass.
  • mypy is clean on the changed module.
  • pre-commit passes on both changed files.

Note on how the tests were run: tox -e py312-docker currently 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 throwaway postgres:16 container with the PALACE_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

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

🤖 Generated with Claude Code

@dbernstein dbernstein added the bug Something isn't working label Sep 24, 2026
@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @dbernstein's task in 1m 3s —— View job


Summary

No issues found; this looks good to merge. Every path in _cell_value that can return an untrusted string now goes through _sanitize_cell_value: the stringified-column branch and the new plain-str branch. The branches that skip it only return strings the code builds itself: enum .values, and strftime output for timezone-aware datetimes. Other value types (int, Decimal, bool, naive datetime) never reach openpyxl's check_string, so they can't raise IllegalCharacterError. _cell_value is only called by _write_csv_rows and _write_excel_rows, so both outputs get the same sanitization. Reusing openpyxl's own ILLEGAL_CHARACTERS_RE means the sanitizer can't drift from the check openpyxl applies when writing. The enum feedback from @tdilauro is handled in 017a88f. The formula-injection thread was already settled as pre-existing and is tracked in #3769, so I haven't raised it again. The new tests cover the stringified-column and plain-string branches for both writers, plus preservation of tab and newline.

@dbernstein dbernstein changed the title Strip Excel-illegal control characters from inventory report cells Strip Excel-illegal control characters from inventory report cells (PP-5246) Sep 24, 2026
@greptile-apps

greptile-apps Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Strips control characters from inventory report output.

The PR appears safe to merge based on the current changes and resolved previous thread.

Summary

The PR strips Excel-illegal control characters at the shared report-cell conversion point so CSV and XLSX receive the same sanitized values.

  • Adds Excel and CSV tests for control-character removal and preservation of legal whitespace.
  • The changes since the previous review introduce no new changes to this PR’s files.

Reviews (7) · Last reviewed commit: "Don't sanitize enum values"

@dbernstein
dbernstein force-pushed the bugfix/inventory-report-illegal-char branch from f488d20 to dc3e15d Compare September 25, 2026 02:22
@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.72%. Comparing base (a87d4c7) to head (017a88f).
⚠️ Report is 7 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dbernstein
dbernstein force-pushed the bugfix/inventory-report-illegal-char branch from 2ddbcc7 to 7cf4de7 Compare September 25, 2026 16:14
@dbernstein
dbernstein requested a review from a team September 25, 2026 16:15

@tdilauro tdilauro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! 🎸🤘🏽

It seems like this PR should not make it into a release without #3769 (see the thread from greptile).

One minor comment below.

return ""
if isinstance(value, enum.Enum):
return str(value.value)
return _sanitize_cell_value(str(value.value))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to sanitize our own enum values?

dbernstein and others added 4 commits September 28, 2026 15:55
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>
@dbernstein
dbernstein force-pushed the bugfix/inventory-report-illegal-char branch from edf3648 to 017a88f Compare September 28, 2026 22:55
@dbernstein
dbernstein enabled auto-merge (squash) September 28, 2026 22:55
@dbernstein
dbernstein merged commit 54de833 into main Sep 28, 2026
25 checks passed
@dbernstein
dbernstein deleted the bugfix/inventory-report-illegal-char branch September 28, 2026 23:05
dbernstein added a commit that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants