mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
fix(115/302): propagate auth token across stream→cloud/play redirect so browser <video> stays authenticated
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -68,6 +69,56 @@ func (s *StreamService) directPlayOnly(ctx context.Context) bool {
|
||||
return parseBoolSetting(v, false)
|
||||
}
|
||||
|
||||
// withAuthToken propagates the caller's auth token onto an internal redirect
|
||||
// target. A browser <video> element cannot send Authorization headers or
|
||||
// cookies when it follows a 302, so the cloud 302 chain
|
||||
// (/api/stream?token=… → /api/cloud/play → CDN) would otherwise hit
|
||||
// /api/cloud/play unauthenticated and 401. We only attach the token to our
|
||||
// own relative API endpoints — never to an absolute external direct link —
|
||||
// so the JWT is never leaked off-site (e.g. to the cloud CDN).
|
||||
func withAuthToken(target string, r *http.Request) string {
|
||||
if r == nil {
|
||||
return target
|
||||
}
|
||||
if strings.HasPrefix(target, "//") {
|
||||
return target
|
||||
}
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.IsAbs() {
|
||||
return target
|
||||
}
|
||||
tok := requestToken(r)
|
||||
if tok == "" {
|
||||
return target
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("token") == "" {
|
||||
q.Set("token", tok)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// requestToken extracts the bearer JWT from the incoming request the same way
|
||||
// the auth middleware does (Authorization header, Emby token headers, or the
|
||||
// token / api_key query params used by <video>.src).
|
||||
func requestToken(r *http.Request) string {
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||
}
|
||||
for _, hk := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if v := strings.TrimSpace(r.Header.Get(hk)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"token", "api_key", "apiKey", "ApiKey"} {
|
||||
if v := strings.TrimSpace(r.URL.Query().Get(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ServeFile streams the file backing the given media ID using
|
||||
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
|
||||
//
|
||||
@@ -83,7 +134,7 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
return ErrMediaNotFound
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
http.Redirect(w, r, m.STRMURL, http.StatusFound)
|
||||
http.Redirect(w, r, withAuthToken(m.STRMURL, r), http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(m.Path)
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithAuthTokenPropagatesToInternalRedirect(t *testing.T) {
|
||||
// <video src=/api/stream/{id}?token=JWT> follows the 302 to the cloud
|
||||
// play endpoint, which must stay authenticated.
|
||||
r := &http.Request{Header: http.Header{}, URL: &url.URL{RawQuery: "token=jwt123&profile=p"}}
|
||||
got := withAuthToken("/api/cloud/play/cloud115?ref=abc", r)
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if u.Query().Get("token") != "jwt123" {
|
||||
t.Fatalf("token not propagated: %q", got)
|
||||
}
|
||||
if u.Query().Get("ref") != "abc" {
|
||||
t.Fatalf("existing query lost: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAuthTokenNeverLeaksToAbsoluteURL(t *testing.T) {
|
||||
// An absolute external direct link (e.g. cloud CDN) must NOT receive the JWT.
|
||||
r := &http.Request{Header: http.Header{}, URL: &url.URL{RawQuery: "token=jwt123"}}
|
||||
got := withAuthToken("https://cdn.115.example/x.mp4?sig=1", r)
|
||||
if strings.Contains(got, "jwt123") {
|
||||
t.Fatalf("JWT leaked to external URL: %q", got)
|
||||
}
|
||||
if got != "https://cdn.115.example/x.mp4?sig=1" {
|
||||
t.Fatalf("external URL mutated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestTokenFromBearerHeader(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Authorization", "Bearer hdrtok")
|
||||
r := &http.Request{Header: h, URL: &url.URL{}}
|
||||
if got := requestToken(r); got != "hdrtok" {
|
||||
t.Fatalf("bearer token not extracted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendQueryToHLSSegments(t *testing.T) {
|
||||
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
||||
got := appendQueryToHLSSegments(in, "token=abc")
|
||||
|
||||
Reference in New Issue
Block a user