Files
Spoolman/tests/test_extra_field_registry.py
Donkie 7d683eb246 security: escape CSV formulas and bound extra-field sizes
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.
2026-07-27 19:58:52 +02:00

154 lines
5.9 KiB
Python

"""Tests for validating and cache-invalidating extra fields written through the settings endpoint.
The /field endpoints validate what they write, but the generic /setting/{key} endpoint did not.
A malformed extra_fields_* array written that way was accepted and then failed to parse on every
later read, leaving GET /field/{entity} permanently returning 500.
"""
import json
import pytest
from spoolman import extra_field_registry as registry
from spoolman.extra_field_registry import EntityType, ExtraField
def field(**overrides: object) -> dict[str, object]:
"""Build a valid extra-field payload, with any part of it overridden."""
return {"key": "batch", "entity_type": "spool", "name": "Batch", "field_type": "text"} | overrides
def test_entity_type_of_setting_recognises_each_entity():
assert registry.entity_type_of_setting("extra_fields_spool") is EntityType.spool
assert registry.entity_type_of_setting("extra_fields_filament") is EntityType.filament
assert registry.entity_type_of_setting("extra_fields_vendor") is EntityType.vendor
def test_entity_type_of_setting_ignores_other_settings():
assert registry.entity_type_of_setting("currency") is None
assert registry.entity_type_of_setting("base_url") is None
assert registry.entity_type_of_setting("extra_fields_nonsense") is None
def test_valid_extra_fields_pass():
registry.validate_extra_field_setting("extra_fields_spool", json.dumps([field()]))
def test_empty_array_passes():
registry.validate_extra_field_setting("extra_fields_spool", json.dumps([]))
def test_other_settings_are_not_validated():
"""Only extra_fields_* settings carry a registry; everything else must pass through."""
registry.validate_extra_field_setting("currency", json.dumps("EUR"))
def test_non_array_is_rejected():
with pytest.raises(ValueError, match="must be an array"):
registry.validate_extra_field_setting("extra_fields_spool", json.dumps({"key": "batch"}))
def test_field_missing_required_keys_is_rejected():
"""The exact shape from the audit: a bare {"key": ...} used to be accepted."""
with pytest.raises(ValueError, match="index 0"):
registry.validate_extra_field_setting("extra_fields_spool", json.dumps([{"key": "nope"}]))
def test_mismatched_entity_type_is_rejected():
payload = json.dumps([field(entity_type="vendor")])
with pytest.raises(ValueError, match="entity type"):
registry.validate_extra_field_setting("extra_fields_spool", payload)
def test_invalid_key_pattern_is_rejected():
payload = json.dumps([field(key="Not A Key")])
with pytest.raises(ValueError, match="index 0"):
registry.validate_extra_field_setting("extra_fields_spool", payload)
def test_choice_field_without_choices_is_rejected():
"""validate_extra_field's rules must apply here too, not just on the /field endpoints."""
payload = json.dumps([field(field_type="choice")])
with pytest.raises(ValueError, match="index 0"):
registry.validate_extra_field_setting("extra_fields_spool", payload)
def test_duplicate_keys_are_rejected():
payload = json.dumps([field(), field(name="Batch again")])
with pytest.raises(ValueError, match="duplicate"):
registry.validate_extra_field_setting("extra_fields_spool", payload)
def test_the_offending_index_is_reported():
payload = json.dumps([field(), field(key="second", name="")])
with pytest.raises(ValueError, match="index 1"):
registry.validate_extra_field_setting("extra_fields_spool", payload)
def test_invalidate_drops_only_the_matching_entity(monkeypatch: pytest.MonkeyPatch):
cache = {
EntityType.spool: [ExtraField.model_validate(field())],
EntityType.vendor: [],
}
monkeypatch.setattr(registry, "extra_field_cache", cache)
registry.invalidate_extra_field_cache("extra_fields_spool")
assert EntityType.spool not in cache
assert EntityType.vendor in cache
def test_invalidate_ignores_unrelated_settings(monkeypatch: pytest.MonkeyPatch):
cache = {EntityType.spool: []}
monkeypatch.setattr(registry, "extra_field_cache", cache)
registry.invalidate_extra_field_cache("currency")
assert EntityType.spool in cache
def test_invalidate_is_safe_when_nothing_is_cached(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(registry, "extra_field_cache", {})
registry.invalidate_extra_field_cache("extra_fields_spool")
def test_oversized_value_is_rejected():
"""A 2 MB value on one spool used to be accepted: unbounded growth from a single request."""
spec = ExtraField.model_validate(field())
oversized = json.dumps("x" * (registry.EXTRA_FIELD_VALUE_MAX_LENGTH + 1))
with pytest.raises(ValueError, match="too big"):
registry.validate_extra_field_value(spec, oversized)
def test_value_at_the_limit_is_accepted():
spec = ExtraField.model_validate(field())
at_limit = json.dumps("x" * (registry.EXTRA_FIELD_VALUE_MAX_LENGTH - 2))
assert len(at_limit) == registry.EXTRA_FIELD_VALUE_MAX_LENGTH
registry.validate_extra_field_value(spec, at_limit)
def test_oversized_value_is_rejected_through_the_dict_validator():
"""The spool/filament/vendor endpoints go through here, and they 400 on ValueError."""
spec = ExtraField.model_validate(field())
oversized = json.dumps("x" * (registry.EXTRA_FIELD_VALUE_MAX_LENGTH + 1))
with pytest.raises(ValueError, match="too big"):
registry.validate_extra_field_dict([spec], {"batch": oversized})
def test_too_many_fields_are_rejected():
payload = json.dumps(
[field(key=f"f{i}", name=f"Field {i}") for i in range(registry.MAX_EXTRA_FIELDS_PER_ENTITY + 1)],
)
with pytest.raises(ValueError, match="maximum is"):
registry.validate_extra_field_setting("extra_fields_spool", payload)
def test_field_count_at_the_limit_is_accepted():
payload = json.dumps(
[field(key=f"f{i}", name=f"Field {i}") for i in range(registry.MAX_EXTRA_FIELDS_PER_ENTITY)],
)
registry.validate_extra_field_setting("extra_fields_spool", payload)