Files
Donkie c33f65243c security: refuse cross-origin writes and websockets, and non-JSON setting bodies
Closes the two confirmed CSRF findings and the cross-site websocket hijack
from the backend security audit (todo.md tasks 1 and 3).

POST /setting/{key} took a bare `str` body, and FastAPI only JSON-parses
application/*json -- anything else arrived as raw bytes that lax-mode Pydantic
coerced. A text/plain body was therefore accepted, which is exactly what
<form enctype="text/plain"> sends, so any website the user visited could
rewrite base_url, label_designs, locations or extra_fields_* through their
browser. Now requires a JSON content type.

The audit suggested Body(media_type="application/json") for this, but that only
annotates the OpenAPI schema and is not enforced at runtime -- a text/plain post
to a pinned endpoint still returns 200. Hence an explicit dependency. Both
clients already send application/json, and a charset parameter is accepted, so
this is wire-compatible.

TrustedOriginMiddleware refuses state-changing HTTP methods and every websocket
handshake from an untrusted origin, using the helper added in the previous
commit. A middleware rather than per-endpoint dependencies so that none of the
nine websocket endpoints can be missed, and one added later cannot forget to
opt in. Reads are left alone -- the same-origin policy already covers them --
but every websocket is guarded, since websockets are exempt from CORS and
otherwise leak the whole inventory to any origin that opens one.

It is ordered inside the CORS middleware so a legitimate cross-origin client
can read the 403 body instead of an opaque CORS error.

Writing a malformed extra_fields_* array through the settings endpoint bypassed
validate_extra_field and left GET /field/{entity} permanently returning 500;
that array is now validated, and the registry cache is invalidated on write so
the /setting and /field paths no longer disagree until restart.

Verified against a real uvicorn server by replaying the audit's exploit: the
text/plain write is refused, a bodyless form post that would have reset a
setting is refused, a websocket from a foreign origin gets HTTP 403 on the
handshake, and clients that send no Origin at all -- Moonraker, OctoPrint,
Home Assistant, curl -- are unaffected on both HTTP and websockets.
2026-07-27 19:24:59 +02:00

202 lines
5.6 KiB
Python

"""Integration tests for the Vendor API endpoint."""
import json
import httpx
from ..conftest import URL
def test_set_currency():
"""Test setting the currency setting."""
# Execute
result = httpx.post(
f"{URL}/api/v1/setting/currency",
json='"SEK"',
)
result.raise_for_status()
# Verify
setting = result.json()
assert setting == {
"value": '"SEK"',
"is_set": True,
"type": "string",
}
# Cleanup
result = httpx.post(
f"{URL}/api/v1/setting/currency",
json="",
)
result.raise_for_status()
def test_unset_currency():
"""Test un-setting the currency setting."""
# Execute set
result = httpx.post(
f"{URL}/api/v1/setting/currency",
json='"SEK"',
)
result.raise_for_status()
# Verify set
setting = result.json()
assert setting == {
"value": '"SEK"',
"is_set": True,
"type": "string",
}
# Execute unset
result = httpx.post(
f"{URL}/api/v1/setting/currency",
json="",
)
result.raise_for_status()
# Verify unset
setting = result.json()
assert setting == {
"value": '"EUR"',
"is_set": False,
"type": "string",
}
def test_set_unknown():
"""Test setting an invalid setting."""
# Execute
result = httpx.post(
f"{URL}/api/v1/setting/not-a-setting",
json='"SEK"',
)
assert result.status_code == 404
def test_set_currency_wrong_type():
"""Test setting the currency setting with the wrong type."""
# Execute
result = httpx.post(
f"{URL}/api/v1/setting/currency",
json=123,
)
assert result.status_code == 422
def test_set_big_value():
"""Test setting a setting to a long string which should be saved correctly."""
long_string = "a" * (2**16 - 1 - 2) # Backend guarantees that it can handle strings up to 65535 characters long.
# Remove 2 characters to account for the quotes.
# Execute
result = httpx.post(
f"{URL}/api/v1/setting/currency",
json=json.dumps(long_string),
)
result.raise_for_status()
# Verify
result = httpx.get(f"{URL}/api/v1/setting/currency")
result.raise_for_status()
setting = result.json()
assert setting == {
"value": json.dumps(long_string),
"is_set": True,
"type": "string",
}
# Cleanup
result = httpx.post(
f"{URL}/api/v1/setting/currency",
json="",
)
result.raise_for_status()
def test_set_setting_rejects_text_plain():
"""A text/plain body is what <form enctype="text/plain"> sends, and used to be accepted.
FastAPI only JSON-parses application/*json; anything else reached the endpoint's bare `str`
body as raw bytes, which lax-mode Pydantic coerced. That made every setting writable by any
website the user happened to visit.
"""
result = httpx.post(
f"{URL}/api/v1/setting/currency",
content='name="US=D"',
headers={"Content-Type": "text/plain;charset=UTF-8"},
)
assert result.status_code == 415
# Verify nothing was written.
result = httpx.get(f"{URL}/api/v1/setting/currency")
result.raise_for_status()
assert result.json()["is_set"] is False
def test_set_setting_rejects_form_encodings():
"""The other two encodings an HTML form can send must be refused as well."""
for content_type in ("application/x-www-form-urlencoded", "multipart/form-data; boundary=x"):
result = httpx.post(
f"{URL}/api/v1/setting/currency",
content="a=b",
headers={"Content-Type": content_type},
)
assert result.status_code == 415, content_type
def test_set_setting_accepts_json_with_a_charset():
"""A charset parameter on the content type must not break real clients."""
result = httpx.post(
f"{URL}/api/v1/setting/currency",
content=json.dumps('"SEK"'),
headers={"Content-Type": "application/json;charset=utf-8"},
)
result.raise_for_status()
assert result.json()["value"] == '"SEK"'
# Cleanup
httpx.post(f"{URL}/api/v1/setting/currency", json="").raise_for_status()
def test_set_malformed_extra_fields_does_not_wedge_the_field_endpoint():
"""A malformed extra_fields_* write used to be accepted and then 500 every /field read."""
result = httpx.post(
f"{URL}/api/v1/setting/extra_fields_spool",
json=json.dumps([{"key": "nope"}]),
)
assert result.status_code == 400
result = httpx.get(f"{URL}/api/v1/field/spool")
assert result.status_code == 200
def test_set_extra_fields_rejects_a_mismatched_entity_type():
result = httpx.post(
f"{URL}/api/v1/setting/extra_fields_spool",
json=json.dumps(
[{"key": "batch", "entity_type": "vendor", "name": "Batch", "field_type": "text"}],
),
)
assert result.status_code == 400
def test_set_extra_fields_refreshes_the_field_endpoint():
"""The registry caches extra fields, and this write path did not invalidate that cache."""
try:
result = httpx.post(
f"{URL}/api/v1/setting/extra_fields_spool",
json=json.dumps(
[{"key": "batch", "entity_type": "spool", "name": "Batch", "field_type": "text"}],
),
)
result.raise_for_status()
# Without cache invalidation this only showed up after a restart.
result = httpx.get(f"{URL}/api/v1/field/spool")
result.raise_for_status()
assert [field["key"] for field in result.json()] == ["batch"]
finally:
httpx.post(f"{URL}/api/v1/setting/extra_fields_spool", json=json.dumps([])).raise_for_status()