navidrome/server/apiv1/gate_test.go
Deluan 870942c7dd fix(api): record the full client IP, challenge presented tokens and mark token responses no-store
The gate passed server.ClientIP to Authenticate and ResolveGrant, which
masks IPv6 addresses to their /64 for rate limiting, so lastUsedIp stored
a prefix. A new server.ClientAddr returns the resolved address unmasked;
ClientIP builds on it and stays the rate limiter key.

A 401 raised after a token was accepted by the gate, such as a password
change whose grant was revoked mid-request, carried a bare Bearer
challenge. writeProblemStatus now answers Bearer error="invalid_token"
whenever the request presented a bearer token.

login, setupFirstAdmin and createAccessToken responses now carry
Cache-Control: no-store (RFC 6749 section 5.1), set by the gate for a
small list of operations so their error responses are covered too.

The v0/v1 setup race test also checks the v1 status is 201 or 409.
2026-09-28 20:06:28 -04:00

359 lines
13 KiB
Go

package apiv1
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const gateSpec = `
openapi: 3.0.3
info: {title: t, version: "1"}
paths:
/open:
get: {operationId: open, x-module: core, security: [], responses: {'200': {description: ok}}}
/things/{id}:
get:
operationId: getThing
x-module: core
x-scope: read
security: [{bearerAuth: []}]
parameters: [{name: id, in: path, required: true, schema: {type: string, maxLength: 3}}]
responses: {'200': {description: ok}}
/things:
post:
operationId: createThing
x-module: password
x-scope: password
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties: {name: {type: string, maxLength: 5}}
responses: {'200': {description: ok}}
/caps:
get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}}
/mint:
post: {operationId: mint, x-module: core, security: [{grantAuth: []}], responses: {'200': {description: ok}}}
/limited:
post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer}
grantAuth: {type: http, scheme: bearer}
`
type fakeAuth struct {
principal *apiauth.Principal
err error
gotToken string
gotSecret string
gotIP string
}
func (f *fakeAuth) Authenticate(_ context.Context, token, ip string) (*apiauth.Principal, error) {
f.gotToken, f.gotIP = token, ip
return f.principal, f.err
}
func (f *fakeAuth) ResolveGrant(_ context.Context, secret, ip string) (*apiauth.Principal, error) {
f.gotSecret, f.gotIP = secret, ip
return f.principal, f.err
}
var testGateRules = gateRules{
limited: map[string]bool{"limited": true},
noScope: map[string]bool{"caps": true},
grantOps: map[string]bool{"mint": true},
noStore: map[string]bool{"mint": true},
}
var _ = Describe("spec gate", func() {
var ctx context.Context
var fa *fakeAuth
var mux *chi.Mux
var g *gate
var reached string
build := func(spec string) (*chi.Mux, error) {
doc, err := openapi3.NewLoader().LoadFromData([]byte(spec))
Expect(err).ToNot(HaveOccurred())
m := chi.NewRouter()
g, err = newGate(doc, m, fa, testGateRules)
if err != nil {
return nil, err
}
m.Use(headAsGet(m), g.handler)
ok := func(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reached = name
if p, found := apiauth.PrincipalFrom(r.Context()); found {
w.Header().Set("X-User", p.User.ID)
}
w.WriteHeader(http.StatusOK)
}
}
m.Get("/open", ok("open"))
m.Get("/things/{id}", ok("getThing"))
m.Post("/things", ok("createThing"))
m.Get("/caps", ok("caps"))
m.Post("/mint", ok("mint"))
m.Post("/limited", ok("limited"))
return m, nil
}
do := func(method, path, auth, body string) *httptest.ResponseRecorder {
var req *http.Request
if body != "" {
req = httptest.NewRequestWithContext(ctx, method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequestWithContext(ctx, method, path, nil)
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
return w
}
BeforeEach(func() {
ctx = GinkgoT().Context()
reached = ""
fa = &fakeAuth{principal: &apiauth.Principal{User: model.User{ID: "u1"}, GrantID: "g1", Scopes: []string{"read"}}}
var err error
mux, err = build(gateSpec)
Expect(err).ToNot(HaveOccurred())
})
It("lets public operations through without a token", func() {
Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK))
Expect(reached).To(Equal("open"))
})
It("requires a token, with a Bearer challenge", func() {
w := do(http.MethodGet, "/things/1", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
Expect(reached).To(BeEmpty())
})
It("accepts the Bearer scheme in any case and trims spaces", func() {
w := do(http.MethodGet, "/things/1", "bearer tok-1 ", "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotToken).To(Equal("tok-1"))
Expect(w.Header().Get("X-User")).To(Equal("u1"))
})
It("maps an expired token to token_expired", func() {
fa.err = apiauth.ErrTokenExpired
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeTokenExpired))
})
It("maps other auth failures to unauthorized with invalid_token", func() {
fa.err = model.ErrInvalidAuth
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
})
It("rejects a token without the operation's scope", func() {
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("lets any valid token through an operation with no x-scope", func() {
fa.principal.Scopes = nil
Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK))
})
DescribeTable("passes the full client address to the authenticator, not the rate-limit /64",
func(method, path string) {
req := httptest.NewRequestWithContext(ctx, method, path, nil)
req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321"
req.Header.Set("Authorization", "Bearer x")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6"))
},
Entry("access token", http.MethodGet, "/things/1"),
Entry("grant", http.MethodPost, "/mint"),
)
It("marks only the listed operations' responses no-store, errors included", func() {
Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = model.ErrInvalidAuth
Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = nil
Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty())
})
It("uses ResolveGrant for grantAuth operations", func() {
Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Code).To(Equal(http.StatusOK))
Expect(fa.gotSecret).To(Equal("ndg_secret"))
Expect(fa.gotToken).To(BeEmpty())
})
It("checks HEAD on a protected GET", func() {
w := do(http.MethodHead, "/things/1", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("turns an insufficient-scope error from Authenticate into a 403 challenge", func() {
fa.err = apiauth.ErrInsufficientScope // e.g. a token carrying admin after demotion
w := do(http.MethodGet, "/caps", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("names the operation's scope when Authenticate reports an insufficient scope", func() {
fa.err = apiauth.ErrInsufficientScope
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`))
})
It("looks routes up on the raw path, as chi dispatches them", func() {
w := do(http.MethodGet, "/things/a%2Fb", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(reached).To(BeEmpty())
root := chi.NewRouter()
root.Mount("/music/api/v1", mux)
w = httptest.NewRecorder()
root.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/a%2Fb", nil))
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(reached).To(BeEmpty())
})
It("works when mounted under a base path", func() {
root := chi.NewRouter()
root.Mount("/music/api/v1", mux)
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/1", nil)
w := httptest.NewRecorder()
root.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("authenticates before validating", func() {
w := do(http.MethodPost, "/things", "", `{"name":"far-too-long"}`)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("returns and logs sanitised validation errors that never echo the value", func() {
logs := captureLogs()
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"hunter2-secret"}`)
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is too long"}))
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
Expect(logs.String()).To(ContainSubstring("failed validation"))
Expect(logs.String()).ToNot(ContainSubstring("hunter2"))
})
It("reports a missing required body field by name", func() {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{}`)
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is required"}))
})
It("validates path parameters", func() {
w := do(http.MethodGet, "/things/toolong", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
})
It("passes unknown paths through to the router's 404", func() {
Expect(do(http.MethodGet, "/nope", "", "").Code).To(Equal(http.StatusNotFound))
})
It("fails closed for a routed pattern the spec does not know", func() {
mux.Get("/extra", func(w http.ResponseWriter, r *http.Request) { reached = "extra" })
w := do(http.MethodGet, "/extra", "", "")
Expect(w.Code).To(Equal(http.StatusInternalServerError))
Expect(reached).To(BeEmpty())
})
It("rate-limits the listed operations with a 429 problem", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
var err error
mux, err = build(gateSpec)
Expect(err).ToNot(HaveOccurred())
Expect(do(http.MethodPost, "/limited", "", "").Code).To(Equal(http.StatusOK))
w := do(http.MethodPost, "/limited", "", "")
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty())
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeRateLimited))
})
DescribeTable("refuses specs that break the security rules",
func(bad string) {
_, err := build(bad)
Expect(err).To(HaveOccurred())
},
Entry("missing security", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core,", 1)),
Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)),
Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)),
Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)),
Entry("grantAuth outside the allowlist", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)),
Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)),
Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)),
)
It("checks routes against the spec in both directions", func() {
Expect(g.checkRoutes()).To(Succeed())
mux.Get("/extra", func(http.ResponseWriter, *http.Request) {})
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("GET /extra is not in the spec")))
extraOp := strings.Replace(gateSpec, "components:", ` /unrouted:
get: {operationId: unrouted, x-module: core, security: [], responses: {'200': {description: ok}}}
components:`, 1)
_, err := build(extraOp)
Expect(err).ToNot(HaveOccurred())
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("unrouted")))
})
})
// captureLogs sends debug logs to a buffer for the rest of the spec.
func captureLogs() *bytes.Buffer {
buf := &bytes.Buffer{}
log.SetOutput(buf)
log.SetLevel(log.LevelDebug)
DeferCleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(log.LevelFatal)
})
return buf
}