Преглед изворни кода

Security hardening (maziggy/bambuddy-security #7)

fix(settings): accept JSON booleans on the Spoolman settings endpoint
maziggy пре 1 месец
родитељ
комит
88dc56d6e1

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 14 - 10
backend/app/api/routes/_oidc_helpers.py

@@ -1,9 +1,11 @@
 """Pure helper functions for OIDC routes.
 
-Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
-``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
-loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
-the public internet (IdP-hosted), so private addresses there are SSRF probes.
+Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
+and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
+than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
+allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
+reachable on the public internet, so a private address there is an SSRF probe
+rather than a configuration.
 """
 
 from __future__ import annotations
@@ -17,9 +19,10 @@ from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE
 def assert_safe_public_https_url(url: str) -> None:
     """Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
 
-    Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
-    guard: also rejects loopback, private (RFC-1918), and link-local addresses
-    because an OIDC icon legitimately lives only on the public internet.
+    Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
+    than the LAN-service SSRF guard: also rejects loopback, private
+    (RFC-1918), and link-local addresses because an IdP and its icon
+    legitimately live only on the public internet.
 
     Checks performed:
     - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
@@ -35,9 +38,10 @@ def assert_safe_public_https_url(url: str) -> None:
     - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
       check so an attacker can't bypass via IPv6 encoding.
 
-    Hostname-based addresses are accepted without DNS resolution (consistent
-    with ``_validate_issuer_url`` policy — the operator is trusted to
-    configure a sensible IdP host).
+    Hostname-based addresses are accepted without DNS resolution — the
+    operator is trusted to configure a sensible IdP host, and resolving here
+    would both add a TOCTOU gap (DNS can change between validation and
+    request) and make the validator issue network requests of its own.
     """
     parsed = urlparse(url)
     if parsed.scheme.lower() != "https":

+ 10 - 56
backend/app/api/routes/_spoolman_helpers.py

@@ -5,17 +5,15 @@ No heavy dependencies — importable in unit tests without the full backend stac
 
 from __future__ import annotations
 
-import ipaddress
 import json
 import logging
 import math
 import re
 from typing import Any
-from urllib.parse import urlparse
 
 from typing_extensions import TypedDict
 
-from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
 
 logger = logging.getLogger(__name__)
 
@@ -80,61 +78,17 @@ class NormalizedFilament(TypedDict):
 
 
 def assert_safe_spoolman_url(url: str) -> None:
-    """Raise ValueError if *url* should be blocked as an SSRF risk.
-
-    Bambuddy is typically deployed on a home LAN alongside Spoolman, so
-    loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
-    172.16-31.x) must be permitted — they are THE normal Spoolman topology.
-    This guard therefore targets the genuinely dangerous cases only.
-
-    Checks performed:
-    - Scheme must be http or https (no file://, gopher://, dict://, etc.).
-    - Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
-      (e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
-      ``ValueError`` for these forms so they would otherwise bypass the
-      explicit-IP block below, but libc (and browsers) resolve them as valid
-      IPv4 addresses.
-    - Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
-      fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
-    - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
-      are blocked — pointless as a destination and suggests misuse.
-    - IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
-      bypass the checks above.
-
-    Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
-    are out of scope — DNS resolution is deliberately not performed here.
-    """
-    parsed = urlparse(url)
-    if parsed.scheme.lower() not in ("http", "https"):
-        raise ValueError("Spoolman URL must use http or https")
-
-    hostname = (parsed.hostname or "").lower()
+    """Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
 
-    # Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
-    # http://0x7f000001/). These slip past ipaddress.ip_address() but libc
-    # (and browsers) parse them as IPv4 — an obvious bypass if not caught.
-    if NUMERIC_IP_RE.match(hostname):
-        raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
+    Thin wrapper over the shared LAN-service policy — see
+    ``_url_safety.assert_safe_lan_service_url`` for what is and isn't
+    rejected, and why loopback/RFC-1918 are deliberately permitted (running
+    Spoolman on the same host or home LAN is THE normal topology).
 
-    try:
-        addr = ipaddress.ip_address(hostname)
-    except ValueError:
-        # Not a bare IP address — includes intentional cases such as "localhost" and
-        # RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
-        # a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
-        # Running Spoolman on the same host or home LAN is the standard Bambuddy
-        # topology, so loopback and private ranges are deliberately NOT blocked here.
-        return
-
-    # Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
-    # encode a blocked IPv4 into an IPv6 literal to bypass the check.
-    effective = unwrap_ipv4_mapped(addr)
-
-    if effective in CLOUD_METADATA_IPS:
-        raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
-
-    if effective.is_multicast or effective.is_unspecified:
-        raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
+    Kept as a named function because the "Spoolman URL …" wording in its
+    errors is user-facing and asserted by existing tests.
+    """
+    assert_safe_lan_service_url(url, label="Spoolman URL")
 
 
 _COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")

+ 77 - 10
backend/app/api/routes/_url_safety.py

@@ -1,19 +1,31 @@
-"""Shared URL-safety primitives used by both SSRF guards in this package.
-
-The two top-level assertion functions —
-``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
-loopback/RFC-1918 because same-LAN deployment is the standard topology) and
-``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
-on the public internet, so loopback/private are rejected) — share the
-*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
-*policy*. Only the data lives here. The functions stay in their respective
-modules with their distinct policies intact.
+"""Shared URL-safety primitives for the SSRF guards in this package.
+
+Bambuddy has exactly two outbound-URL policies, and which one applies is a
+property of the *service*, not of the caller:
+
+- **LAN-service** (``assert_safe_lan_service_url`` below) — the service
+  legitimately lives on the same host or home LAN, so loopback and RFC-1918
+  must be permitted; blocking them would break the normal topology. Used for
+  Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
+  webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
+- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
+  resource can only sensibly live on the public internet, so a private
+  address is an SSRF probe rather than a configuration. Used for OIDC issuer
+  and icon URLs.
+
+Both reject the cases that are dangerous regardless of topology: non-HTTP
+schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
+unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
+
+The LAN-service policy lives here because it now has several callers; the
+public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
 """
 
 from __future__ import annotations
 
 import ipaddress
 import re
+from urllib.parse import urlparse
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
 # targets. Both guards reject these unconditionally.
@@ -49,3 +61,58 @@ def unwrap_ipv4_mapped(
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
         return addr.ipv4_mapped
     return addr
+
+
+def assert_safe_lan_service_url(url: str, *, label: str) -> None:
+    """Raise ValueError if *url* is unsafe for a service that may live on the LAN.
+
+    ``label`` names the setting in the error message ("Spoolman URL", "ntfy
+    server URL", …) so the user sees which field they need to correct.
+
+    Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
+    **permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
+    Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
+    same host or home LAN is THE normal topology, not an attack. A blanket
+    private-address block would break those integrations for most installs.
+
+    What is rejected is dangerous under any topology:
+
+    - Schemes other than http/https. ``httpx`` already raises
+      ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
+      about returning a clear validation error at configuration time rather
+      than an opaque failure at delivery time.
+    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
+      libc and browsers resolve these, but Python's ``ipaddress`` raises
+      ValueError on them, so they would slip past the checks below.
+    - Cloud-provider metadata endpoints — the high-value SSRF target, and
+      never a legitimate destination for any of these services.
+    - Multicast and unspecified addresses — pointless as a destination and
+      indicative of misuse.
+    - IPv4-mapped IPv6 encodings of any of the above.
+
+    Symbolic hostnames are accepted without DNS resolution, matching the
+    public-internet guard: resolution here would be both a TOCTOU (DNS can
+    change between validation and request) and a request the validator
+    shouldn't be making.
+    """
+    parsed = urlparse(url)
+    if parsed.scheme.lower() not in ("http", "https"):
+        raise ValueError(f"{label} must use http or https")
+
+    hostname = (parsed.hostname or "").lower()
+
+    if NUMERIC_IP_RE.match(hostname):
+        raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
+
+    try:
+        addr = ipaddress.ip_address(hostname)
+    except ValueError:
+        return  # symbolic hostname — out of scope by design (no DNS check)
+
+    effective = unwrap_ipv4_mapped(addr)
+
+    if effective in CLOUD_METADATA_IPS:
+        raise ValueError(f"{label} must not point to a cloud metadata endpoint")
+
+    if effective.is_multicast or effective.is_unspecified:
+        raise ValueError(f"{label} must not point to a multicast or unspecified address")

+ 100 - 13
backend/app/api/routes/settings.py

@@ -42,6 +42,88 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
     return setting.value if setting else None
 
 
+# Accepted spellings for a boolean settings value. Settings live in a VARCHAR
+# column and every reader compares them as strings, so these are normalised to
+# "true"/"false" on the way in. The sets are deliberately generous: these
+# endpoints are part of the documented REST surface, reached by scripts and by
+# Home Assistant rest_command, where "True", "1" and "on" are all natural.
+_TRUTHY_SETTING_VALUES = frozenset({"true", "1", "yes", "on"})
+_FALSY_SETTING_VALUES = frozenset({"false", "0", "no", "off"})
+
+
+def setting_is_true(value: object) -> bool:
+    """Return True if a *stored* settings value means "on".
+
+    Deliberately narrower than the spellings ``normalize_bool_setting`` accepts:
+    it matches only what every other reader in the codebase treats as on
+    (``value.lower() == "true"``). Submitted values are canonicalised on write,
+    so a stored value is always "true"/"false"/""; accepting "1" or "on" here
+    would make this function disagree with the rest of the app about any legacy
+    row containing them.
+
+    A bool is tolerated for the case of a row written before values were
+    normalised, where SQLite coerced a raw bool into the VARCHAR column.
+    """
+    if isinstance(value, bool):
+        return value
+    if value is None:
+        return False
+    return str(value).strip().lower() == "true"
+
+
+def normalize_bool_setting(key: str, value: object) -> str:
+    """Coerce a boolean-ish settings value to the canonical "true"/"false".
+
+    Raises HTTPException(400) for values with no sensible interpretation, so an
+    API client gets a message naming the field instead of a 500.
+
+    A JSON boolean is the natural thing for an API client to send, and before
+    this normalisation it caused two distinct failures on
+    ``PUT /settings/spoolman``: ``bool.lower()`` raised AttributeError, and the
+    raw bool was written into a VARCHAR column, which SQLite silently coerces
+    to 1/0 while asyncpg rejects outright. Both surfaced as an opaque 500.
+    """
+    if isinstance(value, bool):  # must precede the int branch — bool is an int
+        return "true" if value else "false"
+    if isinstance(value, int):
+        if value in (0, 1):
+            return "true" if value else "false"
+        raise HTTPException(400, f"{key} must be a boolean; got the number {value}")
+    if isinstance(value, str):
+        candidate = value.strip().lower()
+        if not candidate:
+            # Empty is stored verbatim rather than normalised to "false".
+            # get_spoolman_settings reads these with ``or "<default>"``, so an
+            # empty stored value means "use the default" — and two of them
+            # (spoolman_report_partial_usage, auto_add_unknown_rfid) default to
+            # ON. Rewriting "" to "false" would silently switch them off for any
+            # client that submits a blank value.
+            return ""
+        if candidate in _TRUTHY_SETTING_VALUES:
+            return "true"
+        if candidate in _FALSY_SETTING_VALUES:
+            return "false"
+        raise HTTPException(400, f"{key} must be a boolean; got {value!r}")
+    raise HTTPException(400, f"{key} must be a boolean; got {type(value).__name__}")
+
+
+def normalize_str_setting(key: str, value: object) -> str:
+    """Return a string settings value, rejecting types that would store garbage.
+
+    ``str()`` on a dict or list would persist its repr, so those are refused
+    rather than silently written. Numbers are accepted and stringified: a port
+    or a bare host submitted unquoted is a plausible client mistake, not a
+    reason to fail the request.
+    """
+    if isinstance(value, str):
+        return value
+    if value is None:
+        return ""
+    if isinstance(value, bool | int | float):
+        return str(value)
+    raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
+
+
 async def get_external_login_url(db: AsyncSession) -> str:
     """Get the external URL for the login page.
 
@@ -435,14 +517,20 @@ async def update_spoolman_settings(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Update Spoolman integration settings."""
+    """Update Spoolman integration settings.
+
+    The body is a free-form dict rather than a schema, so each value is
+    normalised before it is persisted — see ``normalize_bool_setting`` for why
+    a JSON boolean used to produce a 500 here.
+    """
     if "spoolman_enabled" in settings:
-        old_val = await get_setting(db, "spoolman_enabled") or "false"
-        new_val = settings["spoolman_enabled"]
+        was_enabled = setting_is_true(await get_setting(db, "spoolman_enabled"))
+        new_val = normalize_bool_setting("spoolman_enabled", settings["spoolman_enabled"])
+        now_enabled = new_val == "true"
         await set_setting(db, "spoolman_enabled", new_val)
 
         # Switching to Spoolman: clear built-in inventory slot assignments
-        if old_val.lower() != "true" and new_val.lower() == "true":
+        if not was_enabled and now_enabled:
             from backend.app.models.spool_assignment import SpoolAssignment
 
             result = await db.execute(delete(SpoolAssignment))
@@ -452,21 +540,20 @@ async def update_spoolman_settings(
         # spoolman_slot_assignments rows linger and would wrongly count as
         # "assigned" in any mode-agnostic check (e.g. the missing-spool-
         # assignment notification, which unions both tables — #1473).
-        elif old_val.lower() == "true" and new_val.lower() != "true":
+        elif was_enabled and not now_enabled:
             from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
             result = await db.execute(delete(SpoolmanSlotAssignment))
             logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
     if "spoolman_url" in settings:
-        await set_setting(db, "spoolman_url", settings["spoolman_url"])
+        await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
     if "spoolman_sync_mode" in settings:
-        await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
-    if "spoolman_disable_weight_sync" in settings:
-        await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
-    if "spoolman_report_partial_usage" in settings:
-        await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
-    if "auto_add_unknown_rfid" in settings:
-        await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
+        await set_setting(
+            db, "spoolman_sync_mode", normalize_str_setting("spoolman_sync_mode", settings["spoolman_sync_mode"])
+        )
+    for bool_key in ("spoolman_disable_weight_sync", "spoolman_report_partial_usage", "auto_add_unknown_rfid"):
+        if bool_key in settings:
+            await set_setting(db, bool_key, normalize_bool_setting(bool_key, settings[bool_key]))
 
     spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
 

+ 24 - 15
backend/app/schemas/auth.py

@@ -360,28 +360,37 @@ def _validate_icon_url(v: str | None) -> str | None:
 
 
 def _validate_issuer_url(v: str | None) -> str | None:
-    """Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
-
-    HTTP is no longer accepted — OIDC providers must be reachable over TLS.
-    Private-network and loopback addresses are rejected to prevent SSRF attacks
-    where an admin-supplied URL could reach internal services.
+    """Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
+
+    An OIDC provider must be reachable over TLS on the public internet, so
+    this uses the public-internet policy: private, loopback and link-local
+    addresses are all rejected.
+
+    Delegates to the runtime guard ``assert_safe_public_https_url`` for the
+    same reason ``_validate_icon_url`` does — no policy drift between the
+    schema layer and the fetcher. The hand-rolled version this replaced
+    checked only ``is_private | is_loopback | is_link_local``, which left
+    numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
+    (``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
+    able to express a target the policy meant to forbid. The guard's
+    docstring already claimed the two were consistent; now they are.
+
+    Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
+    schemas avoid top-level imports from that layer.
     """
-    import ipaddress
-    from urllib.parse import urlparse
-
     if v is None:
         return v
     if not v.startswith("https://"):
         raise ValueError("issuer_url must start with https://")
-    host = urlparse(v).hostname or ""
+    from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
+
     try:
-        addr = ipaddress.ip_address(host)
-        if addr.is_private or addr.is_loopback or addr.is_link_local:
-            raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
+        assert_safe_public_https_url(v)
     except ValueError as exc:
-        if "issuer_url" in str(exc):
-            raise
-        # hostname is a domain name, not a bare IP — that's fine
+        # The guard's messages say "icon URL" — rewrite for this field so the
+        # user sees the setting they actually submitted.
+        detail = str(exc).replace("icon URL", "issuer_url")
+        raise ValueError(detail) from exc
     return v
 
 

+ 56 - 1
backend/app/schemas/settings.py

@@ -1,9 +1,23 @@
 import json
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
 from backend.app.schemas.print_queue import TriState
 
+# Outbound service URLs validated on save, so a bad value is rejected at
+# configuration time with a clear message rather than failing opaquely at
+# request time. Every one of these services is commonly self-hosted on the same
+# host or LAN as Bambuddy, so the LAN-service policy applies: loopback and
+# RFC-1918 stay permitted, while cloud-metadata endpoints, numeric-encoded IPs,
+# IPv4-mapped IPv6 and non-HTTP schemes are rejected. See
+# ``_url_safety.assert_safe_lan_service_url``.
+#
+# Module-level rather than a class attribute so the CI backstop in
+# tests/unit/test_outbound_url_ssrf_guards.py can import the real list and
+# cannot drift from it. Any new outbound-URL setting belongs here (or, if it
+# must be reachable on the public internet, on the stricter OIDC guard).
+LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
+
 
 class AppSettings(BaseModel):
     """Application settings schema."""
@@ -600,6 +614,47 @@ class AppSettingsUpdate(BaseModel):
     default_sidebar_order: str | None = None
     forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
 
+    @field_validator(*LAN_SERVICE_URL_SETTINGS)
+    @classmethod
+    def validate_lan_service_url(cls, v: str | None, info: ValidationInfo) -> str | None:
+        """Reject SSRF-unsafe outbound service URLs on save.
+
+        Empty (and whitespace-only) is the documented "not configured / fall
+        back to the env var" value for all four fields and must keep passing.
+
+        Values that are not absolute URLs at all ("192.168.1.10:3333",
+        "localhost:3333") are left alone rather than rejected. Two reasons:
+
+        - They are inert. Every consumer of these four settings goes through
+          httpx, which raises UnsupportedProtocol for a URL with no scheme, so
+          no request is ever issued and there is nothing to guard against.
+        - They were storable before this validator existed, and the settings
+          UI is a plain text input with no scheme enforcement. Newly rejecting
+          them would break saves that have nothing to do with the URL: the
+          Obico panel, for one, sends obico_ml_url with every change and
+          auto-saves, so one legacy value would block toggling detection on or
+          off. A pre-existing misconfiguration should keep failing where it
+          already failed (at request time), not spread to unrelated fields.
+
+        ``urlparse`` is no help in telling the two apart — it reads
+        "localhost:3333" as scheme "localhost" — so the test is the literal
+        "://" that makes a string an absolute URL.
+        """
+        if v is None or not v.strip():
+            return v
+        candidate = v.strip()
+        if "://" not in candidate:
+            return v
+        # Lazy-imported: schemas avoid top-level imports from api/routes,
+        # matching the existing pattern in auth.py's _validate_icon_url.
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+        try:
+            assert_safe_lan_service_url(candidate, label=info.field_name or "URL")
+        except ValueError as exc:
+            raise ValueError(str(exc)) from exc
+        return v
+
     @field_validator("gcode_snippets")
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:

+ 74 - 5
backend/app/services/notification_service.py

@@ -64,6 +64,55 @@ def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
     return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
 
 
+def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
+    """Validate a provider URL taken from user-supplied config.
+
+    Returns an error message on rejection, or None when the URL is
+    acceptable — the ``_send_*`` methods return ``tuple[bool, str]`` rather
+    than raising, so a message is more useful here than an exception.
+
+    Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
+    receiver on the home LAN is normal and must keep working, so loopback and
+    RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
+    non-HTTP schemes are rejected.
+    """
+    from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+    try:
+        assert_safe_lan_service_url(url, label=label)
+    except ValueError as exc:
+        return str(exc)
+    return None
+
+
+def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
+    """Failure message for a provider whose destination host the user supplies.
+
+    The response body is deliberately **not** returned to the caller. Provider
+    URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` — which
+    the default Operators group carries and which does not imply
+    ``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` accepts a URL
+    straight from the request body without persisting anything. Echoing the
+    response body there turned an intended "does my webhook work?" check into
+    an authenticated read primitive against any host the Bambuddy process can
+    reach, including services that are not exposed to the network at all.
+
+    Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
+    keep returning the upstream body — there is no trust boundary to cross
+    when the destination cannot be influenced.
+
+    The body is logged at debug level, where it stays available to whoever
+    already administers the host without being handed back over the API.
+    """
+    logger.debug(
+        "%s delivery failed with HTTP %s; body: %s",
+        label,
+        response.status_code,
+        (response.text or "")[:200],
+    )
+    return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
+
+
 class NotificationService:
     """Service for sending notifications through various providers."""
 
@@ -265,6 +314,10 @@ class NotificationService:
         if not device_key:
             return False, "Device key is required"
 
+        url_error = _assert_safe_provider_url(server, label="Bark server URL")
+        if url_error:
+            return False, url_error
+
         payload: dict[str, Any] = {
             "device_key": device_key,
             "title": title,
@@ -291,9 +344,13 @@ class NotificationService:
             except ValueError:
                 body = None
             if isinstance(body, dict) and body.get("code") not in (200, None):
-                return False, f"Bark error {body.get('code')}: {str(body.get('message'))[:200]}"
+                # Only the numeric code is echoed. A server chosen by the caller
+                # controls this body too, so the free-text message is a (narrow)
+                # read channel of the same kind _opaque_http_failure closes.
+                logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
+                return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
             return True, "Message sent successfully"
-        return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        return False, _opaque_http_failure(response, label="Bark server")
 
     async def _send_ntfy(
         self,
@@ -311,6 +368,10 @@ class NotificationService:
         if not topic:
             return False, "Topic is required"
 
+        url_error = _assert_safe_provider_url(server, label="ntfy server URL")
+        if url_error:
+            return False, url_error
+
         url = f"{server}/{topic}"
         # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
         # for str header values, but printer names and filenames can contain
@@ -363,7 +424,7 @@ class NotificationService:
                 "Fight Mode, or front the server with Cloudflare Access using a "
                 "service token. (#1534)"
             )
-        return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        return False, _opaque_http_failure(response, label="ntfy server")
 
     async def _send_pushover(
         self, config: dict, title: str, message: str, image_data: bytes | None = None
@@ -683,6 +744,10 @@ class NotificationService:
         if not webhook_url:
             return False, "Webhook URL is required"
 
+        url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
+        if url_error:
+            return False, url_error
+
         # Build payload based on format
         if payload_format == "slack":
             # Slack/Mattermost format - just text field
@@ -728,7 +793,7 @@ class NotificationService:
             if response.status_code in (200, 201, 202, 204):
                 return True, "Webhook delivered successfully"
             else:
-                return False, f"HTTP {response.status_code}: {response.text[:200]}"
+                return False, _opaque_http_failure(response, label="webhook endpoint")
         except Exception as e:
             return False, f"Webhook error: {str(e)}"
 
@@ -829,7 +894,11 @@ class NotificationService:
         elif response.status_code == 401:
             return False, "Home Assistant authentication failed - check your token"
         else:
-            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+            # ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
+            # this is a narrower channel than the per-request provider URLs — but
+            # it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
+            # gets the same treatment.
+            return False, _opaque_http_failure(response, label="Home Assistant endpoint")
 
     async def _send_to_provider(
         self,

+ 39 - 16
backend/tests/unit/services/test_notification_service.py

@@ -1168,16 +1168,24 @@ class TestBarkProvider:
         mock_client.post.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_send_bark_error_in_200_body(self, service):
-        """bark-server can wrap a failure in HTTP 200; the body code must win."""
+    async def test_send_bark_error_in_200_body(self, service, caplog):
+        """bark-server can wrap a failure in HTTP 200; the body code must win.
+
+        Only the numeric code is returned — the server is caller-supplied
+        (bark is self-hostable), so its free-text message is the same read
+        channel the HTTP-failure path closes. The text goes to the debug log.
+        """
         mock_client = self._client_returning(200, {"code": 400, "message": "device token invalid"})
 
         with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
             mock_get_client.return_value = mock_client
-            success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
+            with caplog.at_level("DEBUG", logger="backend.app.services.notification_service"):
+                success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
 
         assert success is False
-        assert "device token invalid" in message
+        assert "Bark error 400" in message
+        assert "device token invalid" not in message
+        assert "device token invalid" in caplog.text
 
     @pytest.mark.asyncio
     async def test_send_bark_http_error(self, service):
@@ -2476,10 +2484,15 @@ class TestNtfyOutbound:
         assert "<!DOCTYPE" not in detail
 
     @pytest.mark.asyncio
-    async def test_ntfy_normal_403_still_surfaces_body(self, service):
-        """A non-Cloudflare 403 (e.g. ntfy auth fail) must keep showing
-        the original body so the user can debug the real error — we
-        only intercept the Cloudflare-challenge shape."""
+    async def test_ntfy_normal_403_is_not_misread_as_a_cloudflare_challenge(self, service, caplog):
+        """A non-Cloudflare 403 (e.g. ntfy auth fail) must report the real
+        status rather than the Cloudflare-challenge advice — we only intercept
+        the challenge shape.
+
+        The origin's body is no longer returned to the API caller: the ntfy
+        server URL is caller-supplied, so echoing it made this an SSRF read
+        primitive. It goes to the debug log instead.
+        """
         import httpx
 
         mock_response = httpx.Response(
@@ -2491,7 +2504,10 @@ class TestNtfyOutbound:
         mock_client = AsyncMock()
         mock_client.post = AsyncMock(return_value=mock_response)
 
-        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+        with (
+            patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
+            caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
+        ):
             ok, detail = await service._send_ntfy(
                 {"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
                 title="t",
@@ -2500,16 +2516,19 @@ class TestNtfyOutbound:
 
         assert ok is False
         assert "Cloudflare" not in detail
-        assert "invalid auth token" in detail
-        assert detail.startswith("HTTP 403:")
+        assert detail.startswith("HTTP 403")
+        assert "invalid auth token" not in detail
+        assert "invalid auth token" in caplog.text
 
     @pytest.mark.asyncio
-    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service):
+    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service, caplog):
         """Cloudflare adds Server: cloudflare to EVERY proxied response,
         including legitimate origin errors. A real 401 "wrong token"
         from an ntfy server that happens to sit behind Cloudflare must
-        still surface the origin's actual error body — we must not flip
+        still be reported as the origin's status — we must not flip
         every CF-fronted 4xx into a "your Cloudflare is blocking" message.
+
+        As above, the origin body reaches the debug log rather than the caller.
         """
         import httpx
 
@@ -2527,7 +2546,10 @@ class TestNtfyOutbound:
         mock_client = AsyncMock()
         mock_client.post = AsyncMock(return_value=mock_response)
 
-        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+        with (
+            patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
+            caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
+        ):
             ok, detail = await service._send_ntfy(
                 {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
                 title="t",
@@ -2536,8 +2558,9 @@ class TestNtfyOutbound:
 
         assert ok is False
         assert "Cloudflare" not in detail
-        assert "unauthorized" in detail
-        assert detail.startswith("HTTP 401:")
+        assert detail.startswith("HTTP 401")
+        assert "unauthorized" not in detail
+        assert "unauthorized" in caplog.text
 
     @pytest.mark.asyncio
     async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):

+ 376 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -0,0 +1,376 @@
+"""Outbound-URL SSRF policy: two tiers, applied consistently.
+
+Bambuddy makes outbound HTTP requests to hosts the operator configures. Which
+policy applies is a property of the *service*, not the caller:
+
+- LAN-service (Spoolman, ntfy, Bark, webhooks, Home Assistant, Obico ML, the
+  slicer sidecars) — loopback and RFC-1918 MUST stay reachable, because
+  self-hosting those next to Bambuddy is the normal topology. Blocking them
+  would break most installs, which is why a blanket private-IP blocklist is
+  the wrong fix here.
+- Public-internet (OIDC issuer and icon URLs) — a private address cannot be a
+  real IdP, so it is a probe.
+
+Both tiers reject what is dangerous under any topology: non-HTTP schemes,
+numeric-encoded IPs, cloud-metadata endpoints, multicast/unspecified, and
+IPv4-mapped IPv6 encodings of the above.
+
+The separate concern covered here is *response-body echo*. Notification
+provider URLs are writable by anyone holding ``NOTIFICATIONS_CREATE`` — which
+the default Operators group carries and which does NOT imply
+``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` takes the URL
+from the request body without persisting it. Returning the upstream body there
+made an intended reachability check into an authenticated read primitive
+against anything the process can reach. Providers whose host Bambuddy pins
+(Pushover, Telegram, CallMeBot, Discord) may still echo, since the caller
+cannot influence the destination.
+"""
+
+from __future__ import annotations
+
+import inspect
+import re
+
+import httpx
+import pytest
+
+from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
+from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+from backend.app.schemas.auth import OIDCProviderCreate, OIDCProviderUpdate
+from backend.app.schemas.settings import LAN_SERVICE_URL_SETTINGS, AppSettingsUpdate
+from backend.app.services import notification_service as ns
+
+# Dangerous under any topology — both tiers must reject all of these.
+UNIVERSALLY_BLOCKED = [
+    "file:///etc/passwd",
+    "gopher://127.0.0.1:6379/_INFO",
+    "ftp://internal.example.com/",
+    "http://169.254.169.254/latest/meta-data/",
+    "http://100.100.100.200/",
+    "http://[fd00:ec2::254]/",
+    "http://2130706433/",
+    "http://0x7f000001/",
+    "http://[::ffff:169.254.169.254]/",
+    "http://0.0.0.0/",
+    "http://239.255.255.250/",
+]
+
+# The normal self-hosted topology — the LAN tier must permit all of these.
+LAN_ALLOWED = [
+    "http://127.0.0.1:7912/",
+    "http://localhost:3003",
+    "http://192.168.1.50:8123",
+    "http://10.0.0.7:3333",
+    "http://172.16.4.9:8080",
+    "https://ntfy.example.com/",
+    "http://spoolman.lan:7912",
+]
+
+
+# ---------------------------------------------------------------------------
+# The LAN-service tier
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_lan_tier_rejects_universally_dangerous_targets(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_lan_service_url(url, label="Test URL")
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_lan_tier_permits_the_normal_self_hosted_topology(url: str):
+    """A blanket private-IP block here would break most real installs."""
+    assert_safe_lan_service_url(url, label="Test URL")
+
+
+def test_lan_tier_names_the_field_in_its_error():
+    with pytest.raises(ValueError, match="ntfy server URL"):
+        assert_safe_lan_service_url("file:///etc/passwd", label="ntfy server URL")
+
+
+def test_spoolman_wrapper_keeps_its_user_facing_wording():
+    """The wording is asserted by pre-existing tests; delegation must not change it."""
+    with pytest.raises(ValueError, match="^Spoolman URL must use http or https$"):
+        assert_safe_spoolman_url("file:///etc/passwd")
+    with pytest.raises(ValueError, match="^Spoolman URL must not point to a cloud metadata endpoint$"):
+        assert_safe_spoolman_url("http://169.254.169.254/")
+
+
+# ---------------------------------------------------------------------------
+# The public-internet tier
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_public_tier_rejects_universally_dangerous_targets(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_public_https_url(url)
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "https://127.0.0.1/",
+        "https://192.168.1.5/",
+        "https://10.1.2.3/",
+        "https://[fe80::1]/",
+        "https://[::ffff:127.0.0.1]/",
+        "http://accounts.google.com/",  # scheme must be https
+    ],
+)
+def test_public_tier_additionally_rejects_private_and_plain_http(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_public_https_url(url)
+
+
+# ---------------------------------------------------------------------------
+# OIDC issuer_url — the encoding bypasses the hand-rolled validator missed
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "https://2130706433/",  # decimal-encoded 127.0.0.1
+        "https://0x7f000001/",  # hex-encoded 127.0.0.1
+        "https://[::ffff:127.0.0.1]/",  # IPv4-mapped loopback
+        "https://[::ffff:169.254.169.254]/",  # IPv4-mapped IMDS
+        "https://0.0.0.0/",
+        "https://239.255.255.250/",
+        "https://169.254.169.254/",
+        "https://127.0.0.1/",
+        "https://192.168.1.5/",
+        "http://idp.example.com/",
+    ],
+)
+def test_issuer_url_rejects_encoded_and_private_targets(url: str):
+    with pytest.raises(ValueError):
+        OIDCProviderCreate(
+            name="SSO",
+            issuer_url=url,
+            client_id="cid",
+            client_secret="secret",
+        )
+
+
+def test_issuer_url_update_is_guarded_too():
+    """The update path matters most: it can change the issuer while the stored
+    client_secret stays, which is the shape that would exfiltrate a real secret."""
+    with pytest.raises(ValueError):
+        OIDCProviderUpdate(issuer_url="https://[::ffff:127.0.0.1]/")
+
+
+def test_issuer_url_error_names_the_field_not_the_icon():
+    with pytest.raises(ValueError, match="issuer_url"):
+        OIDCProviderUpdate(issuer_url="https://127.0.0.1/")
+
+
+def test_a_real_idp_still_validates():
+    provider = OIDCProviderCreate(
+        name="SSO",
+        issuer_url="https://accounts.google.com",
+        client_id="cid",
+        client_secret="secret",
+    )
+    assert provider.issuer_url == "https://accounts.google.com"
+
+
+# ---------------------------------------------------------------------------
+# Settings URLs
+# ---------------------------------------------------------------------------
+
+# Imported from the schema rather than duplicated, so the backstop below cannot
+# silently disagree with what is actually validated.
+LAN_SERVICE_SETTINGS = LAN_SERVICE_URL_SETTINGS
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_settings_urls_reject_dangerous_targets(field: str, url: str):
+    with pytest.raises(ValueError):
+        AppSettingsUpdate(**{field: url})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_settings_urls_permit_lan_hosts(field: str, url: str):
+    assert AppSettingsUpdate(**{field: url})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("empty", ["", "   "])
+def test_settings_urls_accept_empty_meaning_not_configured(field: str, empty: str):
+    """Empty is the documented "fall back to the env var" value for all four."""
+    assert AppSettingsUpdate(**{field: empty})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize(
+    "legacy",
+    [
+        "192.168.1.10:3333",  # urlparse: scheme='', netloc='', hostname=None
+        "localhost:3003",  # urlparse: scheme='localhost' (!), hostname=None
+        "obico.local:3333",  # same trap, with dots
+        "192.168.1.10",
+    ],
+)
+def test_settings_urls_do_not_newly_reject_scheme_less_legacy_values(field: str, legacy: str):
+    """Compatibility guard, not an endorsement.
+
+    The settings inputs are plain text with no scheme enforcement, so values
+    like these are already in the wild. They are inert — httpx raises
+    UnsupportedProtocol, so no request is issued — and they were storable
+    before the validator existed. Rejecting them now would block saves of
+    unrelated fields bundled in the same request (the Obico panel auto-saves
+    obico_ml_url alongside every other Obico setting).
+    """
+    assert AppSettingsUpdate(**{field: legacy})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+def test_settings_urls_still_reject_a_real_non_http_scheme(field: str):
+    """The leniency above is scoped to strings that are not URLs at all."""
+    with pytest.raises(ValueError):
+        AppSettingsUpdate(**{field: "file:///etc/passwd"})
+
+
+def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
+    """CI backstop: a new outbound-URL setting can't land unvalidated.
+
+    Any new ``*_url`` field on AppSettingsUpdate must be added to the
+    validator's field tuple or listed as exempt here with a reason. This
+    catches the failure mode the original report correctly identified — guards
+    added per-incident rather than to the whole class of fields.
+    """
+    exempt = {
+        # Bambuddy's own public address, not a destination it requests. It is
+        # rendered into notification bodies and OIDC redirect URIs, and handed
+        # to Obico's ML server as the `img` parameter for that server to fetch
+        # (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
+        # {nonce}`). Pointing it at a private address only breaks Bambuddy's own
+        # links; it cannot make Bambuddy request anything it otherwise wouldn't.
+        "external_url",
+        # Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
+        # location_service.py, inventory.py, spoolbuddy.py,
+        # spoolman_inventory.py) rather than in the schema, keeping its
+        # established user-facing "Spoolman URL ..." error wording.
+        "spoolman_url",
+        # Not an HTTP URL: ldap:// or ldaps://, handed to an LDAP client, never
+        # to httpx. The LAN-service guard requires http/https and would reject
+        # every valid value. It also cannot reach a cloud-metadata endpoint,
+        # since IMDS only speaks HTTP.
+        "ldap_server_url",
+    }
+    url_fields = {name for name in AppSettingsUpdate.model_fields if name.endswith("_url")}
+    unguarded = url_fields - set(LAN_SERVICE_SETTINGS) - exempt
+    assert not unguarded, (
+        f"New outbound URL setting(s) {sorted(unguarded)} are not covered by a "
+        f"SSRF guard. Add them to AppSettingsUpdate._LAN_SERVICE_URL_FIELDS (or "
+        f"the public-internet guard), or add them to `exempt` above with a reason."
+    )
+
+
+# ---------------------------------------------------------------------------
+# Notification providers: URL guard + no response-body echo
+# ---------------------------------------------------------------------------
+
+
+def _response(status: int = 500, body: str = "root:x:0:0:root:/root:/bin/bash") -> httpx.Response:
+    return httpx.Response(status_code=status, text=body, request=httpx.Request("POST", "http://10.0.0.1/"))
+
+
+SECRET_BODY = "root:x:0:0:root:/root:/bin/bash"
+
+
+def test_opaque_failure_does_not_return_the_response_body():
+    message = ns._opaque_http_failure(_response(), label="webhook endpoint")
+
+    assert SECRET_BODY not in message
+    assert "500" in message, "the status code is still useful and is not sensitive"
+    assert "webhook endpoint" in message
+
+
+def test_opaque_failure_logs_the_body_for_the_operator(caplog):
+    """The body stays available to whoever administers the host — via logs,
+    not via the API response."""
+    with caplog.at_level("DEBUG", logger=ns.__name__):
+        ns._opaque_http_failure(_response(), label="ntfy server")
+
+    assert SECRET_BODY in caplog.text
+
+
+@pytest.mark.parametrize(
+    "provider_label",
+    ["ntfy server", "Bark server", "webhook endpoint", "Home Assistant endpoint"],
+)
+def test_user_supplied_host_providers_use_the_opaque_path(provider_label: str):
+    """Guards the mapping itself: each user-supplied-host provider must route
+    its HTTP failure through _opaque_http_failure rather than formatting the
+    body inline."""
+    src = inspect.getsource(ns)
+    assert f'_opaque_http_failure(response, label="{provider_label}")' in src
+
+
+def test_no_user_supplied_host_provider_formats_the_body_inline():
+    """Any remaining ``response.text[:200]`` must belong to a host-pinned provider.
+
+    Pushover/Telegram/CallMeBot/Discord all target hardcoded hosts (Discord via
+    a webhook-prefix allowlist), so there is no trust boundary to cross.
+    """
+    src = inspect.getsource(ns).split("\n")
+    host_pinned = {"_send_callmebot", "_send_pushover", "_send_telegram", "_send_discord"}
+
+    current = None
+    offenders = []
+    for line in src:
+        match = re.match(r"\s+async def (_send_\w+)", line)
+        if match:
+            current = match.group(1)
+        if "response.text[:200]" in line and current not in host_pinned:
+            offenders.append(current)
+
+    assert not offenders, (
+        f"{offenders} echo the upstream response body but do not target a "
+        f"hardcoded host. Route the failure through _opaque_http_failure."
+    )
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_provider_url_guard_rejects_dangerous_targets(url: str):
+    assert ns._assert_safe_provider_url(url, label="Webhook URL") is not None
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_provider_url_guard_permits_self_hosted_servers(url: str):
+    assert ns._assert_safe_provider_url(url, label="ntfy server URL") is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("provider_type", "config"),
+    [
+        ("ntfy", {"server": "http://169.254.169.254", "topic": "t"}),
+        ("bark", {"server": "http://169.254.169.254", "device_key": "k"}),
+        ("webhook", {"webhook_url": "http://169.254.169.254/latest/meta-data/"}),
+    ],
+)
+async def test_test_config_refuses_metadata_targets_without_a_request(provider_type: str, config: dict, monkeypatch):
+    """The end-to-end shape of the reported attack: an unsaved config aimed at
+    IMDS via the test endpoint. It must be refused before any HTTP call."""
+    called = False
+
+    async def _fail_if_called(*_a, **_kw):
+        nonlocal called
+        called = True
+        raise AssertionError("outbound request should not have been attempted")
+
+    service = ns.NotificationService()
+    monkeypatch.setattr(service, "_get_client", _fail_if_called)
+
+    success, message = await service.send_test_notification(provider_type, config)
+
+    assert success is False
+    assert called is False
+    assert "cloud metadata" in message

+ 163 - 0
backend/tests/unit/test_spoolman_settings_value_coercion.py

@@ -0,0 +1,163 @@
+"""PUT /settings/spoolman must not 500 on a JSON boolean.
+
+The endpoint takes a free-form ``dict`` body, and settings are persisted in a
+VARCHAR column that every reader compares as a string. Sending the natural JSON
+form — ``{"spoolman_enabled": true}`` — used to fail twice over:
+
+- ``bool.lower()`` raised AttributeError while deciding whether the mode had
+  changed, surfacing as an opaque 500;
+- the raw bool was handed to ``upsert_setting``, which SQLite silently coerces
+  to 1/0 while asyncpg rejects it — so the stored representation depended on
+  the deployment's database.
+
+The shipped UI sends strings, so this was reachable only through the REST API
+(scripts, Home Assistant ``rest_command``) — which is exactly where a JSON
+boolean is the obvious thing to send.
+
+These tests cover the normalisers directly. They are pure functions, so the
+matrix stays readable and the endpoint keeps a single code path per field.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.api.routes.settings import (
+    normalize_bool_setting,
+    normalize_str_setting,
+    setting_is_true,
+)
+
+# ---------------------------------------------------------------------------
+# The reported crash
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")])
+def test_json_booleans_are_accepted_and_canonicalised(value: bool, expected: str):
+    """The exact input that used to 500."""
+    assert normalize_bool_setting("spoolman_enabled", value) == expected
+
+
+@pytest.mark.parametrize(("value", "expected"), [(1, "true"), (0, "false")])
+def test_json_numbers_one_and_zero_are_accepted(value: int, expected: str):
+    assert normalize_bool_setting("spoolman_enabled", value) == expected
+
+
+# ---------------------------------------------------------------------------
+# String spellings — generous on purpose, this is a documented REST surface
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["true", "TRUE", "True", " true ", "1", "yes", "on", "ON"])
+def test_truthy_spellings(value: str):
+    assert normalize_bool_setting("auto_add_unknown_rfid", value) == "true"
+
+
+@pytest.mark.parametrize("value", ["false", "FALSE", "False", " false ", "0", "no", "off"])
+def test_falsy_spellings(value: str):
+    assert normalize_bool_setting("auto_add_unknown_rfid", value) == "false"
+
+
+def test_python_style_capitalised_true_is_normalised_lowercase():
+    """The frontend compares with a case-sensitive ``=== 'true'``.
+
+    A client sending "True" previously had it stored verbatim, so the UI
+    rendered the setting as OFF while every backend reader (which all use
+    ``.lower()``) treated it as ON.
+    """
+    assert normalize_bool_setting("spoolman_enabled", "True") == "true"
+
+
+# ---------------------------------------------------------------------------
+# Empty means "use the default" — deliberately NOT normalised to "false"
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["", "   "])
+def test_empty_is_preserved_not_turned_into_false(value: str):
+    """get_spoolman_settings reads these with ``or "<default>"``.
+
+    spoolman_report_partial_usage and auto_add_unknown_rfid default to ON, so
+    coercing a blank submission to "false" would silently switch them off.
+    Whitespace-only collapses to "" so it takes the same path rather than
+    being stored as a truthy-but-meaningless "   ".
+    """
+    assert normalize_bool_setting("spoolman_report_partial_usage", value) == ""
+
+
+# ---------------------------------------------------------------------------
+# Values with no sensible reading get a 400 naming the field, not a 500
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["banana", "maybe", "2", "-1", None, [], {}, 3.5, 7])
+def test_uninterpretable_values_raise_400_naming_the_field(value: object):
+    with pytest.raises(HTTPException) as exc:
+        normalize_bool_setting("spoolman_enabled", value)
+
+    assert exc.value.status_code == 400
+    assert "spoolman_enabled" in str(exc.value.detail)
+
+
+# ---------------------------------------------------------------------------
+# String settings
+# ---------------------------------------------------------------------------
+
+
+def test_str_setting_passes_strings_through_untouched():
+    assert normalize_str_setting("spoolman_url", "http://192.168.1.5:7912/") == "http://192.168.1.5:7912/"
+
+
+def test_str_setting_stringifies_numbers():
+    """An unquoted host or port is a plausible client slip, not a hard error."""
+    assert normalize_str_setting("spoolman_url", 7912) == "7912"
+
+
+def test_str_setting_maps_null_to_empty():
+    assert normalize_str_setting("spoolman_url", None) == ""
+
+
+@pytest.mark.parametrize("value", [{"a": 1}, ["x"]])
+def test_str_setting_refuses_containers_rather_than_storing_a_repr(value: object):
+    with pytest.raises(HTTPException) as exc:
+        normalize_str_setting("spoolman_url", value)
+
+    assert exc.value.status_code == 400
+
+
+# ---------------------------------------------------------------------------
+# setting_is_true — used for the mode-switch comparison
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    ("stored", "expected"),
+    [
+        ("true", True),
+        ("True", True),
+        ("TRUE", True),
+        (" true ", True),
+        ("false", False),
+        ("", False),
+        ("banana", False),
+        (None, False),  # setting absent from the table
+        (True, True),  # legacy row: SQLite coerced a raw bool into the column
+        (False, False),
+    ],
+)
+def test_setting_is_true(stored: object, expected: bool):
+    assert setting_is_true(stored) is expected
+
+
+@pytest.mark.parametrize("stored", ["1", "on", "yes"])
+def test_setting_is_true_stays_narrower_than_the_write_path(stored: str):
+    """Reading must agree with the rest of the codebase, which only accepts "true".
+
+    normalize_bool_setting is generous about what clients may *send*; every
+    reader (spoolman_tracking, filament_deficit, inventory, spoolbuddy, labels,
+    main) compares ``.lower() == "true"``. Accepting more here would make the
+    mode-switch check disagree with them about a legacy row.
+    """
+    assert setting_is_true(stored) is False

Неке датотеке нису приказане због велике количине промена