Skip to content

Cap what an account import reads out of a zip entry - #3113

Open
rosa wants to merge 3 commits into
mainfrom
card-10277929357-import-decompressed-size-cap
Open

rosa wants to merge 3 commits into
mainfrom
card-10277929357-import-decompressed-size-cap

Conversation

@rosa

@rosa rosa commented Sep 11, 2026

Copy link
Copy Markdown
Member

Problem

Account::Import inflates zip entries with no byte limit, so a few-megabyte archive holding one entry that expands to gigabytes exhausts memory on a jobs worker. Anyone can reach it, since Account::ImportsController#create signs up the account itself.

Two paths, not one:

  • Record entries. Account::DataTransfer::RecordSet#load calls JSON.parse(zip.read(file_path)), and ZipFile::Reader#read without a block inflated the whole entry into a string.
  • Storage entries. These stream through ZipFile::Reader::IO into blob.upload, which looked safe, but the length a caller asks for was passed to the extractor as a count of compressed bytes. Active Storage asking for 5MB of a crafted entry got back everything those 5MB inflate to, in one string. Reading a 1024-byte slice of a 2MB entry of repeated bytes returns 1,040,000 bytes. Bounding each read still leaves the total unbounded, so a small archive can keep feeding a crafted entry into storage.

Account::Import#ensure_sufficient_storage_space does not cover any of it: it is a local free-disk preflight, and it compares the archive's own size, which is exactly what a zip bomb keeps small.

Solution

Three limits, all in ZipFile::Reader.

A ceiling on an entry read into memory, MAX_BUFFERED_ENTRY_SIZE, set at 2MB. Record entries hold one database row each, and the only column any of them can fill beyond 64KB is a rich text body; SearchReindexJob already treats a body over 100KB as pathological and skips it. The ceiling is checked twice, against the size the archive declares and against the bytes that come out of the extractor, because a crafted central directory can understate the first. ZipFile::Reader#read takes max_bytes: if a caller ever needs its own.

A bound on a streamed read. ZipFile::Reader::IO#read(length) returns at most length bytes, buffering what a slice inflated to beyond that. A stored entry returns exactly the bytes asked of it, so its reads still pass straight through, which is what our own exports write storage files as. An entry whose compressed data runs out before its deflate stream ends now ends the read rather than being asked again forever.

A budget on everything one archive produces, MAX_TOTAL_EXPANSION of 100x its own size, with a 64MB floor so a small archive holding one large body still imports. Entries are deflated individually and records are small, so a real export barely shrinks: one built from the test fixtures measures 0.76 bytes out per byte of archive, its most compressible entry 2.01:1. This is what bounds the streaming path in total.

Over any of the three raises an error inheriting from ZipFile::InvalidFileError, so the import already stops with an "invalid export" reason and Account::DataImportJob already discards it rather than resuming against the same entry.

ensure_sufficient_storage_space is unchanged. It skips on S3 because there is no local disk to preflight there: the reader range-requests the archive and blob contents upload straight back. Every service Fizzy configures is either Disk or S3, so the blob.open fallback in ZipFile.read_from_disk, which would stage a whole archive locally with no preflight, is not reachable today. Added a comment saying so.

Known remaining cases:

  • An account holding a rich text body whose exported JSON exceeds 2MB can no longer import. There is no write-time limit on body size, so that is a real trade; setting one is a product decision rather than part of a security fix. The tracking card carries it.
  • RecordSet#import still reads up to IMPORT_BATCH_SIZE of 100 records at once, so a batch holds at most 200MB. Bounded by the entry ceiling, where it was unbounded before.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt

The import inflated zip entries with no byte limit, so a few-megabyte
archive holding one entry that expands to gigabytes exhausted memory on a
jobs worker. Anyone can reach it: Account::ImportsController#create signs
up the account itself.

Record entries are now read against a byte budget, checked both on the
size the archive declares and on the bytes that actually come out of the
extractor, since a crafted archive can understate the first. Storage
entries keep streaming, but a read now returns at most the length asked
for rather than whatever a compressed slice inflates to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
Copilot AI balanced review requested due to automatic review settings September 11, 2026 13:57
@rosa

