From c953465c9cdde6326f0da1bb0966c2c6ae5b4789 Mon Sep 17 00:00:00 2001 From: sebastianbarrozo Date: Thu, 23 Jul 2026 09:11:04 -0300 Subject: [PATCH 01/10] Feature ETP-4559: Neutralize CSV formula injection in client CSV builders --- packages/app-shell-core/package.json | 2 +- .../components/import/ImportReviewQueue.jsx | 10 +- .../lib/csv/__tests__/csvSerializer.test.js | 101 ++++++++++++++++++ .../src/lib/csv/csvSerializer.js | 26 +++++ .../src/lib/import/buildTemplateCsv.js | 7 +- 5 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 packages/app-shell-core/src/lib/csv/__tests__/csvSerializer.test.js create mode 100644 packages/app-shell-core/src/lib/csv/csvSerializer.js diff --git a/packages/app-shell-core/package.json b/packages/app-shell-core/package.json index b8d9eb808..2d327918d 100644 --- a/packages/app-shell-core/package.json +++ b/packages/app-shell-core/package.json @@ -39,7 +39,7 @@ "!src/**/__tests__/**" ], "scripts": { - "test": "node --test test/*.test.js src/auth/__tests__/*.test.js src/i18n/__tests__/*.test.js src/runtime/__tests__/*.test.js src/theme/__tests__/*.test.js src/components/ui/__tests__/*.test.js src/lib/__tests__/*.test.js src/lib/validation/__tests__/*.test.js src/lib/import/__tests__/*.test.js", + "test": "node --test test/*.test.js src/auth/__tests__/*.test.js src/i18n/__tests__/*.test.js src/runtime/__tests__/*.test.js src/theme/__tests__/*.test.js src/components/ui/__tests__/*.test.js src/lib/__tests__/*.test.js src/lib/validation/__tests__/*.test.js src/lib/import/__tests__/*.test.js src/lib/csv/__tests__/*.test.js", "test:vitest": "vitest run", "test:consumer": "node scripts/package-consumer-smoke.mjs" }, diff --git a/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx b/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx index 5111ac67a..c09c0f4a8 100644 --- a/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx +++ b/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx @@ -7,6 +7,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover.jsx'; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../ui/command.jsx'; import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from '../ui/dialog.jsx'; import { ScrollPane } from '../ui/scroll-pane.jsx'; +import { csvField } from '../../lib/csv/csvSerializer.js'; const DEFAULT_LABELS = { filterAll: 'All', @@ -79,11 +80,6 @@ const STATUS_TAG_COLORS = { neutral: 'bg-[#F5F7F9] text-[#3F3F50]', }; -function csvEscape(value) { - const s = String(value ?? ''); - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; -} - /** Line number + status pill, shared by every row in the frozen leading column. */ function StatusLineTag({ index, tag, children }) { return ( @@ -266,12 +262,12 @@ function FkMismatchCell({ index, field, value, error, onSelect, simSearchFn, tok */ export function buildErrorsCsv(entries, headers, mapping) { const mappedHeaders = headers.filter((h) => mapping[h]); - const lines = [[...mappedHeaders, 'Error'].map(csvEscape).join(',')]; + const lines = [[...mappedHeaders, 'Error'].map(csvField).join(',')]; for (const entry of entries) { if (entry.errors.length === 0) continue; const values = mappedHeaders.map((h) => entry.row[mapping[h]]); const errorText = entry.errors.map((e) => (e.target ? `${e.target}: ${e.message}` : e.message)).join(' | '); - lines.push([...values, errorText].map(csvEscape).join(',')); + lines.push([...values, errorText].map(csvField).join(',')); } return lines.join('\n'); } diff --git a/packages/app-shell-core/src/lib/csv/__tests__/csvSerializer.test.js b/packages/app-shell-core/src/lib/csv/__tests__/csvSerializer.test.js new file mode 100644 index 000000000..c2d0d266f --- /dev/null +++ b/packages/app-shell-core/src/lib/csv/__tests__/csvSerializer.test.js @@ -0,0 +1,101 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { csvField } from '../csvSerializer.js'; + +describe('csvField', () => { + describe('neutralizes spreadsheet formula injection', () => { + it('prepends an apostrophe to a value starting with =', () => { + assert.equal(csvField('=1+1'), "'=1+1"); + }); + + it('prepends an apostrophe to a value starting with +', () => { + assert.equal(csvField('+SUM(A1:A2)'), "'+SUM(A1:A2)"); + }); + + it('prepends an apostrophe to a value starting with -', () => { + assert.equal(csvField('-CMD'), "'-CMD"); + }); + + it('prepends an apostrophe to a value starting with @', () => { + assert.equal(csvField('@SUM(A1:A2)'), "'@SUM(A1:A2)"); + }); + + it('catches a marker hiding behind a leading tab', () => { + assert.equal(csvField('\t=1+1'), "'\t=1+1"); + }); + + it('catches a marker hiding behind a leading carriage return', () => { + assert.equal(csvField('\r=1+1'), "'\r=1+1"); + }); + + it('catches a marker hiding behind leading spaces', () => { + assert.equal(csvField(' =1+1'), "' =1+1"); + }); + + it('catches a marker hiding behind a leading line feed (and quotes it, since it now contains a raw newline)', () => { + assert.equal(csvField('\n=1+1'), '"\'\n=1+1"'); + }); + + it('neutralizes AND quotes when the value also needs RFC 4180 quoting', () => { + assert.equal(csvField('=1+1,extra'), '"\'=1+1,extra"'); + }); + }); + + describe('does not double-prefix or break legitimate values', () => { + it('does not add a second apostrophe to an already-neutralized value', () => { + assert.equal(csvField("'=1+1"), "'=1+1"); + }); + + it('neutralizes a legitimate negative number (documented trade-off, same as the server policy)', () => { + assert.equal(csvField('-500.00'), "'-500.00"); + }); + + it('leaves ordinary text untouched', () => { + assert.equal(csvField('Normal Value'), 'Normal Value'); + }); + + it('leaves zero untouched', () => { + assert.equal(csvField(0), '0'); + }); + + it('returns an empty string for null', () => { + assert.equal(csvField(null), ''); + }); + + it('returns an empty string for undefined', () => { + assert.equal(csvField(undefined), ''); + }); + + it('returns an empty string for an empty string', () => { + assert.equal(csvField(''), ''); + }); + + it('quotes a value containing a comma', () => { + assert.equal(csvField('texto, con coma'), '"texto, con coma"'); + }); + + it('escapes embedded double quotes and quotes the field', () => { + assert.equal(csvField('con "comillas" internas'), '"con ""comillas"" internas"'); + }); + + it('preserves an embedded (non-leading) line feed and quotes the field', () => { + assert.equal(csvField('line one\nline two'), '"line one\nline two"'); + }); + + it('preserves an embedded (non-leading) CRLF and quotes the field', () => { + assert.equal(csvField('line one\r\nline two'), '"line one\r\nline two"'); + }); + + it('preserves Unicode characters', () => { + assert.equal(csvField('José Ñáñez'), 'José Ñáñez'); + }); + + it('leaves a safe header untouched', () => { + assert.equal(csvField('Commercial Name'), 'Commercial Name'); + }); + + it('is deterministic across repeated calls', () => { + assert.equal(csvField('=1+1'), csvField('=1+1')); + }); + }); +}); diff --git a/packages/app-shell-core/src/lib/csv/csvSerializer.js b/packages/app-shell-core/src/lib/csv/csvSerializer.js new file mode 100644 index 000000000..ca7f98bc2 --- /dev/null +++ b/packages/app-shell-core/src/lib/csv/csvSerializer.js @@ -0,0 +1,26 @@ +const FORMULA_TRIGGER_CHARS = '=+-@'; + +/** + * A value is formula-sensitive when its first non-whitespace character is a spreadsheet + * formula trigger. Leading whitespace/control characters (space, tab, CR, LF) are skipped + * first so a marker cannot hide behind them. Mirrors the server-side policy in + * NeoCsvExportService.java (com.etendoerp.go) so both sides neutralize the same inputs. + */ +function isFormulaInjection(value) { + let i = 0; + while (i < value.length && /\s/.test(value[i])) i++; + return i < value.length && FORMULA_TRIGGER_CHARS.includes(value[i]); +} + +/** + * Serializes a single CSV field: neutralizes spreadsheet formula injection (CWE-1236) by + * prepending an apostrophe when the value is formula-sensitive, then applies RFC 4180 + * quoting (only when the value contains a comma, quote, or newline). + */ +export function csvField(value) { + let s = String(value ?? ''); + if (isFormulaInjection(s)) { + s = `'${s}`; + } + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} diff --git a/packages/app-shell-core/src/lib/import/buildTemplateCsv.js b/packages/app-shell-core/src/lib/import/buildTemplateCsv.js index 994cd0393..102742cd5 100644 --- a/packages/app-shell-core/src/lib/import/buildTemplateCsv.js +++ b/packages/app-shell-core/src/lib/import/buildTemplateCsv.js @@ -1,7 +1,4 @@ -function csvEscape(value) { - const s = String(value ?? ''); - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; -} +import { csvField } from '../csv/csvSerializer.js'; /** * Builds a blank (header-row-only) CSV template for a window's import @@ -13,6 +10,6 @@ function csvEscape(value) { */ export function buildTemplateCsv(fields) { return fields - .map((field) => csvEscape(field.aliases?.[0] ?? field.label ?? field.target)) + .map((field) => csvField(field.aliases?.[0] ?? field.label ?? field.target)) .join(','); } From fe3d7b6a6202e23b58b5246e6c3e6180fa9839cc Mon Sep 17 00:00:00 2001 From: sebastianbarrozo Date: Thu, 23 Jul 2026 19:41:54 -0300 Subject: [PATCH 02/10] Feature ETP-4657: Fix semantic color token drift from ETP-4554 - Restore --foreground/--muted-foreground/--border-subtle to match the original design (verified against staging) after they drifted to unaudited generic shadcn defaults during the ETP-4554 migration. - Revert --border-control/--input to the original light color: it drove both real form-control borders AND shared outline buttons, so its WCAG 1.4.11 fix visually broke buttons too. Documented as an accepted, known a11y gap (WARN comments) instead of silently dropped. - Add ALIAS_TOKEN_PAIRS consistency check (theme/index.js + accessibility.test.js) so generic tokens can't silently drift from the audited accessibility contract again. - Fix shared TableRow/TableHeader/TableFooter: used border-structural (WCAG 3:1-gated, meant for real structural boundaries) for plain decorative row/section dividers; switched to border-subtle. --- .../src/components/ui/table.jsx | 15 +++- packages/app-shell-core/src/styles.css | 20 +++-- .../src/theme/__tests__/accessibility.test.js | 73 +++++++++++++++++-- packages/app-shell-core/src/theme/index.js | 50 ++++++++++++- 4 files changed, 143 insertions(+), 15 deletions(-) diff --git a/packages/app-shell-core/src/components/ui/table.jsx b/packages/app-shell-core/src/components/ui/table.jsx index 7c3659402..afd765d7d 100644 --- a/packages/app-shell-core/src/components/ui/table.jsx +++ b/packages/app-shell-core/src/components/ui/table.jsx @@ -13,7 +13,8 @@ const Table = React.forwardRef(({ className, ...props }, ref) => ( Table.displayName = "Table" const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( - + // WARN(a11y): border-border-subtle, not border-border-structural — see TableRow below. + )) TableHeader.displayName = "TableHeader" @@ -26,9 +27,10 @@ const TableBody = React.forwardRef(({ className, ...props }, ref) => ( TableBody.displayName = "TableBody" const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( + // WARN(a11y): border-border-subtle, not border-border-structural — see TableRow below. tr]:last:border-b-0", className)} + className={cn("border-t border-border-subtle font-medium [&>tr]:last:border-b-0", className)} {...props} /> )) TableFooter.displayName = "TableFooter" @@ -36,8 +38,15 @@ TableFooter.displayName = "TableFooter" const TableRow = React.forwardRef(({ className, ...props }, ref) => ( diff --git a/packages/app-shell-core/src/styles.css b/packages/app-shell-core/src/styles.css index 6e699755c..10a10b4ac 100644 --- a/packages/app-shell-core/src/styles.css +++ b/packages/app-shell-core/src/styles.css @@ -286,7 +286,7 @@ :root { --background: 220 14% 96%; - --foreground: 222 47% 11%; + --foreground: 222 47% 11%; /* #0F1729 — matches staging (go.staging.etendo.cloud), the original design spec's color */ --card: 0 0% 100%; --card-foreground: 222 47% 11%; --popover: 0 0% 100%; @@ -296,7 +296,7 @@ --secondary: 210 40% 96%; --secondary-foreground: 222 47% 11%; --muted: 210 40% 96%; - --muted-foreground: 215 16% 47%; + --muted-foreground: 240 12% 48%; /* #6C6C89 — NOT staging's #65758B: that fails WCAG AA (4.3:1) against --background (only passes 4.7:1 against white --card); this is the a11y-review fix, matches --text-secondary */ --accent: 210 40% 96%; --accent-foreground: 222 47% 11%; --destructive: 0 84% 60%; @@ -306,10 +306,20 @@ --inverse-muted: 215 16% 72%; --inverse-border: 215 20% 30%; /* Semantic accessibility contract v1 (ETP-4554). */ - --border-control: 215 16% 47%; + /* WARN(a11y): --border-control (and --input, which aliases it) is back to + staging's original light color. It drives BOTH real form-control borders + AND the shared outline-button border (shadcn's `border-input` convention), + so darkening it for WCAG 1.4.11 compliance visually affected buttons too, + which the design didn't want. Known gap: at this value, --border-control + only reaches ~1.5:1 contrast against --background/--card, short of the + 3:1 WCAG 1.4.11 non-text contrast minimum for control boundaries. Revisit + by splitting a dedicated (darker) token for real /