Archive import: files are unpacked with no size cap, and pictures and logos skip the checks their upload paths apply #466
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#466
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading howcore/importer.pytakes files out of an archive.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The profile form's call toavatars.processis now ataccounts/forms.py:397;core/importer.pyand the other locations have not moved.The archive importer caps only one kind of file. A captured page goes through
_extract_within, which asks the zip directory for the entry's size and reads at most one byte past the cap. Everything else goes through_extract, which callsarchive.read()with no limit, and so doesread_manifest. The files that come out are then stored as they are: the avatar is not passed throughavatars.processand a logo is not passed throughjobs.logos.process, which is where an SVG is sanitised and a raster image re-encoded.core/importer.py:1101-1107,_extract:return archive.read(stored_name), against_extract_withinatcore/importer.py:1066-1083, used only by_restore_kept_page(:1047-1052);core/importer.py:114-122,read_manifest:archive.read(MANIFEST_NAME)with no limit;core/importer.py:390-394: the avatar is saved withprofile.avatar.save(..., ContentFile(content), save=True); the profile form runsavatars.process(accounts/forms.py:396), which caps, decodes and re-encodes it;core/importer.py:577-581: a new company's logo is saved as it came; the other paths runjobs.logos.process(jobs/logos.py:82), which sanitises an SVG withpictures.sanitise_svgand re-encodes anything else;core/importer.py:846-851(uploads) and:880-884(sent documents): no cap, where the upload form refuses more thanMAX_UPLOAD_BYTES(documents/forms.py:26, 20 MB).import_datacommand (core/management/commands/import_data.py) on an archive; there is no web path to the archive importer. The realistic case is an archive somebody else produced and handed over for restoring.serve_private_filesends every private file withdefault-src 'none'; sandbox(core/files.py:62), so a script inside an imported SVG does not run when the file is visited, and the memory cost falls on a one-off command, not on the web server. Butjobs/logos.pysays of the sanitiser and the hardened serving that "the two are halves of one defence and neither is enough alone", and the importer supplies only one of them; it also contradicts its own capture path, which was capped for exactly this reason._extract_withinwith a cap per kind (avatars.MAX_UPLOAD_BYTES,logos.MAX_BYTES,documents.forms.MAX_UPLOAD_BYTES), and read the manifest through it too with a generous cap. Pass an imported avatar throughavatars.processand an imported logo throughjobs.logos.process, saving under the extensionprocessreturns. When a file is over its cap or refused, leave the field empty and add a line toreport.skipped, as the capture path already does.Done when
tests/security/test_files.pybuilds an archive whose upload entry declares more thanMAX_UPLOAD_BYTES, runsimporter.load, and finds the upload restored without its file and a line inreport.skipped.tests/security/test_files.pyimports an archive whose company logo is an SVG carrying a<script>element and finds the stored logo without it (or refused and reported).tests/test_avatars.pyimports an avatar larger thanavatars.MAX_UPLOAD_BYTES, or one that is not an image, and finds it refused and reported.read_wheelreads a wheel's metadata members without a size cap #969