rosa commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

🤖 @codex security review. Don't run the tests.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T14:33:54.594202Z 4db5944 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

An entry whose compressed data runs out before the deflate stream ends
leaves the extractor short of eof while it has nothing left to give, so
take its first nil as the end rather than asking again forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Valid large rich-text exports become non-importable, while streamed storage entries remain unbounded in total size.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Hardens account imports against ZIP entries that inflate excessively.

Changes:

  • Caps buffered record extraction at 2 MB.
  • Bounds individual streaming reads.
  • Adds oversized-entry and job-discard tests.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
app/models/zip_file.rb Adds the oversized-entry error.
app/models/zip_file/reader.rb Implements bounded extraction.
app/models/zip_file/reader/io.rb Buffers excess streamed output.
app/models/account/import.rb Documents storage preflight behavior.
test/models/zip_file_test.rb Tests extraction limits and streaming.
test/models/account/import_test.rb Tests oversized-record rejection.
test/jobs/account/data_import_job_test.rb Tests terminal error handling.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# column any of them can fill is a rich text body; every other text column the
# export writes tops out at 64KB. Reading them with no ceiling let a
# two-megabyte upload inflate to gigabytes inside a jobs worker.
MAX_BUFFERED_ENTRY_SIZE = 2.megabytes

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Real risk, and worth stating plainly rather than waving at: an account holding a rich text body whose exported JSON is over 2MB would export fine and fail to import, with an "invalid export" reason that does not say why.

I set 2MB against the one threshold the app already states about these bodies. SearchReindexJob defaults rich_text_limit to 100_000 bytes and filters anything larger out of reindexing entirely, because "a single pathological body can stall the batch query or OOM the worker during preload, which has happened in practice". 2MB is twenty times that.

Raising it is not free: RecordSet#import holds IMPORT_BATCH_SIZE of 100 records at once, so the entry ceiling is also what bounds a batch, and RichTextRecordSet#transform_body_for_import runs Nokogiri over each body on top.

A write-time limit on body size would be the real answer, but that is a product decision about what people may type, not something to slip into a security fix. I have flagged the number on the tracking card for Rosa to call: it is one constant, and ZipFile::Reader#read takes max_bytes: if a record set ever needs its own.

I could not measure real body sizes to settle it, since bin/kamal query needs a 1Password prompt I cannot answer from this session.

Comment thread app/models/zip_file/reader.rb Outdated
Comment on lines 27 to 28
if block_given?
yield ZipFile::Reader::IO.new(entry, @io)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 4db5944, same change as the codex thread above: the reader budgets total expanded bytes against the archive size (100x, floor 64MB) and raises ZipFile::ArchiveTooLargeError. That bounds the streamed path on both storage services rather than relying on the disk preflight.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef9baf607a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/zip_file/reader.rb Outdated
Comment on lines 27 to 28
if block_given?
yield ZipFile::Reader::IO.new(entry, @io)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound total output from streamed storage entries

