mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Security hardening (maziggy/bambuddy-security #7)
fix(settings): accept JSON booleans on the Spoolman settings endpoint
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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}$")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user