Skip to main content

max / makenotwork

perf: cap project-metadata export and break zip walk early (audit Run 20 Phase 7) - export_projects materialized the entire catalog into an in-memory JSON tree + string on the request path with no bound. Add a DoS backstop: refuse a catalog above EXPORT_MAX_ROWS with a clear message. The ceiling is far above any real creator's catalog, so no legitimate "full export" is truncated — it only refuses a pathological one instead of pinning unbounded memory. - archive walk_zip accumulated total_uncompressed but only checked it after the loop, so a 100k-entry archive of sub-64-KiB ultra-compressible members (which skip the per-entry ratio floor) could force multi-GB of cumulative decompression first. Break as soon as the cumulative total blows the budget; the finalizer turns it into a Fail verdict. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-03 07:55 UTC
Commit: 1b37fb18b92b6a2c4dd7324c1d1000a3b4e0755f
Parent: 727a2b3
2 files changed, +26 insertions, -1 deletion
@@ -16,7 +16,7 @@ use tokio_stream::{wrappers::ReceiverStream, StreamExt};
16 16 use crate::{
17 17 auth::AuthUser,
18 18 db,
19 - error::{Result, ResultExt},
19 + error::{AppError, Result, ResultExt},
20 20 helpers::{is_htmx_request, sanitize_csv_cell},
21 21 templates::{ExportDownloadTemplate, FormStatusTemplate},
22 22 AppState,
@@ -161,6 +161,21 @@ pub(super) async fn export_projects(
161 161 // Get all projects and all items in 2 queries (not N+1)
162 162 let projects = db::projects::get_projects_by_user(&state.db, user.id).await?;
163 163 let all_items = db::items::get_items_by_user(&state.db, user.id).await?;
164 +
165 + // DoS backstop: this handler materializes the entire catalog into an
166 + // in-memory JSON tree and string on the request path. The ceiling is far
167 + // above any real creator's catalog, so it never truncates a legitimate
168 + // "full export" — it only refuses a pathological one with a clear message
169 + // rather than pinning unbounded memory (Run 20 Perf).
170 + if all_items.len() > EXPORT_MAX_ROWS {
171 + tracing::warn!(user_id = %user.id, items = all_items.len(), "project export exceeds row ceiling, refusing");
172 + let msg = "Your catalog is too large to export in a single request. Contact info@makenot.work for a bulk export.";
173 + if is_htmx {
174 + return export_error_html(msg);
175 + }
176 + return Err(AppError::BadRequest(msg.to_string()));
177 + }
178 +
164 179 let all_item_ids: Vec<db::ItemId> = all_items.iter().map(|i| i.id).collect();
165 180 let tags_map = db::tags::get_tags_for_items(&state.db, &all_item_ids).await?;
166 181
@@ -418,6 +418,16 @@ fn walk_zip<R: std::io::Read + std::io::Seek>(
418 418 } else {
419 419 total_compressed += entry_compressed;
420 420 total_uncompressed += actual_size;
421 + // Stop as soon as the cumulative uncompressed size blows the
422 + // budget — don't keep expanding the remaining entries just to
423 + // check the total after the loop. Sub-64-KiB entries skip the
424 + // per-entry ratio floor below, so a 100k-entry archive of tiny
425 + // ultra-compressible members could otherwise force multi-GB of
426 + // cumulative decompression before the post-loop guard trips
427 + // (Run 20 Perf). The finalizer turns this into a Fail verdict.
428 + if total_uncompressed > constants::SCAN_ZIP_MAX_UNCOMPRESSED {
429 + break;
430 + }
421 431 // Per-entry ratio is a fast-path signal; the accumulation vector
422 432 // (many small ultra-compressed entries) is the total-ratio guard's
423 433 // job below. The size floor keeps tiny, naturally-compressible