Security hardening (maziggy/bambuddy-security #7)

fix(settings): accept JSON booleans on the Spoolman settings endpoint
This commit is contained in:
maziggy
2026-07-30 13:52:56 +02:00
parent d9da60dd8d
commit 88dc56d6e1
11 changed files with 931 additions and 123 deletions
+14 -10
View File
@@ -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":
+9 -55
View File
@@ -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.
"""Raise ValueError if the Spoolman *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.
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).
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.
Kept as a named function because the "Spoolman URL …" wording in its
errors is user-facing and asserted by existing tests.
"""
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()
# 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")
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")
assert_safe_lan_service_url(url, label="Spoolman URL")
_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
+76 -9
View File
@@ -1,19 +1,31 @@
"""Shared URL-safety primitives used by both SSRF guards in this package.
"""Shared URL-safety primitives for the 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.
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
View File
@@ -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