Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions src/csv-injection.test.ts
Original file line number Diff line number Diff line change
@@ -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
)
);
});
});
13 changes: 11 additions & 2 deletions src/routes/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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");
Expand Down