feat(inbox): .zip container threats — zip-slip, zip-bomb, symlink (stage 2b)
The front-end reads zip entries in memory (never extracts to disk), so it owns the container caps while a traversal entry maps onto the guard's path gate: - zip-slip: a '../../evil.md' entry materializes onto a traversal concept path (preserved verbatim, not normalized) -> stage-2 T4 -> FAIL_SECURE -> REJECT. - zip-bomb: per-entry + per-archive uncompressed-size caps (OWASP LLM10) refuse an oversize entry before its bytes are read; a bounded read defends a lying header. Detach-proof: a generous cap admits the same archive, so the cap is load-bearing. - symlink entry: refused at the front-end (no legitimate concept meaning). Caps are kwargs on extract_inbox/receive (small in tests, generous by default). Tests 288 -> 293.
This commit is contained in:
parent
24e57ca10b
commit
52aa40b17a
2 changed files with 156 additions and 18 deletions
|
|
@ -14,6 +14,9 @@ showcase/dev-scoped (PLAN §247), and the core package stays stdlib-only
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
from inbox_frontend import receive, extract_inbox, InboxExtract
|
||||
from llm_ingestion_guard.disposition import Disposition
|
||||
|
||||
|
|
@ -76,6 +79,77 @@ def test_detach_proof_extraction_carries_the_payload(tmp_path, monkeypatch):
|
|||
p = _write(tmp_path, "notes.txt", "Some notes.\n" + _INJECTION + "\n")
|
||||
import inbox_frontend as fe
|
||||
|
||||
monkeypatch.setattr(fe, "extract_inbox", lambda paths: InboxExtract({}, (), ()))
|
||||
monkeypatch.setattr(fe, "extract_inbox", lambda paths, **kw: InboxExtract({}, (), ()))
|
||||
_extracted, _result, verdict = fe.receive([p])
|
||||
assert verdict == "ADMIT"
|
||||
|
||||
|
||||
# --- slice 2b: .zip container threats ---------------------------------------
|
||||
# The front-end reads zip entries in memory (never extracts to disk), so an
|
||||
# on-disk zip-slip / symlink escape cannot happen. It owns the container caps
|
||||
# (bomb / symlink); a traversal entry name becomes a concept path the guard's
|
||||
# T4 gate rejects. Zips are built in the test so the crafted entries are explicit.
|
||||
|
||||
|
||||
def _make_zip(tmp_path, entries, name="drop.zip"):
|
||||
"""Build a zip. Each entry is (name, data) or (name, data, external_attr)."""
|
||||
zp = tmp_path / name
|
||||
with zipfile.ZipFile(zp, "w") as zf:
|
||||
for entry in entries:
|
||||
if len(entry) == 3:
|
||||
ename, data, attr = entry
|
||||
info = zipfile.ZipInfo(ename)
|
||||
info.external_attr = attr
|
||||
zf.writestr(info, data)
|
||||
else:
|
||||
ename, data = entry
|
||||
zf.writestr(ename, data)
|
||||
return zp
|
||||
|
||||
|
||||
def test_zip_clean_entries_admit(tmp_path):
|
||||
zp = _make_zip(tmp_path, [
|
||||
("a.md", "---\ntype: t\n---\nA clean concept.\n"),
|
||||
("docs/b.txt", "A clean note."),
|
||||
])
|
||||
extracted, _result, verdict = receive([zp])
|
||||
assert set(extracted.bundle) == {"uploads/a.md", "uploads/docs/b.md"}
|
||||
assert verdict == "ADMIT"
|
||||
assert all(pr.source_type == "zip" for pr in extracted.provenance)
|
||||
|
||||
|
||||
def test_zip_slip_entry_is_rejected_by_the_path_gate(tmp_path):
|
||||
zp = _make_zip(tmp_path, [("../../evil.md", "---\ntype: t\n---\npayload\n")])
|
||||
_extracted, result, verdict = receive([zp])
|
||||
by_path = {c.path: c for c in result.concepts}
|
||||
slip = "uploads/../../evil.md"
|
||||
assert slip in by_path # traversal preserved
|
||||
assert by_path[slip].error is not None # T4 rejected it
|
||||
assert by_path[slip].disposition is Disposition.FAIL_SECURE
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_zip_bomb_is_refused_by_the_size_cap(tmp_path):
|
||||
zp = _make_zip(tmp_path, [("big.txt", "A" * 5000)])
|
||||
extracted, _result, verdict = receive([zp], max_entry_bytes=1024, max_total_bytes=1024)
|
||||
assert extracted.bundle == {} # never read into the bundle
|
||||
assert any("big.txt" in n for n, _reason in extracted.rejected)
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_zip_bomb_detach_proof(tmp_path):
|
||||
# The same archive under a generous cap is NOT refused -> the cap is what
|
||||
# rejected it above, not the archive shape.
|
||||
zp = _make_zip(tmp_path, [("big.txt", "A" * 5000)])
|
||||
extracted, _result, _verdict = receive([zp], max_entry_bytes=10_000, max_total_bytes=10_000)
|
||||
assert extracted.rejected == ()
|
||||
assert "uploads/big.md" in extracted.bundle
|
||||
|
||||
|
||||
def test_zip_symlink_entry_is_refused(tmp_path):
|
||||
attr = (stat.S_IFLNK | 0o777) << 16
|
||||
zp = _make_zip(tmp_path, [("link.md", "/etc/passwd", attr)])
|
||||
extracted, _result, verdict = receive([zp])
|
||||
assert any("link.md" in n for n, _reason in extracted.rejected)
|
||||
assert "uploads/link.md" not in extracted.bundle
|
||||
assert verdict == "REJECT"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue