mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 10:02:17 -05:00
Two lower-severity findings from the audit (todo.md tasks 6 and 7). A spreadsheet executes a cell that begins with =, +, -, @, tab or CR, so a vendor named `=cmd|' /C calc'!A0` runs when someone opens the CSV export (CWE-1236). Such cells are now prefixed with a single quote, which spreadsheets read as "this is literal text" and do not display. Only strings are escaped -- numbers reach the writer as numeric types, so a negative number cannot be mistaken for a formula and must not grow a stray quote. Header names need no escaping: they are fixed attribute names or extra-field keys, which are already constrained to ^[a-z0-9_]+$. Exports also gained Content-Disposition, so they download under a sensible name rather than rendering in the browser. Extra-field values had no size limit and the columns are Text(), so a single request could store megabytes: unbounded database growth, and the resulting websocket event overflowed the 1 MiB default frame limit, which killed live updates for every connected client. Values are now capped at the same 65535 characters as settings. The number of extra fields per entity is capped too -- the per-entity key count was already bounded, since unknown keys are rejected, but the registry itself could be grown without limit, and it is cached in memory and embedded in every entity response. Finally, a setting larger than the column limit returned 500; the ValueError is now caught and returned as a 400. Verified end-to-end: a 2 MB extra value is refused on both create and update, an oversized setting returns 400 and leaves the setting unset, normal values still work, and a vendor named `=cmd|' /C calc'!A0` exports as `'=cmd|' /C calc'!A0` while the JSON export keeps the raw name.
95 lines
2.7 KiB
Python
95 lines
2.7 KiB
Python
"""Functions for exporting data."""
|
|
|
|
import io
|
|
from collections.abc import Iterable
|
|
from enum import Enum
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Query, Response
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from spoolman.database import filament, spool, vendor
|
|
from spoolman.database.database import get_db_session
|
|
from spoolman.database.models import Base
|
|
from spoolman.export import dump_as_csv, dump_as_json
|
|
|
|
# ruff: noqa: D103
|
|
router = APIRouter(
|
|
prefix="/export",
|
|
tags=["export"],
|
|
)
|
|
|
|
|
|
class ExportFormat(Enum):
|
|
CSV = "csv"
|
|
JSON = "json"
|
|
|
|
|
|
@router.get(
|
|
"/spools",
|
|
name="Export spools",
|
|
description="Export the list of spools in various formats. Filament and vendor data is included.",
|
|
)
|
|
async def export_spools(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
fmt: ExportFormat,
|
|
allow_archived: Annotated[
|
|
bool,
|
|
Query(description="Whether to include archived spools in the export."),
|
|
] = False,
|
|
) -> Response:
|
|
all_spools, _ = await spool.find(db=db, allow_archived=allow_archived)
|
|
return await _export(all_spools, fmt, "spools")
|
|
|
|
|
|
@router.get(
|
|
"/filaments",
|
|
name="Export filaments",
|
|
description="Export the list of filaments in various formats. Vendor data is included.",
|
|
)
|
|
async def export_filaments(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
fmt: ExportFormat,
|
|
) -> Response:
|
|
all_filaments, _ = await filament.find(db=db)
|
|
return await _export(all_filaments, fmt, "filaments")
|
|
|
|
|
|
@router.get(
|
|
"/vendors",
|
|
name="Export vendors",
|
|
description="Export the list of vendors in various formats.",
|
|
)
|
|
async def export_vendors(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
fmt: ExportFormat,
|
|
) -> Response:
|
|
all_vendors, _ = await vendor.find(db=db)
|
|
return await _export(all_vendors, fmt, "vendors")
|
|
|
|
|
|
async def _export(objects: Iterable[Base], fmt: ExportFormat, name: str) -> Response:
|
|
"""Export the objects in various formats."""
|
|
buffer = io.StringIO()
|
|
media_type = ""
|
|
|
|
if fmt == ExportFormat.CSV:
|
|
media_type = "text/csv"
|
|
await dump_as_csv(objects, buffer)
|
|
elif fmt == ExportFormat.JSON:
|
|
media_type = "application/json"
|
|
await dump_as_json(objects, buffer)
|
|
else:
|
|
raise ValueError(f"Unknown export format: {fmt}")
|
|
|
|
return Response(
|
|
content=buffer.getvalue(),
|
|
media_type=media_type,
|
|
# Offer it as a download with a sensible name rather than letting the browser render it.
|
|
# `name` is a fixed literal per endpoint, never user input.
|
|
headers={"Content-Disposition": f'attachment; filename="{name}.{fmt.value}"'},
|
|
)
|