mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52: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.
154 lines
5.9 KiB
Python
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)
|