-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Cap what an account import reads out of a zip entry #3113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,45 @@ | ||
| class ZipFile::Reader | ||
| # Entries read into memory hold one database record each, and the largest | ||
| # 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Any account containing an Action Text body whose exported JSON exceeds 2 MB can no longer be imported: AGENTS.md reference: AGENTS.md:L49-L54 Useful? React with 馃憤聽/ 馃憥.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Raising it is not free: 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 I could not measure real body sizes to settle it, since |
||
|
|
||
| # The extractor is fed compressed bytes, so how much it hands back in one call | ||
| # is the archive author's choice: a maximally compressible slice expands about | ||
| # a thousandfold. Slicing here keeps every real record a single read of the | ||
| # archive, which matters on S3 where each read is its own range request, while | ||
| # capping what one call can return at tens of megabytes. | ||
| EXTRACT_SLICE_SIZE = 64.kilobytes | ||
|
|
||
| # Deflating each entry on its own is what makes an archive's total expansion a | ||
| # usable signal: a real export barely shrinks, since records are small and per | ||
| # entry overhead eats the savings, while a crafted one expands a thousandfold. | ||
| # Budgeting everything read out of an archive against what was actually | ||
| # uploaded bounds the streaming path too, where an entry's own declared size is | ||
| # the archive author's word. The floor keeps a small archive holding one large | ||
| # rich text body importable. | ||
| MAX_TOTAL_EXPANSION = 100 | ||
| MIN_EXPANSION_BUDGET = 64.megabytes | ||
|
|
||
| def initialize(io) | ||
| @io = io | ||
| @reader = ZipKit::FileReader.read_zip_structure(io: io) | ||
| @expanded = 0 | ||
| @budget = [ io.size * MAX_TOTAL_EXPANSION, MIN_EXPANSION_BUDGET ].max | ||
| rescue ZipKit::FileReader::ReadError, ZipKit::FileReader::MissingEOCD, ZipKit::FileReader::UnsupportedFeature => e | ||
| raise ZipFile::InvalidFileError, e.message | ||
| end | ||
|
|
||
| def read(file_path) | ||
| def read(file_path, max_bytes: MAX_BUFFERED_ENTRY_SIZE) | ||
| entry = @reader.find { |e| e.filename == file_path } | ||
| raise ArgumentError, "File not found in zip: #{file_path}" unless entry | ||
| raise ArgumentError, "Cannot read directory entry: #{file_path}" if entry.filename.end_with?("/") | ||
|
|
||
| if block_given? | ||
| yield ZipFile::Reader::IO.new(entry, @io) | ||
| yield ZipFile::Reader::IO.new(entry, @io, self) | ||
| else | ||
| entry.extractor_from(@io).extract | ||
| extract_within(entry, max_bytes) | ||
| end | ||
| end | ||
|
|
||
|
|
@@ -25,4 +50,42 @@ def glob(pattern) | |
| def exists?(file_path) | ||
| @reader.any? { |e| e.filename == file_path } | ||
| end | ||
|
|
||
| # Called for every byte handed out, buffered or streamed. | ||
| def count_expanded(bytes) | ||
| @expanded += bytes | ||
|
|
||
| if @expanded > @budget | ||
| raise ZipFile::ArchiveTooLargeError, | ||
| "archive has produced #{@expanded} bytes, over the #{@budget} byte limit for its size" | ||
| end | ||
| end | ||
|
|
||
| private | ||
| def extract_within(entry, max_bytes) | ||
| ensure_within entry, entry.uncompressed_size, max_bytes | ||
|
|
||
| extractor = entry.extractor_from(@io) | ||
| content = "".b | ||
|
|
||
| until extractor.eof? | ||
| chunk = extractor.extract(EXTRACT_SLICE_SIZE) | ||
| break if chunk.nil? | ||
|
|
||
| count_expanded chunk.bytesize | ||
| content << chunk | ||
| ensure_within entry, content.bytesize, max_bytes | ||
|
Comment on lines
+76
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The 2 MB ceiling applies independently to each entry, but Useful? React with 馃憤聽/ 馃憥.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 馃 Deliberate, and the 2MB entry cap is what bounds it. 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 |
||
| end | ||
|
|
||
| content | ||
| end | ||
|
|
||
| # The size an entry declares is the archive author's word, so the bytes that | ||
| # come out of the extractor are counted as well. | ||
| def ensure_within(entry, bytes, max_bytes) | ||
| if bytes > max_bytes | ||
| raise ZipFile::EntryTooLargeError, | ||
| "#{entry.filename} expands to at least #{bytes} bytes, over the #{max_bytes} byte limit" | ||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
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.
SearchReindexJobdefaultsrich_text_limitto 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#importholdsIMPORT_BATCH_SIZEof 100 records at once, so the entry ceiling is also what bounds a batch, andRichTextRecordSet#transform_body_for_importruns 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#readtakesmax_bytes:if a record set ever needs its own.I could not measure real body sizes to settle it, since
bin/kamal queryneeds a 1Password prompt I cannot answer from this session.