mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-12 21:28:37 +01:00
* fix(share): always assign the authenticated user as share owner A share's UserID was taken from the request body and only defaulted when empty, so any authenticated user could create a share attributed to another user. For playlist shares the contents are resolved in the owner's library-access context, turning the spoofed owner into an access-escalation vector in multi-library setups. Force the owner from the request context at both the service boundary and the persistence layer, ignoring any client-supplied UserID. * fix(plugins): block SSRF to private IPs resolved from hostnames The HTTP host client only checked the literal host string, so a symbolic hostname (or a trailing-dot "localhost.") resolving to a private/loopback address bypassed the SSRF guard when a plugin declared no requiredHosts. Enforce the check at dial time via net.Dialer.Control on the resolved IP, which also covers redirect hops and DNS rebinding. When an explicit requiredHosts allowlist is set, defer to it as the operator's trust decision. * fix(plugins): gate private IPs on explicit IP/CIDR allowlist entries Following review feedback: an allowlisted hostname authorizes the external service, not whatever private IP it may resolve or rebind to. Enforce the resolved-IP guard even when requiredHosts is set, permitting a private address only when a literal IP or CIDR entry explicitly covers it. This keeps "reach this external API" and "reach my internal network" as two separate, explicit operator decisions. * fix(plugins): treat unspecified addresses as private in the SSRF guard Dialing 0.0.0.0 or :: reaches the local host, so they bypassed the private/loopback check. * fix(plugins): let a bare "*" allowlist reach private addresses Plugins such as AudioMuse-AI declare requiredHosts ["*"] to reach a user-configured service on the LAN, whose address the manifest cannot know. Requiring a literal IP/CIDR entry broke them. Named hosts and subdomain wildcards still cannot resolve to private addresses. * refactor(plugins): simplify the SSRF-guarded HTTP client and release its pool Build the client directly around the guarded transport instead of replacing a throwaway one, fail closed on an unparseable dial address, and close the per-plugin transport's idle connections when the plugin unloads. Trim stale comments. * fix(plugins): stop enabling extism's unguarded http_request host function Passing requiredHosts as the extism manifest's AllowedHosts enabled extism's own http_request (pdk.NewHTTPRequest), which only glob-matches the hostname and follows redirects without re-checking, bypassing the resolved-IP SSRF guard. Plugins must use host.HTTPSend. * fix(plugins): move bundled Rust examples to the host HTTP service Extism's built-in http_request is now disabled, so the webhook and Discord examples switch to nd_pdk::host::http::send. Update the README to say host.HTTPSend is the only supported way to make HTTP requests. * fix(plugins): move the Python example to the host HTTP service coverartarchive-py used extism's built-in Http.request, which is now disabled. Call Navidrome's http_send host function instead. The plugin can no longer run under the standalone extism CLI, so drop the CLI test targets and instructions.
114 lines
3.3 KiB
Python
114 lines
3.3 KiB
Python
# Cover Art Archive Plugin for Navidrome
|
|
#
|
|
# This plugin fetches album cover art from the Cover Art Archive (https://coverartarchive.org/)
|
|
# using the MusicBrainz album MBID.
|
|
#
|
|
# Build with:
|
|
# extism-py plugin/__init__.py -o coverartarchive-py.wasm
|
|
|
|
import base64
|
|
import extism
|
|
import json
|
|
|
|
|
|
@extism.import_fn("extism:host/user", "http_send")
|
|
def http_send(req: dict) -> dict: ...
|
|
|
|
|
|
def http_get(url):
|
|
"""GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes)."""
|
|
resp = http_send({"request": {"method": "GET", "url": url}})
|
|
if resp.get("error"):
|
|
raise Exception(f"HTTP request failed: {resp['error']}")
|
|
result = resp.get("result") or {}
|
|
return result.get("statusCode", 0), base64.b64decode(result.get("body") or "")
|
|
|
|
|
|
@extism.plugin_fn
|
|
def nd_get_album_images():
|
|
"""Retrieve album cover images from Cover Art Archive."""
|
|
input_data = extism.input_json()
|
|
mbid = input_data.get("mbid", "")
|
|
|
|
if not mbid:
|
|
raise Exception("not found: MBID required")
|
|
|
|
# Query Cover Art Archive API
|
|
url = f"https://coverartarchive.org/release/{mbid}"
|
|
status, body = http_get(url)
|
|
|
|
if status != 200:
|
|
raise Exception(f"not found: CAA returned status {status}")
|
|
|
|
try:
|
|
data = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
raise Exception("not found: invalid JSON response")
|
|
|
|
caa_images = data.get("images", [])
|
|
if not caa_images:
|
|
raise Exception("not found: no images in response")
|
|
|
|
# Find the front cover image
|
|
front_image = find_front_image(caa_images)
|
|
if not front_image:
|
|
raise Exception("not found: no front cover image")
|
|
|
|
# Build the response with available image sizes
|
|
images = build_image_list(front_image)
|
|
if not images:
|
|
raise Exception("not found: no usable image URLs")
|
|
|
|
extism.output_str(json.dumps({"images": images}))
|
|
|
|
|
|
def find_front_image(images):
|
|
"""Find the front cover image from CAA response."""
|
|
# First, look for an image explicitly marked as front
|
|
for img in images:
|
|
if img.get("front", False):
|
|
return img
|
|
|
|
# Second, look for an image with "Front" in types
|
|
for img in images:
|
|
types = img.get("types", [])
|
|
if "Front" in types:
|
|
return img
|
|
|
|
# Fallback to first image
|
|
if images:
|
|
return images[0]
|
|
|
|
return None
|
|
|
|
|
|
def build_image_list(img):
|
|
"""Build list of images with URLs and sizes from CAA image data."""
|
|
images = []
|
|
thumbnails = img.get("thumbnails", {})
|
|
|
|
# First, try numeric sizes (250, 500, 1200, etc.)
|
|
for size_str, url in thumbnails.items():
|
|
if not url:
|
|
continue
|
|
try:
|
|
size = int(size_str)
|
|
images.append({"url": url, "size": size})
|
|
except ValueError:
|
|
pass # Not a numeric size
|
|
|
|
# If no numeric sizes, fallback to named sizes
|
|
if not images:
|
|
size_map = {"large": 500, "small": 250}
|
|
for size_name, size in size_map.items():
|
|
url = thumbnails.get(size_name)
|
|
if url:
|
|
images.append({"url": url, "size": size})
|
|
|
|
# If still no images, use the main image URL
|
|
if not images:
|
|
main_url = img.get("image")
|
|
if main_url:
|
|
images.append({"url": main_url, "size": 0})
|
|
|
|
return images
|