fix(sf-fe-a5): address review #346 — CSV injection + area_m2 NaN fallback + nits

🟠 HIGH (security): ExportButtons.escapeCsvCell now prefixes cells starting
with =/+/-/@/tab/CR with a leading apostrophe (OWASP CWE-1236). Prevents
Excel/Calc/Sheets from evaluating attacker-controllable strings (egrn.address,
cad_num) as formulas (=cmd|... / =HYPERLINK / DDE).

🟡 MEDIUM: buildFallbackEgrn now returns area_m2: NaN instead of 0; KPI 'Площадь'
and EgrnPropertyTable 'Площадь' row both gate on Number.isFinite + >0, rendering
'—' consistently when EGRN absent (previously asymmetric: KPI '—' / table '0 м²').

🟢 LOW: scoreVerdict collapsed dead branch (both >=80 and >=65 returned same
ПОДХОДИТ label). MiniMap toSiteMapData narrows source via explicit ternary
instead of unchecked 'as' cast.
This commit is contained in:
lekss361 2026-05-18 01:53:09 +03:00
parent 6fe8d637fe
commit 47d0374386
4 changed files with 17 additions and 9 deletions

View file

@ -31,7 +31,10 @@ function buildRows(data: ParcelEgrn): Row[] {
{ label: "Адрес", value: data.address },
{
label: "Площадь",
value: `${data.area_m2.toLocaleString("ru")} м²`,
value:
Number.isFinite(data.area_m2) && data.area_m2 > 0
? `${data.area_m2.toLocaleString("ru")} м²`
: "—",
mono: true,
},
{ label: "ВРИ", value: data.vri },

View file

@ -55,12 +55,18 @@ function buildCsvRows(data: ParcelAnalyzeResponse): string[][] {
}
function escapeCsvCell(cell: string): string {
// Wrap in quotes if contains comma, quote, or newline
const needsQuotes = /[",\n\r]/.test(cell);
if (needsQuotes) {
return `"${cell.replace(/"/g, '""')}"`;
// CSV-injection mitigation (OWASP / CWE-1236): cells starting with =, +, -, @,
// tab, or CR are evaluated as formulas by Excel/Calc/Sheets. Prefix dangerous
// starters with a leading apostrophe so the spreadsheet treats them as text.
let safe = cell;
if (/^[=+\-@\t\r]/.test(safe)) {
safe = "'" + safe;
}
return cell;
const needsQuotes = /[",\n\r]/.test(safe);
if (needsQuotes) {
return `"${safe.replace(/"/g, '""')}"`;
}
return safe;
}
function generateCsvBlob(rows: string[][]): Blob {

View file

@ -51,7 +51,7 @@ interface Props {
function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
return {
cad_num: data.cad_num,
source: (data.source as ParcelAnalysis["source"]) ?? "cad_quarter",
source: data.source === "cad_building" ? "cad_building" : "cad_quarter",
geom_geojson: (data.geom_geojson as Geometry) ?? null,
score_breakdown: data.score_breakdown,
score: data.score,

View file

@ -31,7 +31,7 @@ function buildFallbackEgrn(cad: string): ParcelEgrn {
return {
cad_num: cad,
address: "—",
area_m2: 0,
area_m2: NaN,
vri: "—",
category: "—",
registration_date: null,
@ -57,7 +57,6 @@ function scoreVerdict(score: number, scoreLabel: string | undefined): string {
const labelUpper = label.toUpperCase();
if (score >= 80) return `ПОДХОДИТ · ${labelUpper}`;
if (score >= 65) return `ПОДХОДИТ · ${labelUpper}`;
if (score >= 45) return `СРЕДНЕ · ${labelUpper}`;
return `РИСКИ · ${labelUpper}`;