Files
ImpTune/tests/test_intunewin.py
kawaandClaude Opus 5 b397d3dc3d feat: memory-only sessions on HTTP, streamed exports, UI refresh
Session

- COOKIE_SECURE=false no longer persists the owner key for ten years.
  services/session.cookie_kwargs() drops max_age in that mode, so the
  browser holds the key in memory and the session ends with the window.
  Everything still persists server-side; only the browser link is
  temporary. base.html shows a warning banner (FR/EN) and an extra
  paragraph in the onboarding modal, and the README explains the
  trade-off and the backup-key escape hatch.
- Both cookie writers (middleware, POST /session/restore) go through
  cookie_kwargs() so the policy cannot drift between them.
- The CSRF guard on /session/restore compared request.url.scheme against
  the Origin header. Behind a TLS-terminating proxy uvicorn sees http
  while the browser sends https, so every legitimate restore was
  rejected with 403. It now compares hosts only, including
  X-Forwarded-Host.
- /static/*, /favicon.ico and /robots.txt skip the middleware. Each
  cookieless hit was inserting an Owner row no browser could ever use.

Reliability

- Malformed printer-form FK fields no longer escape as HTTP 500:
  a non-numeric client_id/driver_id raised ValueError and an unknown
  driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX
  fragments, and the duplicated field checks moved into
  _validate_fields().
- Package exports stream. build_intunewin() encrypts the inner ZIP in
  1 MB chunks against temp files with a streaming HMAC and SHA256, and
  both endpoints serve the result with FileResponse plus a background
  cleanup task. A 100 MB driver used to be held in memory three or four
  times over per concurrent download. The byte layout is unchanged.
- FileResponse also escapes the download filename, which was previously
  interpolated raw into Content-Disposition.
- python-multipart >= 0.0.18 (CVE-2024-53981, reachable from
  /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from
  icon upload).
- icons.py reads cfg.ICONS_DIR instead of re-deriving the path from
  DATA_DIR, matching the .intunewin export.

UI

- Sidebar/topbar shell, inline SVG icon macros (partials/icons.html),
  card and data-table components, grouped printer list, and the
  dedicated /printers/new page replacing partials/printer_form.html.

Tests

- 194 pass with a bare `pytest tests/`: tests/conftest.py now forces
  cfg.COOKIE_SECURE = False like the e2e conftest already did, so the
  Secure cookie is no longer dropped over http://testserver.
- New coverage for the malformed-FK guards, the chunk-boundary cases in
  the encrypt loop (every residue mod _CHUNK plus a multi-megabyte
  payload), temp-dir cleanup after both exports, and the whole
  COOKIE_SECURE matrix.
- test_printer_edit.py located the Edit button by its translated label,
  so it only passed on English-locale machines. It now targets the
  showModal() hook, which also cuts the e2e run from 84s to 15s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:49 +02:00

352 lines
15 KiB
Python

"""
Byte-level validation tests for the .intunewin file format.
These tests serve as the format specification: if they pass, the byte layout is correct.
The only remaining validation is a real Intune upload (manual, Phase 10 gate).
Detection.xml format follows the IntuneWinAppUtil.exe reference exactly:
- ToolVersion is an XML *attribute* on <ApplicationInfo> (not a child element)
- No xmlns namespace (reference uses [XmlRoot("ApplicationInfo")] with no Namespace param)
- No <?xml?> declaration header (reference uses OmitXmlDeclaration=true)
- No MacAlgorithm element (not present in reference FileEncryptionInfo model)
"""
import base64
import hashlib
import hmac
import io
import os
import xml.etree.ElementTree as ET
import zipfile
import pytest
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from imptune.generators.intunewin_builder import _TOOL_VERSION, build_intunewin
@pytest.fixture
def source_dir(tmp_path):
"""Create a small test source directory with a few files."""
src = tmp_path / "source"
src.mkdir()
(src / "install.ps1").write_text("Write-Host 'Installing printer...'")
(src / "config.json").write_text('{"printer": "HP LaserJet"}')
(src / "readme.txt").write_text("Printer deployment package")
return str(src)
@pytest.fixture
def built_package(source_dir, tmp_path):
"""Build an .intunewin package and return the path."""
output = str(tmp_path / "package.intunewin")
build_intunewin(source_dir, "install.ps1", output)
return output
@pytest.fixture
def package_contents(built_package):
"""Extract outer ZIP contents and parsed Detection.xml."""
with zipfile.ZipFile(built_package, "r") as outer:
names = outer.namelist()
blob = outer.read("IntuneWinPackage/Contents/IntunePackage.intunewin")
detection_xml_bytes = outer.read("IntuneWinPackage/Metadata/Detection.xml")
tree = ET.fromstring(detection_xml_bytes.decode("utf-8"))
return {
"names": names,
"blob": blob,
"detection_xml_bytes": detection_xml_bytes,
"tree": tree,
}
def _get_xml_text(tree, tag):
"""Get text of a direct child element (no namespace — reference omits xmlns)."""
elem = tree.find(tag)
return elem.text if elem is not None else None
def _get_encryption_info(tree):
"""Get EncryptionInfo sub-element (no namespace — reference omits xmlns)."""
return tree.find("EncryptionInfo")
def _get_enc_text(tree, tag):
"""Get text of a child of EncryptionInfo (no namespace)."""
enc = _get_encryption_info(tree)
if enc is None:
return None
elem = enc.find(tag)
return elem.text if elem is not None else None
class TestOuterZipStructure:
def test_output_is_valid_zip(self, built_package):
"""build_intunewin() output file is a valid ZIP archive."""
assert zipfile.is_zipfile(built_package), "Output file must be a valid ZIP archive"
def test_outer_zip_structure(self, package_contents):
"""Outer ZIP contains exactly the two required entries."""
names = set(package_contents["names"])
assert "IntuneWinPackage/Contents/IntunePackage.intunewin" in names
assert "IntuneWinPackage/Metadata/Detection.xml" in names
def test_outer_zip_stored(self, built_package):
"""Outer ZIP entries use ZIP_STORED compression (no extra compression on encrypted content)."""
with zipfile.ZipFile(built_package, "r") as outer:
for info in outer.infolist():
assert info.compress_type == zipfile.ZIP_STORED, (
f"Entry {info.filename} must use ZIP_STORED, got compress_type={info.compress_type}"
)
class TestDetectionXml:
def test_detection_xml_valid(self, package_contents):
"""Detection.xml is valid XML with ApplicationInfo root element and ToolVersion attribute.
Reference format: <ApplicationInfo ToolVersion="1.8.6.0"> with NO xmlns namespace.
Presence of xmlns would change element identity for Intune's XML parser, causing
silent metadata-parse failure in the upload wizard.
"""
tree = package_contents["tree"]
assert tree.tag == "ApplicationInfo", (
f"Root element must be plain 'ApplicationInfo' (no xmlns namespace), got {tree.tag!r}"
)
assert tree.get("ToolVersion") == _TOOL_VERSION, (
f"ApplicationInfo must have ToolVersion attribute = {_TOOL_VERSION!r}, "
f"got {tree.get('ToolVersion')!r}"
)
def test_detection_xml_fields(self, package_contents):
"""Detection.xml contains all required elements matching the reference FileEncryptionInfo model.
The reference model has 7 EncryptionInfo sub-elements (MacAlgorithm is NOT present).
"""
tree = package_contents["tree"]
# Direct children
for field in ("Name", "UnencryptedContentSize", "FileName", "SetupFile"):
assert _get_xml_text(tree, field) is not None, f"Missing field: {field}"
# EncryptionInfo sub-elements — 7 required (MacAlgorithm absent per reference schema)
for field in (
"EncryptionKey",
"MacKey",
"InitializationVector",
"Mac",
"ProfileIdentifier",
"FileDigest",
"FileDigestAlgorithm",
):
assert _get_enc_text(tree, field) is not None, f"Missing EncryptionInfo/{field}"
# MacAlgorithm must NOT be present (not in reference FileEncryptionInfo model)
assert _get_enc_text(tree, "MacAlgorithm") is None, (
"EncryptionInfo/MacAlgorithm must NOT be present — not in reference schema"
)
def test_setup_file_in_detection_xml(self, package_contents):
"""SetupFile element matches the setup_file argument passed to build_intunewin."""
tree = package_contents["tree"]
assert _get_xml_text(tree, "SetupFile") == "install.ps1"
class TestEncryptedBlobLayout:
def test_encrypted_blob_layout(self, package_contents):
"""Encrypted blob starts with 32 bytes (HMAC) + 16 bytes (IV) + remainder (ciphertext)."""
blob = package_contents["blob"]
# Must be at least 48 bytes (HMAC + IV) plus at least one AES block (16 bytes)
assert len(blob) >= 64, f"Blob too short: {len(blob)} bytes"
# Total length = 48 header + ciphertext length; ciphertext length is a multiple of 16
ciphertext_len = len(blob) - 48
assert ciphertext_len > 0, "Blob has no ciphertext after header"
assert ciphertext_len % 16 == 0, (
f"Ciphertext length {ciphertext_len} must be a multiple of AES block size 16"
)
def test_iv_is_16_bytes(self, package_contents):
"""IV extracted from Detection.xml base64-decodes to exactly 16 bytes (NOT 32 — critical per RESEARCH.md)."""
tree = package_contents["tree"]
iv_b64 = _get_enc_text(tree, "InitializationVector")
assert iv_b64 is not None, "InitializationVector missing from Detection.xml"
iv = base64.b64decode(iv_b64)
assert len(iv) == 16, f"IV must be exactly 16 bytes, got {len(iv)}"
def test_encryption_key_is_32_bytes(self, package_contents):
"""EncryptionKey from Detection.xml base64-decodes to exactly 32 bytes."""
tree = package_contents["tree"]
key_b64 = _get_enc_text(tree, "EncryptionKey")
assert key_b64 is not None, "EncryptionKey missing from Detection.xml"
key = base64.b64decode(key_b64)
assert len(key) == 32, f"EncryptionKey must be exactly 32 bytes, got {len(key)}"
def test_mac_key_is_32_bytes(self, package_contents):
"""MacKey from Detection.xml base64-decodes to exactly 32 bytes."""
tree = package_contents["tree"]
mac_key_b64 = _get_enc_text(tree, "MacKey")
assert mac_key_b64 is not None, "MacKey missing from Detection.xml"
mac_key = base64.b64decode(mac_key_b64)
assert len(mac_key) == 32, f"MacKey must be exactly 32 bytes, got {len(mac_key)}"
class TestCryptographicVerification:
def test_hmac_matches(self, package_contents):
"""HMAC-SHA256 of (IV || ciphertext) matches first 32 bytes of blob AND Mac in Detection.xml.
The reference implementation (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads
the first 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
(bytes[32:] = IV || ciphertext) to verify integrity. The IV MUST be included in the
HMAC so that a forged IV cannot redirect decryption without being detected.
"""
blob = package_contents["blob"]
tree = package_contents["tree"]
blob_hmac = blob[:32]
iv_and_ciphertext = blob[32:] # IV (16 bytes) + ciphertext — what the reference hashes
mac_key_b64 = _get_enc_text(tree, "MacKey")
mac_key = base64.b64decode(mac_key_b64)
computed_hmac = hmac.new(mac_key, iv_and_ciphertext, hashlib.sha256).digest()
# Must match the blob header
assert computed_hmac == blob_hmac, (
"HMAC-SHA256 of (IV || ciphertext) does not match the first 32 bytes of the blob"
)
# Must also match Detection.xml Mac field
xml_mac = base64.b64decode(_get_enc_text(tree, "Mac"))
assert computed_hmac == xml_mac, (
"HMAC-SHA256 of (IV || ciphertext) does not match the Mac value in Detection.xml"
)
def test_decryption_roundtrip(self, source_dir, package_contents):
"""Decrypt the ciphertext and verify it is a valid ZIP containing the original source files."""
blob = package_contents["blob"]
tree = package_contents["tree"]
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
ciphertext = blob[48:]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
# Must be a valid ZIP
assert zipfile.is_zipfile(io.BytesIO(plaintext)), (
"Decrypted plaintext is not a valid ZIP file"
)
# Must contain the original source files
with zipfile.ZipFile(io.BytesIO(plaintext), "r") as inner_zip:
inner_names = set(inner_zip.namelist())
for filename in ("install.ps1", "config.json", "readme.txt"):
assert filename in inner_names, (
f"Original file {filename} not found in decrypted inner ZIP. Found: {inner_names}"
)
def test_file_digest_matches(self, package_contents):
"""FileDigest in Detection.xml matches SHA256 of the decrypted plaintext ZIP."""
blob = package_contents["blob"]
tree = package_contents["tree"]
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
ciphertext = blob[48:]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
computed_digest = hashlib.sha256(plaintext).digest()
xml_digest = base64.b64decode(_get_enc_text(tree, "FileDigest"))
assert computed_digest == xml_digest, (
"FileDigest in Detection.xml does not match SHA256 of decrypted plaintext"
)
def test_unencrypted_content_size(self, package_contents):
"""UnencryptedContentSize in Detection.xml matches byte length of decrypted plaintext ZIP."""
blob = package_contents["blob"]
tree = package_contents["tree"]
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
ciphertext = blob[48:]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
xml_size = int(_get_xml_text(tree, "UnencryptedContentSize"))
assert xml_size == len(plaintext), (
f"UnencryptedContentSize {xml_size} does not match actual plaintext size {len(plaintext)}"
)
class TestStreamingChunkBoundaries:
"""The builder encrypts the inner ZIP in _CHUNK-sized pieces rather than in
one buffer. Only the final (short) read carries PKCS7 padding, so a payload
landing exactly on a chunk boundary is the case that breaks first."""
@staticmethod
def _decrypt(built_package):
with zipfile.ZipFile(built_package, "r") as outer:
blob = outer.read("IntuneWinPackage/Contents/IntunePackage.intunewin")
tree = ET.fromstring(
outer.read("IntuneWinPackage/Metadata/Detection.xml").decode("utf-8")
)
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(blob[48:]), AES.block_size)
# MAC covers IV || ciphertext and must survive chunked hashing
mac_key = base64.b64decode(_get_enc_text(tree, "MacKey"))
assert hmac.new(mac_key, blob[32:], hashlib.sha256).digest() == blob[:32]
# Metadata is computed while streaming, not from a buffered plaintext
assert base64.b64decode(_get_enc_text(tree, "FileDigest")) == hashlib.sha256(plaintext).digest()
assert int(_get_xml_text(tree, "UnencryptedContentSize")) == len(plaintext)
return plaintext
@pytest.mark.parametrize("payload_size", range(96, 128))
def test_every_residue_of_chunk_size(self, tmp_path, monkeypatch, payload_size):
"""Sweep 32 consecutive sizes with a 16-byte chunk so every offset mod
_CHUNK is exercised, including the exact-multiple case where the last
read returns b"" and padding is a whole standalone block."""
monkeypatch.setattr("imptune.generators.intunewin_builder._CHUNK", 16)
src = tmp_path / "src"
src.mkdir()
# Incompressible, so inner-ZIP size tracks payload size 1:1
(src / "install.ps1").write_bytes(os.urandom(payload_size))
output = str(tmp_path / "package.intunewin")
build_intunewin(str(src), "install.ps1", output)
plaintext = self._decrypt(output)
assert zipfile.is_zipfile(io.BytesIO(plaintext))
def test_multi_megabyte_payload_roundtrips(self, tmp_path):
"""Several full 1 MB chunks through the default code path."""
src = tmp_path / "src"
src.mkdir()
blob = os.urandom(2_500_000)
(src / "install.ps1").write_text("Write-Host 'go'")
(src / "driver.bin").write_bytes(blob)
output = str(tmp_path / "package.intunewin")
build_intunewin(str(src), "install.ps1", output)
plaintext = self._decrypt(output)
with zipfile.ZipFile(io.BytesIO(plaintext)) as inner:
assert inner.read("driver.bin") == blob
class TestArgumentValidation:
def test_missing_source_dir_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
build_intunewin(str(tmp_path / "nope"), "install.ps1", str(tmp_path / "o.intunewin"))
def test_empty_setup_file_raises(self, source_dir, tmp_path):
with pytest.raises(ValueError):
build_intunewin(source_dir, "", str(tmp_path / "o.intunewin"))