mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +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"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -68,6 +69,56 @@ func (s *StreamService) directPlayOnly(ctx context.Context) bool {
|
|||||||
return parseBoolSetting(v, false)
|
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
|
// ServeFile streams the file backing the given media ID using
|
||||||
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
|
// 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
|
return ErrMediaNotFound
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(m.STRMURL) != "" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
f, err := os.Open(m.Path)
|
f, err := os.Open(m.Path)
|
||||||
|
|||||||
@@ -1,10 +1,50 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestAppendQueryToHLSSegments(t *testing.T) {
|
||||||
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
||||||
got := appendQueryToHLSSegments(in, "token=abc")
|
got := appendQueryToHLSSegments(in, "token=abc")
|
||||||
|
|||||||
Reference in New Issue
Block a user