mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-14 17:39:12 +02:00
fix(rest): reject empty Delete path params in reverse-proxy clients
ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).
Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.
This commit is contained in:
@@ -2,6 +2,7 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
@@ -33,6 +34,12 @@ func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster,
|
|||||||
// NetBird cannot be deleted via this endpoint; the server returns 404 / 400
|
// NetBird cannot be deleted via this endpoint; the server returns 404 / 400
|
||||||
// for cluster addresses the account does not own.
|
// for cluster addresses the account does not own.
|
||||||
func (a *ReverseProxyClustersAPI) Delete(ctx context.Context, clusterAddress string) error {
|
func (a *ReverseProxyClustersAPI) Delete(ctx context.Context, clusterAddress string) error {
|
||||||
|
// Guard against the empty input: url.PathEscape("") returns "" which
|
||||||
|
// would collapse the request URL onto the collection endpoint and
|
||||||
|
// silently delete nothing (or 405 depending on routing).
|
||||||
|
if clusterAddress == "" {
|
||||||
|
return errors.New("clusterAddress is required")
|
||||||
|
}
|
||||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/clusters/"+url.PathEscape(clusterAddress), nil, nil)
|
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/clusters/"+url.PathEscape(clusterAddress), nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -88,3 +88,17 @@ func TestReverseProxyClusters_Delete_Err(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReverseProxyClusters_Delete_EmptyAddress guards against an empty
|
||||||
|
// clusterAddress reaching the wire — that would collapse the URL onto
|
||||||
|
// the collection endpoint instead of a specific cluster. The client
|
||||||
|
// must short-circuit with a typed error before any request is issued.
|
||||||
|
func TestReverseProxyClusters_Delete_EmptyAddress(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/reverse-proxies/clusters/", func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("empty clusterAddress must be rejected client-side; no request should reach the server")
|
||||||
|
})
|
||||||
|
err := c.ReverseProxyClusters.Delete(context.Background(), "")
|
||||||
|
assert.Error(t, err, "empty clusterAddress must surface as an error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
@@ -61,6 +62,12 @@ func (a *ReverseProxyTokensAPI) Create(ctx context.Context, request api.ProxyTok
|
|||||||
// credentials existed; the plain secret can no longer authenticate any
|
// credentials existed; the plain secret can no longer authenticate any
|
||||||
// new proxy registration.
|
// new proxy registration.
|
||||||
func (a *ReverseProxyTokensAPI) Delete(ctx context.Context, tokenID string) error {
|
func (a *ReverseProxyTokensAPI) Delete(ctx context.Context, tokenID string) error {
|
||||||
|
// Guard against the empty input: url.PathEscape("") returns "" which
|
||||||
|
// would collapse the request URL onto the collection endpoint and
|
||||||
|
// silently delete nothing (or 405 depending on routing).
|
||||||
|
if tokenID == "" {
|
||||||
|
return errors.New("tokenID is required")
|
||||||
|
}
|
||||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/proxy-tokens/"+url.PathEscape(tokenID), nil, nil)
|
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/proxy-tokens/"+url.PathEscape(tokenID), nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -129,3 +129,16 @@ func TestReverseProxyTokens_Delete_Err(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReverseProxyTokens_Delete_EmptyID guards against an empty tokenID
|
||||||
|
// reaching the wire — url.PathEscape("") would collapse the URL onto
|
||||||
|
// the collection endpoint.
|
||||||
|
func TestReverseProxyTokens_Delete_EmptyID(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/reverse-proxies/proxy-tokens/", func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("empty tokenID must be rejected client-side; no request should reach the server")
|
||||||
|
})
|
||||||
|
err := c.ReverseProxyTokens.Delete(context.Background(), "")
|
||||||
|
assert.Error(t, err, "empty tokenID must surface as an error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user