Skip to content

parseCSV() silently drops rows with unparseable amounts and lets parseFloat silently truncate malformed numeric strings #134

Description

@prodbycorne

Overview

parseCSV() (the CSV-to-recipients parser backing the airdrop recipients file-upload path) silently drops any row whose amount field doesn't parse to a valid number, and uses parseFloat, which itself silently truncates malformed numeric strings rather than rejecting them — with no error, no warning, and no discrepancy report telling the uploader that some rows were excluded or misinterpreted.

async function parseCSV(buffer) {
  const results = [];
  let rowCount = 0;
  ...
  await pipeline(Readable.from(chunks), csv(), async (rows) => {
    for await (const data of rows) {
      rowCount += 1;
      if (rowCount > config.airdrops.maxRecipients) {
        throw new AppError('VALIDATION_ERROR', 'recipients cannot exceed 10,000', 400);
      }

      const address = data.address || data.Address || data.ADDRESS;
      const amount = parseFloat(data.amount || data.Amount || data.AMOUNT);
      if (address && !Number.isNaN(amount)) {
        results.push({ address, amount });
      }
    }
  });

  return results;
}

Two distinct, confirmed problems in this function, both silent:

  1. Rows with an unparseable amount are dropped, not rejected. if (address && !Number.isNaN(amount)) is the only gate on whether a parsed row makes it into results — a row with a missing, empty, or entirely non-numeric amount field (e.g. a blank cell, a stray text note in that column, a header-row mismatch) simply doesn't get pushed into results, with rowCount still incremented (so it does count toward the 10,000-row cap) but the row itself vanishing from the output with zero indication to the caller that anything was excluded. The function's return value — a plain array — carries no metadata about how many input rows were seen vs. how many were actually parsed into results; a caller cannot distinguish "this CSV genuinely had exactly 47 recipient rows" from "this CSV had 50 rows but 3 were silently dropped for having unparseable amounts."
  2. parseFloat silently truncates malformed numeric strings instead of rejecting them. parseFloat parses as much of a leading numeric substring as it can and silently ignores everything after — parseFloat("1,000") returns 1 (stops at the comma), parseFloat("100USD") returns 100, parseFloat("50 units") returns 50. A CSV exported from a spreadsheet using thousands-separator formatting (an extremely common, realistic real-world CSV shape for a "recipient amounts" file) would have every comma-formatted value silently truncated down to just its leading digit group — "1,000,000" becomes 1, "25,000" becomes 25 — with no error, no rejected row, nothing distinguishing a correctly-parsed small amount from a catastrophically mis-parsed large one. This is a financial-disbursement input path (parseCSV's output ultimately becomes the recipient amounts for a token airdrop), and it accepts and silently mangles malformed numeric input rather than validating it strictly.

This is not merely a false sense of security from the existing total-amount-vs-sum validation (open issue #72, total !== body.total_amount for the JSON-body create path) — the CSV-upload path (POST /airdrops/:id/recipients with a file) adds recipients to an existing airdrop's already-fixed total_amount and does not perform any sum-against-total_amount cross-check at all for CSV-derived recipients (that check only exists in airdropCreateBodySchema's superRefine, which only runs for the JSON-body creation path, not the recipients-upload path) — so a silently truncated or silently dropped row in a CSV upload has no independent safety net catching it at all on this specific path.

Requirements

  • Make parseCSV() reject (fail the whole upload with a clear, row-numbered validation error) rather than silently drop any row with a missing or unparseable amount — consistent with how every other validation failure in this same handler (invalid address, duplicate address, non-positive amount) is already surfaced as an explicit, row-numbered AppError, not silently dropped.
  • Replace parseFloat with strict numeric parsing that rejects (rather than truncates) any string containing trailing non-numeric content after the numeric portion — e.g. validate with a regex (/^-?\d+(\.\d+)?$/) or Number(str) (which returns NaN for "1,000"/"100USD" rather than silently truncating, unlike parseFloat) before accepting a row's amount.
  • If some degree of leniency is genuinely desired (e.g. stripping thousands-separator commas before parsing, as a deliberate, explicit accommodation for spreadsheet-exported CSVs), do so explicitly and only for that specific, documented format — not as an incidental side effect of using a lenient parsing function that also happens to accept arbitrary trailing garbage.
  • Report, at minimum in an error case, which row number(s) failed and why, matching the row-numbered error style already used elsewhere in the same file (e.g. `recipient ${i}: invalid Stellar address`).

Acceptance Criteria

  • A CSV row with a missing, empty, or non-numeric amount field causes the entire upload to be rejected with a clear, row-numbered error — not silently excluded from the parsed result.
  • A CSV row with a comma-formatted amount (e.g. "1,000") is either explicitly, deliberately accepted as 1000 (if comma-stripping is intentionally implemented) or explicitly rejected with a clear error — it must not silently become 1.
  • A CSV row with trailing non-numeric garbage in the amount field (e.g. "100USD", "50 units") is rejected with a clear error, not silently truncated to 100/50.
  • A test exercises all three malformed-amount scenarios above (missing, comma-formatted, trailing-garbage) and asserts the upload is rejected (or, for the comma case, correctly and deliberately handled) rather than silently producing a wrong or incomplete recipient list.

Additional Notes

More precise references

  • src/routes/airdrops.js:139-141 (parseCSV's row-processing body): confirmed the exact code — const amount = parseFloat(data.amount || data.Amount || data.AMOUNT); if (address && !Number.isNaN(amount)) { results.push({ address, amount }); } — no else branch, no accumulation of skipped-row information, no error thrown for a row failing this check.
  • src/routes/airdrops.js:135-137: confirmed rowCount (used only for the 10,000-row cap check) is incremented for every row read from the CSV stream regardless of whether that row ends up in results — confirming the cap-check and the actual-output-count are tracked independently, with nothing reconciling them or surfacing the gap.
  • parseFloat behavior confirmed per the ECMAScript specification and directly reproducible: parseFloat("1,000")1; parseFloat("100USD")100; parseFloat("50 units")50; parseFloat("")NaN (this last case is caught by the existing !Number.isNaN(amount) check, so purely-empty amounts are excluded — but non-empty, partially-numeric strings are not caught, and are silently mangled instead).
  • src/validation/schemas.js:107-122 (recipientsSchema, used to re-validate parsed CSV rows via parseRecipients after parseCSV returns): confirmed amount: z.number().positive() — this schema validates the already-parsed numeric value (e.g. 1 after "1,000" was truncated) for positivity, but has no way to know or object that 1 came from a mangled "1,000" input, since by the time Zod sees it, the truncation has already silently happened inside parseCSV and the original string is gone.
  • Confirmed (per the companion merge-conflict issue in this batch) that neither the CSV-upload path nor the JSON-body recipients-array path within the currently-unparseable src/routes/airdrops.js performs any total_amount-vs-sum cross-check for recipients added via POST /airdrops/:id/recipients — only airdropCreateBodySchema's superRefine (the initial POST /airdrops creation path) has that check, and only for inline JSON-supplied recipients, not CSV-derived ones added afterward.

Additional edge cases

  • Because total_amount is fixed at airdrop-creation time and recipients-via-CSV are added afterward with no sum-reconciliation against it (see above), a silently-mangled CSV amount wouldn't necessarily be caught by any other layer of validation in this system today — this issue's fix is the most direct, and possibly the only, safety net against this specific class of financial-data corruption on the CSV-upload path specifically.
  • Worth considering whether the fix should also validate that amount doesn't exceed some sane per-recipient maximum independent of the eventual total_amount reconciliation (related to, but distinct from, open issue No upper-bound / int64 stroop overflow validation on airdrop total_amount and recipient amounts #73's int64/stroop-overflow concern) — out of scope for this issue's core fix, but worth a mention in the PR discussion since both touch "how much do we trust a numeric amount from user input" in the same recipient-processing pipeline.
  • This issue's fix should be coordinated with whoever resolves the unrelated merge-conflict issue in the same file (src/routes/airdrops.js), since both touch the CSV-upload handler; sequencing the two PRs (or combining them) is worth discussing rather than risking one clobbering the other's changes.

Test/reproduction plan

const csvMissingAmount = 'address,amount\nGABC...XYZ,\nGDEF...UVW,100\n';
const csvCommaFormatted = 'address,amount\nGABC...XYZ,"1,000"\n';
const csvTrailingGarbage = 'address,amount\nGABC...XYZ,100USD\n';

// For each: upload via POST /airdrops/:id/recipients with the CSV as `file`.
// Currently: the first silently produces only 1 recipient (the second row) with no error;
// the second silently produces { address: 'GABC...XYZ', amount: 1 } instead of 1000 or a rejection;
// the third silently produces { address: 'GABC...XYZ', amount: 100 } instead of a rejection.
// After fix: all three should be rejected with a clear, row-numbered validation error
// (or, for the comma case only, deliberately and correctly parsed to 1000 if comma-stripping is implemented).

Cross-references

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignapiREST API design and endpointsbugSomething isn't workingvery hardExtremely hard — deep expertise, careful design, and significant time required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions