fix(plex): strip X-Plex-Token on cross-host redirect

The Plex integration used bare http.Client values with no CheckRedirect
policy, so they followed redirects automatically. net/http strips the
standard sensitive headers on a cross-host redirect but not custom-named
headers, so the Plex token carried in X-Plex-Token was forwarded verbatim
to any host the configured PLEX_HOST redirected to, disclosing the
credential.

Route all Plex outbound calls through a shared client whose CheckRedirect
policy deletes X-Plex-Token when the redirect target host differs from the
original request host. Includes a regression test.

Signed-off-by: tonghuaroot <tonghuaroot@gmail.com>
This commit is contained in:
tonghuaroot
2026-05-29 01:35:37 +08:00
committed by momi
parent 8c8d604bc6
commit 4e6e71687b
2 changed files with 83 additions and 7 deletions
+25 -7
View File
@@ -334,6 +334,24 @@ type PlexClientResources []struct {
} `json:"connections"`
}
// plexHTTPClient is the shared client for all Plex outbound calls. Its
// CheckRedirect policy strips the custom X-Plex-Token header when a redirect
// crosses to a different host. net/http already strips the standard sensitive
// headers (Authorization, Cookie, WWW-Authenticate) on a cross-host redirect,
// but it does NOT strip custom-named headers, so without this the Plex token
// would be forwarded to any host the configured PLEX_HOST redirects to.
var plexHTTPClient = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
req.Header.Del("X-Plex-Token")
}
return nil
},
}
type Service struct {
cfg *config.ServerConfig
}
@@ -345,7 +363,7 @@ func NewService(cfg *config.ServerConfig) *Service {
}
func (s *Service) GetPlexIdentity(host string) (PlexIdentity, error) {
httpClient := &http.Client{}
httpClient := plexHTTPClient
req, err := http.NewRequest("GET", host+"/identity", nil)
if err != nil {
return PlexIdentity{}, err
@@ -369,7 +387,7 @@ func (s *Service) GetPlexIdentity(host string) (PlexIdentity, error) {
}
func (s *Service) FetchPlexAccountFromToken(token string) (PlexUser, error) {
httpClient := &http.Client{}
httpClient := plexHTTPClient
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
if err != nil {
return PlexUser{}, err
@@ -419,7 +437,7 @@ func (s *Service) UpdateConfigPlexHost(cfg *config.ServerConfig, v string) (Plex
}
func (s *Service) GetPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
httpClient := &http.Client{}
httpClient := plexHTTPClient
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections", nil)
if err != nil {
return PlexLibrariesResponse{}, err
@@ -444,7 +462,7 @@ func (s *Service) GetPlexLibraries(plexAuth string) (PlexLibrariesResponse, erro
}
func (s *Service) GetPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
httpClient := &http.Client{}
httpClient := plexHTTPClient
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
if err != nil {
return PlexLibraryItemsResponse{}, err
@@ -469,7 +487,7 @@ func (s *Service) GetPlexLibraryItems(plexAuth string, libraryKey string) (PlexL
}
func (s *Service) GetPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
httpClient := &http.Client{}
httpClient := plexHTTPClient
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
@@ -494,7 +512,7 @@ func (s *Service) GetPlexLibraryItemSeasons(plexAuth string, ratingKey string) (
}
func (s *Service) GetPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
httpClient := &http.Client{}
httpClient := plexHTTPClient
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
@@ -522,7 +540,7 @@ func (s *Service) GetPlexLibraryItemEpisodes(plexAuth string, ratingKey string)
// so they can authenticate against it for api requests.
// If no auth token is returned or errored, assume user doesn't have access to home plex server library.
func (s *Service) GetPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
httpClient := &http.Client{}
httpClient := plexHTTPClient
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
if err != nil {
return "", err
+58
View File
@@ -0,0 +1,58 @@
package plex
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/sbondCo/Watcharr/config"
)
// TestGetPlexLibraries_StripsTokenOnCrossHostRedirect verifies the Plex client
// does not forward X-Plex-Token across a cross-host redirect but preserves it
// on a same-host redirect.
func TestGetPlexLibraries_StripsTokenOnCrossHostRedirect(t *testing.T) {
const token = "secret-plex-token"
t.Run("cross-host strips token", func(t *testing.T) {
var finalToken string
final := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
finalToken = r.Header.Get("X-Plex-Token")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"MediaContainer":{}}`))
}))
defer final.Close()
redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, final.URL+r.URL.Path, http.StatusFound)
}))
defer redir.Close()
svc := NewService(&config.ServerConfig{PLEX_HOST: redir.URL})
_, _ = svc.GetPlexLibraries(token)
if finalToken != "" {
t.Fatalf("X-Plex-Token forwarded cross-host = %q, want empty", finalToken)
}
})
t.Run("same-host keeps token", func(t *testing.T) {
var finalToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("r") == "1" {
finalToken = r.Header.Get("X-Plex-Token")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"MediaContainer":{}}`))
return
}
http.Redirect(w, r, r.URL.Path+"?r=1", http.StatusFound)
}))
defer srv.Close()
svc := NewService(&config.ServerConfig{PLEX_HOST: srv.URL})
_, _ = svc.GetPlexLibraries(token)
if finalToken != token {
t.Fatalf("X-Plex-Token on same host = %q, want %q", finalToken, token)
}
})
}