Commit graph

5 commits

Author SHA1 Message Date
Deluan Quintão
101145742f
test(plugins): stub DNS in the host SSRF guard tests (#6208)
The dial-time SSRF guard runs on the resolved IP, so the tests that prove a
symbolic hostname cannot reach loopback used "localhost." — a trailing dot never
matches /etc/hosts, so Go queries real DNS. Machines whose resolver does not
answer "localhost." (a VPN DNS, for example) got "no such host" before the dial
guard ever ran, failing three specs.

Add tests.StubResolver, a net.Resolver backed by an in-memory DNS responder over
net.Pipe, and let the plugin dialers take a resolver so tests can inject it.
Name resolution in those specs no longer depends on the machine's DNS.
2026-09-23 18:42:11 -04:00
Deluan Quintão
1a8463f7de
Merge commit from fork
* 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.
2026-09-12 13:38:08 -04:00
Deluan Quintão
c9385fbb6b
test(plugins): build test plugins in Go instead of shelling out to make (#6060)
* test(plugins): build test plugins in Go instead of shelling out to make

The plugins suite built its .ndp test packages by running `make -C
plugins/testdata`, which needs make and zip on the PATH. That is the reason
the 26 WASM-dependent spec files are tagged //go:build !windows.

buildTestPlugins now does the same work in Go: the same mtime check make
performed, `GOOS=wasip1 GOARCH=wasm go build` per plugin, and archive/zip
for the package. TinyGo was already optional and unused in CI, so nothing is
lost there. The first plugin builds on its own so the shared wasip1 stdlib
and PDK objects land in the build cache before the rest fan out: on a cold
cache that is 2.2s against 3.4s for the sequential make and 7.3s for an
unrestrained fan-out.

Packaging moved into a writeNdp helper shared with createTestPackage, which
was already writing the same two-entry archive. Entries are written in a
fixed order, so the .ndp bytes are now reproducible; the loader hashes those
bytes, and `zip` also stored file mtimes, so the previous packages differed
on every rebuild.

The Makefile is unchanged and still works for building the plugins by hand.
Removing the !windows tags is a separate step, once CI is green here.

* test(plugins): run the WASM plugin specs on Windows

With the test plugins now built in Go, nothing in the suite needs a Unix
toolchain, so the //go:build !windows tags come off all 25 spec files. The
Windows CI job runs `go test ./...`, so it picks the suite up with no
workflow change.

plugins_suite_windows_test.go existed only to bootstrap the handful of specs
that compiled on Windows; plugins_suite_test.go now serves both.

* test(plugins): skip the planted-symlink spec where symlinks need privileges

os.Symlink needs an elevated token or Developer Mode on Windows, so the
unconditional Expect(...).To(Succeed()) would fail for contributors running
the suite on an ordinary Windows box. The elevated GitHub runner hides this.

The equivalent spec in sandbox_fs_internal_test.go already attempts the
symlink and skips on error; this does the same, keeping the pin live
everywhere it can run, including Windows CI.

* fix(ci): stop the Windows ndpgen test failing silently

The ndpgen suite builds its helper binary to %TEMP%\ndpgen-test, and Windows
will not exec a file without an executable extension, so the "supports
verbose mode" spec has been failing there. Nobody noticed because the
Test ndpgen step ran under pwsh, which carries on after a non-zero exit and
takes the step's status from the last command, so the job stayed green with
a FAIL line in its log.

Add the .exe suffix, and run the step under bash like the Linux job does, so
a failure in any of its three commands fails the job.
2026-08-31 21:42:10 -04:00
Deluan
03844a9a36 feat(plugins): add NoFollowRedirects option to HTTPRequest
Allow plugins to opt out of automatic redirect following on a per-request
basis. When set to true, the response returns the redirect status code and
Location header directly instead of following to the final destination.
2026-03-20 18:16:07 -04:00
Deluan Quintão
652c27690b
feat(plugins): add HTTP host service (#5095)
* feat(httpclient): implement HttpClient service for outbound HTTP requests in plugins

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(httpclient): enhance SSRF protection by validating host requests against private IPs

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(httpclient): support DELETE requests with body in HttpClient service

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(httpclient): refactor HTTP client initialization and enhance redirect handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(http): standardize naming conventions for HTTP types and methods

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor example plugin to use host.HTTPSend for improved error management

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): fix IPv6 SSRF bypass and wildcard host matching

Fix two bugs in the plugin HTTP/WebSocket host validation:

1. extractHostname now strips IPv6 brackets when no port is present
(e.g. "[::1]" → "::1"). Previously, net.SplitHostPort failed for
bracketed IPv6 without a port, leaving brackets intact. This caused
net.ParseIP to return nil, bypassing the private/loopback SSRF guard.

2. matchHostPattern now treats "*" as an allow-all pattern. Previously,
a bare "*" only matched via exact equality, so plugins declaring
requiredHosts: ["*"] (like webhook-rs) had all requests rejected.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-24 14:28:36 -05:00