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>
This commit is contained in:
+66
-37
@@ -1,5 +1,4 @@
|
||||
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -7,7 +6,8 @@ import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from fastapi.responses import FileResponse, PlainTextResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, Owner, Printer
|
||||
@@ -17,6 +17,11 @@ from imptune.storage.driver_store import DriverStore
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
# Driver payloads run to ~100 MB. Packages are assembled in a temp dir and
|
||||
# streamed from disk instead of being held in memory, so N concurrent
|
||||
# downloads cost N file handles rather than N × package size of RAM.
|
||||
_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int, owner: Owner):
|
||||
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
|
||||
@@ -47,6 +52,16 @@ def _get_driver_zip_path(driver) -> str:
|
||||
return str(DriverStore(cfg.DRIVERS_DIR).get_path(driver.sha256))
|
||||
|
||||
|
||||
def _streamed_download(path: str, tmpdir: str, media_type: str, filename: str) -> FileResponse:
|
||||
"""Serve a built package off disk, deleting its temp dir once sent."""
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=media_type,
|
||||
filename=filename,
|
||||
background=BackgroundTask(shutil.rmtree, tmpdir, True),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/ninja")
|
||||
def get_ninja_package(request: Request, printer_id: int):
|
||||
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
|
||||
@@ -76,22 +91,30 @@ def get_ninja_package(request: Request, printer_id: int):
|
||||
collate=printer.collate,
|
||||
)
|
||||
|
||||
# Build ZIP in-memory
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Add install script
|
||||
zf.writestr(f"{safe_name}/install.ps1", install_script)
|
||||
# Build the ZIP on disk, copying driver members through in chunks so a
|
||||
# 100 MB driver never lands in memory whole.
|
||||
tmpdir = tempfile.mkdtemp(prefix="imptune_ninja_")
|
||||
try:
|
||||
# Fixed on-disk name — the printer name only shapes the download name,
|
||||
# so a "/" or ":" in it can't break the temp path.
|
||||
out_path = os.path.join(tmpdir, "package.zip")
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(f"{safe_name}/install.ps1", install_script)
|
||||
|
||||
# Extract and re-add driver files from driver ZIP
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
for member in driver_zf.namelist():
|
||||
member_data = driver_zf.read(member)
|
||||
zf.writestr(f"{safe_name}/drivers/{member}", member_data)
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
for member in driver_zf.infolist():
|
||||
target = f"{safe_name}/drivers/{member.filename}"
|
||||
if member.is_dir():
|
||||
zf.writestr(target, b"")
|
||||
continue
|
||||
with driver_zf.open(member) as src, zf.open(target, "w") as dst:
|
||||
shutil.copyfileobj(src, dst, _CHUNK)
|
||||
except BaseException:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'},
|
||||
return _streamed_download(
|
||||
out_path, tmpdir, "application/zip", f"{safe_name}_ninja.zip"
|
||||
)
|
||||
|
||||
|
||||
@@ -130,17 +153,24 @@ def get_intunewin_package(request: Request, printer_id: int):
|
||||
)
|
||||
detect_script = render_detect(printer_name=printer.name)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="imptune_") as tmpdir:
|
||||
# Write scripts
|
||||
with open(os.path.join(tmpdir, "install.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(install_script)
|
||||
with open(os.path.join(tmpdir, "uninstall.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(uninstall_script)
|
||||
with open(os.path.join(tmpdir, "detect.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(detect_script)
|
||||
# The build output is streamed straight off disk, so the temp tree has to
|
||||
# outlive this handler — the response's background task removes it.
|
||||
tmpdir = tempfile.mkdtemp(prefix="imptune_")
|
||||
try:
|
||||
staging = os.path.join(tmpdir, "staging")
|
||||
os.makedirs(staging, exist_ok=True)
|
||||
|
||||
# Extract driver ZIP contents into tmpdir/drivers/
|
||||
drivers_subdir = os.path.join(tmpdir, "drivers")
|
||||
# Write scripts
|
||||
for filename, script in (
|
||||
("install.ps1", install_script),
|
||||
("uninstall.ps1", uninstall_script),
|
||||
("detect.ps1", detect_script),
|
||||
):
|
||||
with open(os.path.join(staging, filename), "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
|
||||
# Extract driver ZIP contents into staging/drivers/
|
||||
drivers_subdir = os.path.join(staging, "drivers")
|
||||
os.makedirs(drivers_subdir, exist_ok=True)
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
driver_zf.extractall(drivers_subdir)
|
||||
@@ -150,17 +180,16 @@ def get_intunewin_package(request: Request, printer_id: int):
|
||||
if icon_record is not None:
|
||||
icon_src = os.path.join(cfg.ICONS_DIR, icon_record.sha256)
|
||||
if os.path.isfile(icon_src):
|
||||
shutil.copy2(icon_src, os.path.join(tmpdir, "icon.png"))
|
||||
shutil.copy2(icon_src, os.path.join(staging, "icon.png"))
|
||||
|
||||
# Build .intunewin
|
||||
output_path = os.path.join(tmpdir, "out.intunewin")
|
||||
build_intunewin(tmpdir, "install.ps1", output_path)
|
||||
# Build .intunewin outside the staging dir — an output file written into
|
||||
# the tree being packaged would end up inside its own package.
|
||||
output_path = os.path.join(tmpdir, "package.intunewin")
|
||||
build_intunewin(staging, "install.ps1", output_path)
|
||||
except BaseException:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'},
|
||||
return _streamed_download(
|
||||
output_path, tmpdir, "application/octet-stream", f"{safe_name}.intunewin"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user