For a crafted deflated storage/* entry, this block path bypasses extract_within, while ZipFile::Reader::IO limits only the output of each individual extraction and never counts total decompressed bytes. The public import flow can therefore accept a small, highly compressible archive and continuously feed gigabytes into blob.upload; the disk preflight is based on the compressed archive size, so this can fill local storage or generate unbounded S3 work and cost. Enforce a cumulative expansion, account-quota, or expected-size bound while retaining streaming.

AGENTS.md reference: AGENTS.md:L49-L54

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Agreed, and fixed in 4db5944. ZipFile::Reader now budgets every byte it hands out, streamed or buffered, against the size of the archive it came from: MAX_TOTAL_EXPANSION of 100x, with a 64MB floor so a small archive holding one large rich text body still imports. Over budget raises ZipFile::ArchiveTooLargeError, which inherits from InvalidFileError like the entry error, so the import fails with "invalid export" and the job discards.

The ratio is a usable signal here because zip_kit deflates each entry on its own and records are small. I measured a real export built from the test fixtures at 0.76 bytes out per byte of archive, with the most compressible single entry at 2.01:1, so 100x leaves a wide margin over anything legitimate.

Comment on lines +53 to +54
content << chunk
ensure_within entry, content.bytesize, max_bytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound decompressed bytes across each import batch

The 2 MB ceiling applies independently to each entry, but RecordSet#import_batch and the rich-text override retain 100 parsed records at once. An attacker can submit 100 highly compressible entries just below this limit and force roughly 200 MB of attacker-controlled strings per import, plus JSON and insert_all! allocation overhead; concurrent public imports can still exhaust a jobs worker despite this mitigation. Apply an aggregate batch budget or process large records without retaining a full 100-entry batch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Deliberate, and the 2MB entry cap is what bounds it. IMPORT_BATCH_SIZE is 100, so a batch holds at most 200MB of record strings plus the insert_all! overhead. That is an ordinary amount for a job to allocate, where the same archive previously had no ceiling at all, and the new archive budget caps the whole pass on top.

An aggregate batch budget would have to count declared entry sizes, which the archive author writes, so it would only be as good as the per-entry ceiling that already backstops it. I would rather keep one number to reason about. If 200MB turns out to be too much in practice, lowering MAX_BUFFERED_ENTRY_SIZE moves it without new machinery.

# column any of them can fill is a rich text body; every other text column the
# export writes tops out at 64KB. Reading them with no ceiling let a
# two-megabyte upload inflate to gigabytes inside a jobs worker.
MAX_BUFFERED_ENTRY_SIZE = 2.megabytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve valid rich-text records above 2 MB

Any account containing an Action Text body whose exported JSON exceeds 2 MB can no longer be imported: action_text_rich_texts.body is a LONGTEXT column, and the application has no corresponding 2 MB validation, yet this generic limit rejects the entry and marks the whole export invalid. Use a model-specific limit backed by an equivalent write-time constraint, or otherwise allow existing large rich-text records to round-trip.

AGENTS.md reference: AGENTS.md:L49-L54

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Real risk, and worth stating plainly rather than waving at: an account holding a rich text body whose exported JSON is over 2MB would export fine and fail to import, with an "invalid export" reason that does not say why.

I set 2MB against the one threshold the app already states about these bodies. SearchReindexJob defaults rich_text_limit to 100_000 bytes and filters anything larger out of reindexing entirely, because "a single pathological body can stall the batch query or OOM the worker during preload, which has happened in practice". 2MB is twenty times that.

Raising it is not free: RecordSet#import holds IMPORT_BATCH_SIZE of 100 records at once, so the entry ceiling is also what bounds a batch, and RichTextRecordSet#transform_body_for_import runs Nokogiri over each body on top.

A write-time limit on body size would be the real answer, but that is a product decision about what people may type, not something to slip into a security fix. I have flagged the number on the tracking card for Rosa to call: it is one constant, and ZipFile::Reader#read takes max_bytes: if a record set ever needs its own.

I could not measure real body sizes to settle it, since bin/kamal query needs a 1Password prompt I cannot answer from this session.

Capping a buffered entry left the streaming path open: a crafted deflated
storage entry returns a bounded amount per read but no bounded amount in
total, so a small archive could still feed gigabytes into blob.upload.

Every byte a reader hands out now counts against a budget set from the
archive's own size, with a floor so a small archive holding one large rich
text body still imports. A real export measures 0.76 bytes out per byte of
archive, and its most compressible entry 2:1, because entries are deflated
individually and records are small.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
@rosa

rosa commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

🤖 @codex security review. Don't run the tests.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 4db5944e18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@rosa rosa changed the title Fix H1-4005179: cap what an account import reads out of a zip entry Cap what an account import reads out of a zip entry Sep 11, 2026
@rosa
rosa force-pushed the card-10277929357-import-decompressed-size-cap branch from 4db5944 to c9b005f Compare September 11, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants