Repository navigation
Add scene-witness reducer (checkpoint: contract module saved) #232
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’ll 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
6c7ca23
14dd051
903d86f
7133283
6200098
9176125
4a9fa44
5682a67
5a4b4f4
86b2b4d
22292a8
a726199
da6d308
0ac96a3
3baa338
6c7bea1
1d1e7e1
3fb7303
a8182aa
468cb86
62bd963
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,12 +53,31 @@ def _limit_bytes(env: str, default_mb: float) -> int: | |
| return int(mb * _MB) | ||
|
|
||
|
|
||
| def _limit_int(env: str, default: int) -> int: | ||
| raw = os.environ.get(env) | ||
| if raw is None: | ||
| return default | ||
| try: | ||
| value = int(raw) | ||
| except ValueError: | ||
| value = -1 | ||
| if value <= 0: | ||
| raise ValueError(f"{env}={raw!r} must be a positive whole number") | ||
| return value | ||
|
|
||
|
|
||
| # Read once at import so a bad value stops the server at startup. Tests monkeypatch these names. | ||
| MAX_UPLOAD_BYTES = _limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256) | ||
| MAX_UNZIPPED_BYTES = _limit_bytes("HOUSESCAN_MAX_UNZIPPED_MB", 512) | ||
| # Parsing JSON takes many times its size in memory (10.5 MB took 255 MB), and a real scene.json | ||
| # is well under 1 MB, so scene.json itself is capped far below the bundle. | ||
| MAX_SCENE_BYTES = _limit_bytes("HOUSESCAN_MAX_SCENE_MB", 10) | ||
| # A bundle's central directory is parsed in full the moment ZipFile is built, so the directory's | ||
| # entry count and size are read from its end record BEFORE the bundle is opened. 20,000 entries | ||
| # is far above any real walk's file count, and a directory holding them is far under this byte | ||
| # cap; together they keep a crafted bundle from spending the request on metadata alone. | ||
| MAX_ZIP_ENTRIES = _limit_int("HOUSESCAN_MAX_ZIP_ENTRIES", 20_000) | ||
| MAX_ZIP_DIRECTORY_KB = _limit_int("HOUSESCAN_MAX_ZIP_DIRECTORY_KB", 8_192) | ||
|
|
||
| # Loaded at import: invalid rules must stop the server before it answers anything. | ||
| LOADED: LoadedRules = load_rules() | ||
|
|
@@ -181,6 +200,8 @@ def _policy() -> dict[str, Any]: | |
| "version": p.version, | ||
| # Same rule as solver.solve: no policy id means nothing is approved automatically. | ||
| "auto_approve": p.auto_approve and p.id is not None, | ||
| # Same rule as solver.solve: reported so a client can ask why nothing was rejected. | ||
| "allow_reject": p.allow_reject, | ||
| "sources": list(LOADED.sources), | ||
| "rules_sha256": LOADED.sha256, | ||
| "notice": p.notice, | ||
|
|
@@ -298,8 +319,39 @@ def _check_scene_size(size: int) -> None: | |
| ) | ||
|
|
||
|
|
||
| def _zip_directory(data: bytes) -> tuple[int, int]: | ||
| """The central directory's entry count and size, read from the zip's end record before any | ||
| metadata is parsed. `zipfile.ZipFile` reads the whole directory and builds every entry while | ||
| it is constructed, so a later count comes too late. `zipfile._EndRecData` is the function | ||
| ZipFile itself uses (ZIP64 included), so the sizes checked here are the ones it will read.""" | ||
| try: | ||
| with io.BytesIO(data) as f: | ||
| end = zipfile._EndRecData(f) | ||
| except OSError: | ||
| end = None | ||
|
Comment on lines
+327
to
+331
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.
For a ZIP carrying a ZIP64 locator with a corrupt offset or unsupported multi-disk metadata, Useful? React with 👍 / 👎. |
||
| if not end: | ||
| raise ApiError( | ||
| 400, "unreadable_zip", "The bundle has no zip end-of-central-directory record." | ||
| ) | ||
| return end[zipfile._ECD_ENTRIES_TOTAL], end[zipfile._ECD_SIZE] | ||
|
|
||
|
|
||
| def _open_bundle(data: bytes) -> tuple[bytes, set[str], str]: | ||
| """scene.json's bytes, every file name in the bundle, and the folder scene.json sits in.""" | ||
| entries, directory_bytes = _zip_directory(data) | ||
| if entries > MAX_ZIP_ENTRIES: | ||
|
Comment on lines
+341
to
+342
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.
Count the directory records within the byte cap before constructing How this was verified: The upload supplies the claimed count, while Prompt To Fix With AIThis is a comment left during a code review.
Path: server/api.py
Line: 341-342
Comment:
**ZIP entry cap is bypassed**
`_zip_directory()` trusts the entry count claimed by the uploaded ZIP. Python's `ZipFile` ignores that count and reads entries until it reaches the declared directory byte size. A ZIP containing over 20,000 short entries can claim zero entries and remain below the 8 MB directory cap, bypassing `MAX_ZIP_ENTRIES`.
Count the directory records within the byte cap before constructing `ZipFile`, and add a test with a false claimed count. The directory byte cap still limits the overall size.
**How this was verified:** The upload supplies the claimed count, while `ZipFile` reads directory records using the byte size rather than that count.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| raise ApiError( | ||
| 413, | ||
| "bundle_too_many_entries", | ||
| f"The bundle lists {entries} entries, over the {MAX_ZIP_ENTRIES} limit.", | ||
| ) | ||
|
Comment on lines
+341
to
+347
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.
A crafted archive can underreport the entry count in its end-of-central-directory record while retaining a much larger central directory. Python's Useful? React with 👍 / 👎. |
||
| if directory_bytes > MAX_ZIP_DIRECTORY_KB * 1024: | ||
| raise ApiError( | ||
| 413, | ||
| "bundle_directory_too_large", | ||
| f"The bundle's zip directory is {directory_bytes} bytes, over the " | ||
| f"{MAX_ZIP_DIRECTORY_KB} KB limit.", | ||
| ) | ||
| try: | ||
| zf = zipfile.ZipFile(io.BytesIO(data)) | ||
| except (zipfile.BadZipFile, zlib.error, ValueError, EOFError) as exc: | ||
|
|
||
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.
_EndRecData()can raiseBadZipFilewhen a ZIP64 locator says the archive spans multiple disks. The new call happens before the existingBadZipFilehandler, and_zip_directory()catches onlyOSError. Such an upload now returns 500internal_errorinstead of 400unreadable_zip.Catch
BadZipFilehere and preserve the existing refusal.Prompt To Fix With AI