Skip to main content

max / makenotwork

server: the item-sales export moves to the server, sanitized Closes 1ea96868. CSV injection, pre-existing. static/tab-item-sales.js built the file in the browser by scraping the rendered table and quoting each cell with a bare ". It neutralised no formula prefix and escaped no embedded quote. The Buyer column is guest_email, typed by the buyer at guest checkout, so the payload is attacker-chosen, the victim is the creator who opens the file, and the vector is a purchase. Every server-side export in exports/mod.rs already ran sanitize_csv_cell, which prefixes a leading = + - @ tab or CR with an apostrophe and doubles embedded quotes. This was the only export in the tree built client-side and inherited none of it. So: POST /api/export/items/{id}/sales, and the button becomes the described export_act that five other sites already use. The 21-line file is deleted whole, and with it a download filename built from a creator-controlled item title. The route reads the whole set rather than paging it. The sales tab already calls get_sales_by_item and renders every row, so holding the same set adds no exposure the page did not have, and paging it would want a second query for an item-scoped read rather than a seller-scoped one. Ownership is the query's: get_sales_by_item filters on seller_id. The rest of the panel does not convert yet. Its Refund control needs data-after, a described Act cannot emit one, and moving the write onto the writes-only nest means extracting a refund core out of a money path that claims the row before calling Stripe. Filed as b25dd957 rather than attempted inside a tab conversion. 203 lib tests green, fmt and clippy clean, frontend_globals seal passes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 23:15 UTC
Signed with PGP, not checked
Commit: fa7013824465c07c31519850260a66d1f15c8cef
Parent: 1425478
6 files changed, +84 insertions, -25 deletions
@@ -920,6 +920,10 @@
920 920 pub struct ItemSalesTabTemplate {
921 921 pub item: Item,
922 922 pub sales: Vec<SaleRow>,
923 + /// Where the described Export control posts. Built by the handler rather
924 + /// than composed in the template: `export_act::html` takes a `&str` and
925 + /// Askama cannot `format!` one in an expression.
926 + pub export_route: String,
923 927 }
924 928
925 929 /// Item embed tab: copy-paste embed codes for this item.
@@ -588,6 +588,12 @@
588 588 let export_routes = CsrfRouter::new()
589 589 .route("/api/export/projects", post_csrf(exports::export_projects))
590 590 .route("/api/export/sales", post_csrf(exports::export_sales))
591 + // Item-scoped, and separate from the seller-wide export above: the item
592 + // sales tab offers a file for the one item it is showing. `1ea96868`.
593 + .route(
594 + "/api/export/items/{id}/sales",
595 + post_csrf(exports::export_item_sales),
596 + )
591 597 .route(
592 598 "/api/export/purchases",
593 599 post_csrf(exports::export_purchases),
@@ -1,7 +1,12 @@
1 1 <div class="item-sales-tab-header">
2 2 <h2 class="subsection-title">Sales</h2>
3 3 {% if !sales.is_empty() %}
4 - <button class="btn-secondary btn-compact" data-action="exportItemSalesCSV">Export CSV</button>
4 + {#- The described export control, posting to a server route that runs
5 + `sanitize_csv_cell` over every free-text column. This was
6 + `data-action="exportItemSalesCSV"`, which built the file in the browser
7 + by scraping this table and quoting each cell with a bare `"`. See
8 + `1ea96868`. -#}
9 + {{ crate::quasi::export_act::html(export_route.as_str(), "item-sales.csv")|safe }}
5 10 {% endif %}
6 11 </div>
7 12
@@ -44,5 +49,3 @@
44 49 </table>
45 50 {% endif %}
46 51
47 - <div id="tab-item-sales-cfg" hidden data-item-title="{{ item.title }}"></div>
48 - <script src="/static/tab-item-sales.js?v=0623"></script>
@@ -542,6 +542,67 @@
542 542 finish_csv(is_htmx, "makenot-work-sales.csv", rx).await
543 543 }
544 544
545 + /// Export one item's sales as a downloadable CSV file.
546 + ///
547 + /// `1ea96868`. This existed as `static/tab-item-sales.js`, which built the file
548 + /// in the browser by scraping the rendered table and quoting each cell with a
549 + /// bare `"`. That neutralised no formula prefix and escaped no embedded quote,
550 + /// and the Buyer column is `guest_email` -- typed by the buyer at guest
551 + /// checkout, so attacker-controlled and landing in a file the creator opens.
552 + /// Every server-side export in this module already ran `sanitize_csv_cell`;
553 + /// that one was the only export in the tree built client-side and inherited
554 + /// none of it.
555 + ///
556 + /// # Why it reads the whole set rather than paging
557 + ///
558 + /// The item's own sales tab already calls `get_sales_by_item` and renders every
559 + /// row, so holding the same set here adds no exposure the page did not have.
560 + /// Paging it would want a second query, and this one is item-scoped rather than
561 + /// seller-scoped: it is a page of a creator's history, not the history.
562 + ///
563 + /// Ownership is the query's, not a separate check: `get_sales_by_item` takes
564 + /// `seller_id` and filters on it, so another creator's item id returns nothing.
565 + #[tracing::instrument(skip_all, name = "exports::export_item_sales")]
566 + pub(super) async fn export_item_sales(
567 + State(db): State<PgPool>,
568 + headers: HeaderMap,
569 + AuthUser(user): AuthUser,
570 + axum::extract::Path(item_id): axum::extract::Path<crate::db::ItemId>,
571 + ) -> Result<Response> {
572 + let is_htmx = is_htmx_request(&headers);
573 + let sales = db::transactions::get_sales_by_item(&db, item_id, user.id).await?;
574 +
575 + let mut body = String::new();
576 + for tx in &sales {
577 + let buyer = tx
578 + .guest_email
579 + .clone()
580 + .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string()))
581 + .unwrap_or_else(|| "Unknown".to_string());
582 + writeln!(
583 + body,
584 + "{},{},{},{}",
585 + tx.created_at.format("%Y-%m-%d %H:%M"),
586 + sanitize_csv_cell(&buyer),
587 + crate::formatting::format_dollars_plain(tx.amount_cents.as_i64()),
588 + sanitize_csv_cell(&tx.status.to_string()),
589 + )
590 + .unwrap();
591 + }
592 +
593 + // One page, then done. `spawn_paginated_csv` ends when a page comes back
594 + // shorter than a batch, so the row count is what terminates it and an
595 + // oversized set still stops on the empty second page.
596 + let rows = sales.len();
597 + let mut once = Some(body);
598 + let rx = spawn_paginated_csv("Date,Buyer,Amount,Status\n", move |_limit, _offset| {
599 + let page = once.take();
600 + async move { Ok(page.map_or_else(|| (String::new(), 0), |text| (text, rows))) }
601 + });
602 +
603 + finish_csv(is_htmx, "makenot-work-item-sales.csv", rx).await
604 + }
605 +
545 606 /// Export revenue splits as a downloadable CSV file.
546 607 #[tracing::instrument(skip_all, name = "exports::export_splits")]
547 608 pub(super) async fn export_splits(
@@ -229,5 +229,11 @@
229 229 })
230 230 .collect();
231 231
232 - Ok(ItemSalesTabTemplate { item, sales: rows })
232 + let export_route = format!("/api/export/items/{item_id}/sales");
233 +
234 + Ok(ItemSalesTabTemplate {
235 + item,
236 + sales: rows,
237 + export_route,
238 + })
233 239 }
@@ -1,21 +1,0 @@
1 - function exportItemSalesCSV() {
2 - var cfg = document.getElementById('tab-item-sales-cfg');
3 - var itemTitle = cfg.dataset.itemTitle;
4 - var table = document.getElementById('item-sales-table');
5 - if (!table) return;
6 - var rows = table.querySelectorAll('tbody tr');
7 - var csv = 'Date,Buyer,Amount,Status\n';
8 - rows.forEach(function(row) {
9 - var cells = row.querySelectorAll('td');
10 - if (cells.length < 4) return;
11 - csv += '"' + cells[0].textContent.trim() + '","'
12 - + cells[1].textContent.trim() + '","'
13 - + cells[2].textContent.trim() + '","'
14 - + cells[3].textContent.trim() + '"\n';
15 - });
16 - var blob = new Blob([csv], { type: 'text/csv' });
17 - var a = document.createElement('a');
18 - a.href = URL.createObjectURL(blob);
19 - a.download = itemTitle + '-sales.csv';
20 - a.click();
21 - }