Files
Spoolman/tests/test_env.py
T
Donkie 7d4728ae9b security: add shared origin-trust helper
Tasks 1-3 of the backend security audit all need to answer "is this browser
origin someone we trust". Build it once, driven entirely by the existing
SPOOLMAN_CORS_ORIGIN rather than a new env var.

- Unset means same-origin only, which is right for nearly every deployment.
  Non-browser clients (Moonraker, OctoPrint) send no Origin and are unaffected.
- A list means those origins plus our own.
- "*" and debug mode mean "I do not want origin checks" and are honoured as
  such, with a loud startup warning. An operator who writes "*" is opting out;
  reinterpreting it as something narrower would leave them no way to say so.

Also accept X-Forwarded-Host, so a reverse proxy that rewrites Host (nginx
without proxy_set_header Host $host, Apache without ProxyPreserveHost On) does
not see the guard reject its own web UI. A malicious page cannot set that
header on any of the vectors the guard exists to stop: forms cannot set request
headers, nor can the browser WebSocket API, and a fetch that adds one needs a
CORS preflight it will not get.

get_cors_origin() now normalizes its entries -- it was a bare split(","), so
"https://a, https://b" yielded " https://b", which no Origin header can match.
CORS itself benefits from the same fix. The raw value is kept in the startup
log so a typo stays visible.

Adds tests/ for backend unit tests, runnable with `poe test` and wired into the
lefthook ci group. Nothing enforces this helper yet; that is tasks 1-3.
2026-07-27 08:51:11 +02:00

49 lines
1.8 KiB
Python

"""Tests for environment variable parsing."""
import pytest
from spoolman import env
@pytest.mark.parametrize(
("raw", "expected"),
[
("https://spoolman.local", "https://spoolman.local"),
(" https://spoolman.local ", "https://spoolman.local"),
("https://spoolman.local/", "https://spoolman.local"),
("https://spoolman.local///", "https://spoolman.local"),
("HTTPS://Spoolman.Local", "https://spoolman.local"),
("*", "*"),
],
)
def test_normalize_origin(raw: str, expected: str):
assert env.normalize_origin(raw) == expected
def test_get_cors_origin_unset(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("SPOOLMAN_CORS_ORIGIN", raising=False)
assert env.get_cors_origin() is None
assert env.is_cors_defined() is False
def test_get_cors_origin_single(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("SPOOLMAN_CORS_ORIGIN", "https://spoolman.local")
assert env.get_cors_origin() == ["https://spoolman.local"]
assert env.is_cors_defined() is True
def test_get_cors_origin_trims_list_entries(monkeypatch: pytest.MonkeyPatch):
"""A space after the comma must not produce an entry no Origin header can ever match."""
monkeypatch.setenv("SPOOLMAN_CORS_ORIGIN", "https://a.local, https://b.local/")
assert env.get_cors_origin() == ["https://a.local", "https://b.local"]
def test_get_cors_origin_drops_empty_and_duplicate_entries(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("SPOOLMAN_CORS_ORIGIN", "https://a.local,,https://a.local/, ")
assert env.get_cors_origin() == ["https://a.local"]
def test_get_cors_origin_raw_is_unparsed(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("SPOOLMAN_CORS_ORIGIN", " https://a.local, https://b.local ")
assert env.get_cors_origin_raw() == " https://a.local, https://b.local "