⚡ Sangsakala Proxy 🏠 Home
Skip to content

Commit d5f222e

Browse files
mnriemCopilot
andcommitted
fix: cap cumulative GNU and PAX archive metadata
Enforce a per-archive metadata payload budget before parsing every GNU/PAX header, including per-member headers. Cover exact cumulative boundaries, archive state isolation, ordinary file payloads, and caller diagnostics. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 36566e4 commit d5f222e

6 files changed

Lines changed: 89 additions & 5 deletions

File tree

‎docs/reference/catalogs.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ installing or running components from an unfamiliar project.
112112
Shared archive validation limits the decompressed tar.gz stream to 54 MiB,
113113
including tar headers, padding, and GNU/PAX metadata as well as file contents.
114114
Each GNU/PAX metadata payload is also capped at 64 KiB before it is read or
115-
parsed. Global PAX payloads have a tighter cumulative budget of 8 KiB per
115+
parsed, with a cumulative budget of 1 MiB for all metadata payloads per archive.
116+
Global PAX payloads have a tighter cumulative budget of 8 KiB per
116117
archive because their fields are copied into retained members. Metadata chains
117118
are limited to 32 headers, and negative metadata sizes are rejected.
118119
These limits apply during format detection and extraction; ordinary member-size

‎src/specify_cli/_download_security.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
# Include tar headers, padding, and GNU/PAX metadata, not just file payloads.
3737
MAX_TAR_STREAM_BYTES = MAX_ZIP_TOTAL_BYTES + 4 * 1024 * 1024
3838
MAX_TAR_METADATA_BYTES = 64 * 1024
39+
# Per-member metadata dictionaries are retained during archive validation.
40+
MAX_TAR_TOTAL_METADATA_BYTES = 1 * 1024 * 1024
3941
# Global fields are copied into every retained member.
4042
MAX_TAR_GLOBAL_PAX_BYTES = 8 * 1024
4143
MAX_TAR_METADATA_DEPTH = 32
@@ -146,6 +148,7 @@ def _proc_gnusparse_10(
146148

147149
class _BoundedTarFile(tarfile.TarFile):
148150
tarinfo = _BoundedTarInfo
151+
_metadata_bytes: int = 0
149152
_global_pax_bytes: int = 0
150153
_metadata_depth: int = 0
151154

@@ -156,13 +159,19 @@ def _metadata_header(self, member: tarfile.TarInfo) -> Iterator[None]:
156159
"tar.gz metadata chain exceeds maximum depth "
157160
f"of {MAX_TAR_METADATA_DEPTH}"
158161
)
162+
if self._metadata_bytes + member.size > MAX_TAR_TOTAL_METADATA_BYTES:
163+
raise _TarLimitError(
164+
"tar.gz archive metadata exceeds maximum cumulative size "
165+
f"of {MAX_TAR_TOTAL_METADATA_BYTES} bytes"
166+
)
159167
if member.type == tarfile.XGLTYPE:
160168
if self._global_pax_bytes + member.size > MAX_TAR_GLOBAL_PAX_BYTES:
161169
raise _TarLimitError(
162170
"tar.gz global PAX metadata exceeds maximum cumulative size "
163171
f"of {MAX_TAR_GLOBAL_PAX_BYTES} bytes"
164172
)
165173
self._global_pax_bytes += member.size
174+
self._metadata_bytes += member.size
166175
self._metadata_depth += 1
167176
try:
168177
yield

‎tests/archive_helpers.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def tar_metadata_record(metadata_type: bytes, size: int, key: str = "comment") -
1010
info.type = metadata_type
1111
info.size = size
1212
if metadata_type in (tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK):
13-
payload = b"file.txt\0".ljust(size, b"\0")
13+
payload = b"file.txt\0"[:size].ljust(size, b"\0")
1414
elif size:
1515
prefix = f"{size} {key}=".encode()
1616
payload = prefix + b"x" * (size - len(prefix) - 1) + b"\n"
@@ -30,8 +30,10 @@ def tar_global_pax_archive(sizes: list[int], *, interleaved: bool) -> bytes:
3030
return gzip.compress(b"".join(records) + b"\0" * tarfile.BLOCKSIZE)
3131

3232

33-
def tar_metadata_chain_archive(count: int, metadata_type: bytes = tarfile.XHDTYPE) -> bytes:
34-
plain = tar_metadata_record(metadata_type, 32) * count
33+
def tar_metadata_chain_archive(
34+
count: int, metadata_type: bytes = tarfile.XHDTYPE, *, size: int = 32
35+
) -> bytes:
36+
plain = tar_metadata_record(metadata_type, size) * count
3537
return gzip.compress(
3638
plain + tarfile.TarInfo("file.txt").tobuf() + b"\0" * tarfile.BLOCKSIZE
3739
)
@@ -47,6 +49,8 @@ def tar_negative_metadata_archive(metadata_type: bytes) -> bytes:
4749

4850

4951
def tar_safety_archive(kind: str) -> bytes:
52+
if kind == "total-metadata":
53+
return tar_metadata_chain_archive(17, size=65536)
5054
if kind == "global-pax":
5155
return tar_global_pax_archive([1024] * 9, interleaved=False)
5256
if kind == "metadata-chain":

‎tests/specify_cli/extensions/test_command_add.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class TestExtensionAddCLI:
5151
("global-pax", "global PAX metadata"),
5252
("metadata-chain", "metadata chain"),
5353
("negative-metadata", "negative size"),
54+
("total-metadata", "archive metadata exceeds"),
5455
],
5556
)
5657
@pytest.mark.parametrize("suffix", ["ext.tar.gz", "assets/42"])

‎tests/specify_cli/workflows/test_command_add.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,6 +1395,7 @@ def test_add_from_suffixless_url_sniffs_archive(
13951395
("global-pax", "global PAX metadata"),
13961396
("metadata-chain", "metadata chain"),
13971397
("negative-metadata", "negative size"),
1398+
("total-metadata", "archive metadata exceeds"),
13981399
],
13991400
)
14001401
def test_suffixless_download_preserves_archive_safety_errors(

‎tests/test_download_security.py‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
[
4444
("global-pax", "global PAX.*cumulative size.*8192"),
4545
("metadata-chain", "metadata chain.*maximum depth.*32"),
46+
("total-metadata", "archive metadata.*cumulative size.*1048576"),
4647
],
4748
)
4849
def test_tar_archive_metadata_budgets_reject_before_parsing(
@@ -86,6 +87,73 @@ def bounded_parser(member, archive):
8687
assert not out_dir.exists()
8788

8889

90+
@pytest.mark.parametrize("metadata_type", [tarfile.XHDTYPE, tarfile.SOLARIS_XHDTYPE])
91+
@pytest.mark.parametrize("over_budget", [False, True])
92+
def test_per_member_metadata_cumulative_boundary(
93+
tmp_path, monkeypatch, metadata_type, over_budget
94+
):
95+
plain = b""
96+
for index, size in enumerate([65536] * 15 + [65535]):
97+
plain += tar_metadata_record(metadata_type, size)
98+
plain += tarfile.TarInfo(f"file{index}.txt").tobuf()
99+
plain += tar_metadata_record(tarfile.GNUTYPE_LONGLINK, 2 if over_budget else 1)
100+
plain += tarfile.TarInfo("last.txt").tobuf()
101+
archive_path = tmp_path / "per-member.tar.gz"
102+
archive_path.write_bytes(gzip.compress(plain + b"\0" * tarfile.BLOCKSIZE))
103+
original_parser = tarfile.TarInfo._proc_member
104+
parsed_bytes = {}
105+
106+
def bounded_parser(member, archive):
107+
if member.type in (
108+
tarfile.XHDTYPE, tarfile.SOLARIS_XHDTYPE, tarfile.GNUTYPE_LONGLINK
109+
):
110+
parsed_bytes[archive] = parsed_bytes.get(archive, 0) + member.size
111+
assert parsed_bytes[archive] <= 1048576, "Excess metadata reached parser"
112+
return original_parser(member, archive)
113+
114+
monkeypatch.setattr(tarfile.TarInfo, "_proc_member", bounded_parser)
115+
if over_budget:
116+
with pytest.raises(_CustomZipError, match="archive metadata.*cumulative size.*1048576"):
117+
safe_extract_archive(
118+
archive_path, tmp_path / "out", error_type=_CustomZipError
119+
)
120+
assert not (tmp_path / "out").exists()
121+
else:
122+
for index in range(2):
123+
out_dir = tmp_path / f"out{index}"
124+
assert safe_extract_archive(archive_path, out_dir) == "tar.gz"
125+
assert len(list(out_dir.iterdir())) == 17
126+
127+
128+
@pytest.mark.parametrize(
129+
"metadata_type",
130+
[tarfile.XHDTYPE, tarfile.XGLTYPE, tarfile.SOLARIS_XHDTYPE,
131+
tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK],
132+
)
133+
@pytest.mark.parametrize("over_budget", [False, True])
134+
def test_cumulative_metadata_budget_includes_every_header_type(
135+
tmp_path, monkeypatch, metadata_type, over_budget
136+
):
137+
monkeypatch.setattr(
138+
_download_security, "MAX_TAR_TOTAL_METADATA_BYTES", 64, raising=False
139+
)
140+
plain = tar_metadata_record(tarfile.XGLTYPE, 32)
141+
plain += tarfile.TarInfo("first.txt").tobuf()
142+
plain += tar_metadata_record(metadata_type, 33 if over_budget else 32)
143+
plain += tarfile.TarInfo("last.txt").tobuf()
144+
archive_path = tmp_path / "mixed.tar.gz"
145+
archive_path.write_bytes(gzip.compress(plain + b"\0" * tarfile.BLOCKSIZE))
146+
if over_budget:
147+
with pytest.raises(_CustomZipError, match="archive metadata.*cumulative size.*64"):
148+
safe_extract_archive(
149+
archive_path, tmp_path / "out", error_type=_CustomZipError
150+
)
151+
assert not (tmp_path / "out").exists()
152+
else:
153+
assert safe_extract_archive(archive_path, tmp_path / "out") == "tar.gz"
154+
assert len(list((tmp_path / "out").iterdir())) == 2
155+
156+
89157
@pytest.mark.parametrize("extractor", [safe_extract_tar, safe_extract_archive])
90158
@pytest.mark.parametrize("over_budget", [False, True])
91159
def test_interleaved_global_pax_budget_is_cumulative(
@@ -368,7 +436,7 @@ def test_tiny_compressed_pax_archive_rejects_large_metadata(tmp_path):
368436

369437
def test_tar_metadata_limit_does_not_limit_regular_file_payload(tmp_path):
370438
archive_path = tmp_path / "payload.tar.gz"
371-
contents = b"x" * 65537
439+
contents = b"x" * 1048577
372440
_write_tar_gz(archive_path, [("file.txt", contents)])
373441
out_dir = tmp_path / "out"
374442

0 commit comments

Comments
 (0)