From 19d6c44c5b0509afd46ebf4dac7fcb4e495a9994 Mon Sep 17 00:00:00 2001 From: pq198363-ops <246611021+pq198363-ops@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:50:11 +0800 Subject: [PATCH] security: neutralize csv formula injection --- README.md | 2 ++ docs/security.md | 13 +++++++++++++ src/csv-injection.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/routes/usage.ts | 13 +++++++++++-- 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 docs/security.md create mode 100644 src/csv-injection.test.ts diff --git a/README.md b/README.md index 337ee4f..36f398d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ agentpay-backend/ - [Billing units and settlement semantics](docs/billing-units.md) explains stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why `POST /api/v1/settle` drains backend counters without moving funds. +- [Security notes](docs/security.md) documents response-surface and export + hardening, including CSV formula-injection mitigation. ## Quickstart diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..0f52e57 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,13 @@ +# Security Notes + +## CSV exports + +`GET /api/v1/usage/export.csv` neutralizes spreadsheet formula injection in +dynamic fields before returning the attachment. Any exported `agent` or +`serviceId` value that starts with `=`, `+`, `-`, `@`, a tab, or a carriage +return is prefixed with an apostrophe so spreadsheet tools treat the value as +text. + +The CSV path still preserves standard CSV quoting for quotes, commas, and line +breaks. JSON exports are not modified because they are not interpreted as +spreadsheet formulas. diff --git a/src/csv-injection.test.ts b/src/csv-injection.test.ts new file mode 100644 index 0000000..43386b6 --- /dev/null +++ b/src/csv-injection.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import request from "supertest"; +import { app } from "./index.js"; +import { escapeCsvField } from "./routes/usage.js"; + +void describe("CSV formula injection mitigation", () => { + void it("neutralizes formula-leading fields before CSV quoting", () => { + assert.strictEqual(escapeCsvField("=cmd"), "'=cmd"); + assert.strictEqual(escapeCsvField("+1"), "'+1"); + assert.strictEqual(escapeCsvField("-1"), "'-1"); + assert.strictEqual(escapeCsvField("@ref"), "'@ref"); + assert.strictEqual(escapeCsvField("\tTabbed"), "'\tTabbed"); + assert.strictEqual(escapeCsvField("\rCarriage"), `"'\rCarriage"`); + assert.strictEqual(escapeCsvField('="quoted"'), `"'=""quoted"""`); + assert.strictEqual(escapeCsvField("normal"), "normal"); + assert.strictEqual(escapeCsvField("needs,quote"), '"needs,quote"'); + }); + + void it("neutralizes usage CSV exports without changing JSON exports", async () => { + await request(app) + .post("/api/v1/usage") + .send({ agent: "=cmd", serviceId: "@svc", requests: 1 }); + + const csv = await request(app).get("/api/v1/usage/export.csv"); + assert.strictEqual(csv.status, 200); + assert.ok(csv.headers["content-type"].startsWith("text/csv")); + assert.match(csv.text, /^'=cmd,'@svc,1$/m); + + const json = await request(app).get("/api/v1/usage/export.json"); + assert.strictEqual(json.status, 200); + assert.ok( + json.body.items.some( + (item: { agent: string; serviceId: string; total: number }) => + item.agent === "=cmd" && item.serviceId === "@svc" && item.total >= 1 + ) + ); + }); +}); diff --git a/src/routes/usage.ts b/src/routes/usage.ts index cd28a50..b493239 100644 --- a/src/routes/usage.ts +++ b/src/routes/usage.ts @@ -21,6 +21,16 @@ type BillingTotalBreakdown = { unpricedRequests: number; }; +const FORMULA_PREFIX_RE = /^[=+\-@\t\r]/; + +/** + * Escapes a CSV field and neutralizes spreadsheet formula prefixes. + */ +export function escapeCsvField(value: string): string { + const safeValue = FORMULA_PREFIX_RE.test(value) ? `'${value}` : value; + return /[",\n\r]/.test(safeValue) ? `"${safeValue.replace(/"/g, '""')}"` : safeValue; +} + /** * Builds usage, billing, settlement, and agent rollup routes. */ @@ -127,11 +137,10 @@ export function createUsageRouter(): Router { }); router.get("/api/v1/usage/export.csv", (_req, res: Response) => { - const escape = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v); const rows: string[] = ["agent,serviceId,total"]; for (const [key, total] of usageStore.entries()) { const [agent, serviceId] = key.split("::"); - rows.push(`${escape(agent)},${escape(serviceId)},${total}`); + rows.push(`${escapeCsvField(agent)},${escapeCsvField(serviceId)},${total}`); } res.setHeader("Content-Type", "text/csv"); res.setHeader("Content-Disposition", "attachment; filename=usage.csv");