Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
15 KiB
B05 — Spreadsheet-safe CSV fields
Implementation date: 2026-09-09. Local implementation and verification only; not deployed. B01–B04 application behavior is unchanged. B06 has not been started. No schema, dependency, import-system or serialization-framework change.
Original behavior and root cause
F05 remains a P2 export-safety finding, not server-side code execution. The historical validation, observations and probes.mjs are preserved. Before editing application code, a new permanent controller regression exported a synthetic job with title =1+1. It failed as expected: the independently parsed cell was =1+1, rather than the required '=1+1. The actual before CSV was then opened read-only in a separate, hidden local Excel 16.0 instance: D2 had HasFormula=true and value 2. Only harmless arithmetic was used; no links, commands or exfiltration payloads were exercised.
ExportController.Esc correctly escaped commas, quotes and newlines, but CSV quoting does not force spreadsheet text interpretation. All strings were passed through syntax escaping without a spreadsheet policy. The fix therefore belongs at export, before the existing syntax escaping; it does not sanitize storage or change input validation.
The same Excel check exposed an encoding issue: Ærlig Økonomi Ås opened as Ærlig Økonomi Ã…s in the BOM-less UTF-8 file. This is the concrete reason for adding a UTF-8 BOM. The frontend's previous Blob.text() followed by a new text Blob would strip that BOM, so the download helper now preserves the returned Blob bytes. JSON downloads still preserve their original bytes.
Inventory and policy
There is one CSV export path: authenticated GET /api/export/jobs?format=csv, case-insensitive format selection and existing includeDeleted behavior. Settings → General → Export CSV calls it through ImportExportJobs. Account export archives contain JSON, not another CSV writer. Other comma-list parsing and the frontend's separate CSV-to-import-JSON converter are not CSV exports and are unchanged. No CSV writing library was present; the existing, correct Esc implementation is retained and verified with an independent framework CSV parser.
| Columns | Classification | Output policy |
|---|---|---|
| Company, CompanyLocation, CompanySource, JobTitle, Status, Location, Salary, SalaryCurrency, SalaryPeriod, NextAction, JobUrl, Notes, CoverLetterText | 13 text fields, including imported/user-influenced strings and free-text salary | CsvCellSafety.SpreadsheetText, then existing CSV escaping |
| SalaryMin, SalaryMax | Nullable decimal | Existing invariant numeric formatting; no apostrophe, including negative numbers |
| DateApplied, FollowUpAt | Nullable DateTime | Existing round-trip o formatting; no apostrophe |
The shared helper examines the first Unicode scalar after any leading Unicode whitespace, control or Format-category characters. If that scalar is ASCII =, +, - or @, prepend one ASCII apostrophe at the absolute start of the original value. Nothing is removed, trimmed or rewritten. Leading tab, CR/LF, NUL, vertical tab, non-breaking space, zero-width space, BOM and supplementary-plane format characters are covered by permanent tests. Existing apostrophe-prefixed text is unchanged, even on a second pass. Null becomes the existing empty CSV cell. Ordinary text, including Unicode and embedded delimiters, stays unchanged before syntax escaping.
This is an explicit spreadsheet-text convention, not a claim to prevent every spreadsheet's undocumented normalization or a user's later transformation. Excel's actual direct-open behavior is recorded below. For LibreOffice-compatible import, UTF-8/comma/standard double-quote parsing leaves the leading apostrophe as text rather than an initial formula marker; appearance and apostrophe handling can vary with import options. LibreOffice was not installed, so no LibreOffice runtime verification is claimed. Repository instructions prohibit external services without authorization; no online compatibility lookup or dependency download was used.
| Input / field | Before parsed CSV cell | After parsed CSV cell |
|---|---|---|
Text =1+1 |
=1+1 (Excel evaluates to 2) |
'=1+1 (Excel literal text) |
Text +SUM(A1:A2) |
+SUM(A1:A2) |
'+SUM(A1:A2) |
Text -2+3 |
-2+3 |
'-2+3 |
Text @SUM(A1:A2) |
@SUM(A1:A2) |
'@SUM(A1:A2) |
Text space + tab + =1+1 |
\t=1+1 |
' \t=1+1 |
Text =SUM(1,2) |
=SUM(1,2) |
'=SUM(1,2); serialized CSV is "'=SUM(1,2)" |
| Decimal SalaryMin | -750.50 |
-750.50 (Excel numeric -750.5) |
| DateApplied | 2026-09-09T12:30:00.0000000Z |
Identical existing format |
Escapes in the table describe controls; the actual sample files contain the real characters. The response now uses text/csv; charset=utf-8, starts with bytes EF BB BF, and retains ASP.NET's attachment disposition and a server-generated date-only filename. No filename is derived from user text. The browser retains its generated safe filename and now downloads identical response bytes.
Spreadsheet evidence and compatibility trade-offs
Before Excel observation, after observations for all 17 columns, after original fixture, typed/adversarial fixture and local probe are retained. The probe opens explicit local synthetic files read-only with updates, events and macros disabled, then closes its own workbooks and Excel instance without saving.
Excel 16.0 after the fix: all 17 cells in both fixtures have HasFormula=false. The original title is the literal '=1+1; Norwegian company text is intact. The mixed fixture covers all four markers across columns, leading space/tab/CRLF/zero-width space, comma-containing expressions, multiline Norwegian text and negative free-text salary. Decimal cells remain numeric -750.5 / 1200.75. Dates retain their existing ISO round-trip strings; B05 does not claim to convert those strings into native spreadsheet date serials.
The apostrophe is visible in this Excel direct-open mode. It is deliberately retained rather than silently deleting user text. CSV is now a spreadsheet-safe presentation export, not a byte-exact text round-trip format. JSON remains the unchanged raw-data export. Importing safe CSV into another system may retain the apostrophe; manually removing it or using tooling that strips prefixes can reintroduce formula interpretation. No guarantee is made about re-saving/reopening modified files, all spreadsheet versions/import settings, or spreadsheet cell-length limits. The 70,000-character regression proves valid CSV preservation, not that Excel can display unlimited cell contents.
Permanent regression coverage
JobTrackerApi.Tests/CsvExportTests.cs: 25 cases exercise the real controller export and independentMicrosoft.VisualBasic.FileIO.TextFieldParser, not just the helper. They cover the original exploit, all four markers, leading controls/Unicode format variants, idempotency, normal/empty/null text, delimiter/quote/newline correctness, a delimiter-breakout-shaped value, Norwegian/emoji, every text column, untouched decimal/date cells, very long fields, case-insensitive CSV selection and deleted-row selection. Reloaded stored values and raw JSON remain unmodified.job-tracker-ui/src/csv-export.test.tsx: 2 cases, CSV and JSON, check actual component download bytes and safe generated filenames; CSV includes BOM and Norwegian text.job-tracker-ui/e2e/csv-export.spec.ts: real local authentication plus B02 context for synthetic creation, real CSV headers/bytes, unchanged JSON, and an actual Settings download compared byte-for-byte with the API response.
Verification results
Final command results and raw-output hashes are recorded in the manifest. No production requests or destructive database operation were used; API/browser fixtures use fresh synthetic local data.
Final results: 25 focused CSV backend cases, 902 broader backend tests, 298 frontend tests / 67 suites, 18 browser tests, 9 B04 rate-policy HTTP scenarios, and 1 B03 PDF admission probe pass. All final selected suites have zero failures/skips; browser/API suites have no flaky outcomes or retries. The focused two-test frontend export run also passed; counts overlap and must not be added together. The one excluded backend test performs external DNS and was not authorized: ImapServiceSsrfGuardTests.ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail. B01 import, B02 session/ownership and B03/B04 tests are included in the broader backend/browser/API checks.
API/test-project Release build with warnings as errors: 0 warnings / 0 errors. Next production build: passed, using /api for the final build. Targeted ESLint and git diff --check: passed (lint prints only the existing Browserslist stale-data advisory). Standalone TypeScript's final command exits 1 with only the pre-existing src/interview-prep.test.tsx(120,10): error TS2571: Object is of type 'unknown'. No suppression or unrelated type fix was added. A successful Next build does not close that separate R01a gate.
Repository root, PowerShell:
& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --filter FullyQualifiedName~CsvExportTests --logger 'trx;LogFileName=after.trx' --results-directory .artifacts/b05 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --no-build --filter 'FullyQualifiedName!~ImapServiceSsrfGuardTests.ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail' --logger 'trx;LogFileName=backend.trx' --results-directory .artifacts/b05 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe build JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore -warnaserror
Copy-Item docs/audits/2026-09-07/validation/evidence/b02-browser-config.ts.txt .artifacts/b05/playwright.offline.config.ts
.artifacts/b05/excel-probe.ps1 -CsvPaths .artifacts/b05/after-csv.csv,.artifacts/b05/after-typed.csv
git diff --check
The before-fix command used the same focused filter with LogFileName=before.trx and failed 1/1 as expected. Before CSV bytes were extracted from B05_CSV_SAMPLE in that TRX; after files come from B05_CSV_SAMPLE and B05_TYPED_SAMPLE in after.trx. The Excel before run used .artifacts/b05/before.csv; the retained probe was subsequently extended to inspect all 17 cells for the after run.
UI directory, PowerShell (the three API/browser commands ran serially because they share port 5302):
npm test -- --runInBand src/csv-export.test.tsx
node node_modules/jest/bin/jest.js --runInBand --watchAll=false --json --outputFile=../.artifacts/b05/frontend.json
node node_modules/eslint/bin/eslint.js src/components/ImportExportJobs.tsx src/csv-export.test.tsx e2e/csv-export.spec.ts --max-warnings=0
$env:DOTNET_HOST_PATH='C:/Users/Connor/.dotnet/dotnet.exe'
$env:NEXT_TELEMETRY_DISABLED='1'
$env:PLAYWRIGHT_STATIC_EXPORT='true'
$env:PLAYWRIGHT_PREBUILT_EXPORT='false'
node node_modules/@playwright/test/cli.js test --config=../.artifacts/b05/playwright.offline.config.ts --reporter=json > ../.artifacts/b05/browser.json
node node_modules/@playwright/test/cli.js test --config=playwright.rate-limits.config.ts --reporter=json > ../.artifacts/b05/rate-limits.json
node node_modules/@playwright/test/cli.js test --config=playwright.pdf.config.ts --reporter=json > ../.artifacts/b05/pdf-admission.json
$env:NEXT_PUBLIC_API_BASE_URL='/api'
node --max-old-space-size=4096 node_modules/next/dist/bin/next build
node node_modules/typescript/bin/tsc --noEmit --pretty false
The full browser run built a fresh production-static UI for the synthetic localhost:5302 API, using the existing offline fixture with external integrations/workers disabled. The later /api production build replaced that test-targeted output; neither artifact was deployed. Existing local packages and Chromium were used. Temporary fixture data is disposable synthetic data, not a production migration. Local raw logs/TRX/JSON remain under .artifacts/b05; the stable evidence includes hashes and safe generated CSV samples.
Files changed for B05
- Application:
JobTrackerApi/Controllers/ExportController.cs, newJobTrackerApi/Services/CsvCellSafety.cs,job-tracker-ui/src/components/ImportExportJobs.tsx. - Permanent tests: new
JobTrackerApi.Tests/CsvExportTests.cs,job-tracker-ui/src/csv-export.test.tsx,job-tracker-ui/e2e/csv-export.spec.ts. - Documentation: this completion report, validation README/queue/evidence,
docs/todo/work.md, andevidence/b05-*samples, Excel observations/probe and results manifest. The original audit evidence is not rewritten. Other uncommitted B01–B04/audit work anddocs/redesignpredate B05; the entire working-tree diff is not B05.
Self-review, residual scope and acceptance
Every one of the 13 exported text columns calls the same policy before the unchanged CSV escaping. Embedded delimiters cannot create new cells after independent parsing. Prefix placement precedes controls, and applying the helper twice does not add another prefix. The query remains AsNoTracking; neither entity values nor JSON are changed. The four typed columns deliberately bypass text neutralization and retain prior formatting. The browser download carries server bytes without decoding. Authentication, ownership filters, middleware, HTTP imports and PDF admission were not modified.
No schema or rollback transformation is required. Reverting these export changes would reopen formula interpretation and the observed Excel encoding problem. New text columns or future CSV exporters must use this boundary; future typed columns should retain their typed formatting. B05 does not solve CSV size/resource limits, raw JSON spreadsheet conversion, or spreadsheet-specific reprocessing of exported files.
Acceptance: B05 satisfies its eight criteria locally under the defined policy, with the pre-existing R01a standalone TypeScript failure explicitly remaining open. The original arithmetic formula no longer evaluates in the verified Excel workflow; permanent tests establish every CSV text field and leading-character policy, unchanged storage/JSON/typed formats, standard CSV syntax and Unicode preservation. The browser/HTTP regression proves real headers and identical downloaded bytes. Broader tests and release builds pass; compatibility limits above do not imply untested cross-application guarantees. No later ticket or deployment is authorized by this completion.
Recommended next ticket: B06 — verify and close ingress bypass path. Not started. Production topology inspection/change still needs its separately scoped authorization and deployment evidence.