@@ -0,0 +1,269 @@
|
||||
package hevcpull
|
||||
|
||||
import (
|
||||
"91porn-server/common/log"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ExpiresParam = "hevc_exp"
|
||||
SignatureParam = "hevc_sig"
|
||||
|
||||
minSecretBytes = 32
|
||||
maxSignatureTTL = 480*time.Hour + 5*time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSecret = errors.New("HEVC pull secret must contain at least 32 bytes")
|
||||
ErrInvalidSignature = errors.New("invalid HEVC pull signature")
|
||||
ErrExpired = errors.New("expired HEVC pull signature")
|
||||
|
||||
signatureTextPattern = regexp.MustCompile(`(?i)(hevc_sig(?:=|%3D))[0-9a-f]{64}`)
|
||||
)
|
||||
|
||||
// NormalizeSource accepts only the canonical relative m3u8 paths stored by
|
||||
// the video service. Absolute URLs, query strings, fragments, backslashes,
|
||||
// and dot traversal are rejected so the signed path and cloud task identity
|
||||
// always refer to the same source.
|
||||
func NormalizeSource(source string) (string, error) {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" || strings.Contains(source, "\\") {
|
||||
return "", errors.New("invalid HEVC pull source")
|
||||
}
|
||||
parsed, err := url.Parse(source)
|
||||
if err != nil ||
|
||||
parsed.IsAbs() ||
|
||||
parsed.Host != "" ||
|
||||
parsed.User != nil ||
|
||||
parsed.RawQuery != "" ||
|
||||
parsed.Fragment != "" {
|
||||
return "", errors.New("invalid HEVC pull source")
|
||||
}
|
||||
sourcePath := strings.TrimLeft(parsed.Path, "/")
|
||||
if sourcePath == "" || strings.Contains(sourcePath, "\\") {
|
||||
return "", errors.New("invalid HEVC pull source")
|
||||
}
|
||||
for _, char := range sourcePath {
|
||||
if char < 0x20 || char == 0x7f {
|
||||
return "", errors.New("invalid HEVC pull source")
|
||||
}
|
||||
}
|
||||
for _, segment := range strings.Split(sourcePath, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return "", errors.New("invalid HEVC pull source traversal")
|
||||
}
|
||||
}
|
||||
normalized := strings.TrimLeft(path.Clean("/"+sourcePath), "/")
|
||||
if normalized == "" ||
|
||||
normalized == "." ||
|
||||
path.Ext(normalized) != ".m3u8" {
|
||||
return "", errors.New("invalid HEVC pull source")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// ResolveChildSource resolves a child playlist URI from a signed master
|
||||
// playlist against the parent source path. Only local m3u8 paths are accepted;
|
||||
// absolute URLs and query-bearing variants are rejected.
|
||||
func ResolveChildSource(parentSource, childURI string) (string, error) {
|
||||
parentSource, err := NormalizeSource(parentSource)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
childURI = strings.TrimSpace(childURI)
|
||||
if childURI == "" || strings.Contains(childURI, "\\") {
|
||||
return "", errors.New("invalid HEVC child playlist")
|
||||
}
|
||||
child, err := url.Parse(childURI)
|
||||
if err != nil ||
|
||||
child.IsAbs() ||
|
||||
child.Host != "" ||
|
||||
child.User != nil ||
|
||||
child.RawQuery != "" ||
|
||||
child.Fragment != "" ||
|
||||
child.Path == "" {
|
||||
return "", errors.New("invalid HEVC child playlist")
|
||||
}
|
||||
|
||||
parentNamespace := knownSourceNamespace(parentSource)
|
||||
var resolved string
|
||||
if strings.HasPrefix(child.Path, "/") {
|
||||
resolved = strings.TrimLeft(child.Path, "/")
|
||||
childNamespace := knownSourceNamespace(resolved)
|
||||
switch parentNamespace {
|
||||
case "", "sp":
|
||||
// A path without a reserved prefix still resolves to the default
|
||||
// SP origin. Explicitly switching to another origin is forbidden.
|
||||
if childNamespace != "" && childNamespace != "sp" {
|
||||
return "", errors.New("HEVC child playlist changes source namespace")
|
||||
}
|
||||
default:
|
||||
// For non-default origins the source namespace is encoded in the
|
||||
// route path. A plain root-relative URI cannot preserve that origin,
|
||||
// so require the playlist to name the same namespace explicitly.
|
||||
if childNamespace != parentNamespace {
|
||||
return "", errors.New("ambiguous HEVC root-relative child playlist")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if parentNamespace != "" && childEscapesSourceNamespace(parentSource, child.Path) {
|
||||
return "", errors.New("HEVC child playlist escapes source namespace")
|
||||
}
|
||||
resolved = path.Join("/", path.Dir(parentSource), child.Path)
|
||||
}
|
||||
resolved, err = NormalizeSource(strings.TrimLeft(resolved, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(child.Path, "/") &&
|
||||
parentNamespace != "" &&
|
||||
knownSourceNamespace(resolved) != parentNamespace {
|
||||
return "", errors.New("HEVC child playlist escapes source namespace")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func childEscapesSourceNamespace(parentSource, childPath string) bool {
|
||||
parentDir := strings.Trim(path.Dir(parentSource), "/")
|
||||
depth := 0
|
||||
if parentDir != "" && parentDir != "." {
|
||||
depth = len(strings.Split(parentDir, "/"))
|
||||
}
|
||||
for _, segment := range strings.Split(childPath, "/") {
|
||||
switch segment {
|
||||
case "", ".":
|
||||
continue
|
||||
case "..":
|
||||
if depth <= 1 {
|
||||
return true
|
||||
}
|
||||
depth--
|
||||
default:
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func knownSourceNamespace(source string) string {
|
||||
first, _, _ := strings.Cut(strings.TrimLeft(source, "/"), "/")
|
||||
switch first {
|
||||
case "sp", "pms", "laosiji", "v1", "v2", "v3":
|
||||
return first
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// SignURL signs an exact source path for the cloud transcoder. The returned
|
||||
// URL contains only the expiry and signature query parameters.
|
||||
func SignURL(rawURL, secret string, expiresAt time.Time) (string, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if len([]byte(secret)) < minSecretBytes {
|
||||
return "", ErrInvalidSecret
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" || parsed.EscapedPath() == "" {
|
||||
return "", errors.New("invalid HEVC pull URL")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("HEVC pull URL must not contain query or fragment")
|
||||
}
|
||||
expires := expiresAt.UTC().Unix()
|
||||
if expires <= 0 {
|
||||
return "", errors.New("invalid HEVC pull expiry")
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set(ExpiresParam, strconv.FormatInt(expires, 10))
|
||||
query.Set(SignatureParam, signature(parsed.EscapedPath(), expires, secret))
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// VerifyURL validates expiry, exact path binding, and the HMAC signature.
|
||||
func VerifyURL(parsed *url.URL, secret string, now time.Time) error {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if len([]byte(secret)) < minSecretBytes {
|
||||
log.Error("VerifyURL fail 1")
|
||||
return ErrInvalidSecret
|
||||
}
|
||||
if parsed == nil || parsed.EscapedPath() == "" {
|
||||
log.Error("VerifyURL fail 2", log.Any("parsed", parsed))
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
query := parsed.Query()
|
||||
if len(query) != 2 ||
|
||||
len(query[ExpiresParam]) != 1 ||
|
||||
len(query[SignatureParam]) != 1 {
|
||||
log.Error("VerifyURL fail 3", log.Any("query", query))
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
expires, err := strconv.ParseInt(query.Get(ExpiresParam), 10, 64)
|
||||
if err != nil {
|
||||
log.Error("VerifyURL fail 4", log.E(err))
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
now = now.UTC()
|
||||
expiresAt := time.Unix(expires, 0).UTC()
|
||||
if !expiresAt.After(now) {
|
||||
log.Error("VerifyURL fail 5", log.Any("expires", expires))
|
||||
return ErrExpired
|
||||
}
|
||||
if expiresAt.After(now.Add(maxSignatureTTL)) {
|
||||
log.Error("VerifyURL fail 6", log.Any("expires", expires))
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
|
||||
provided, err := hex.DecodeString(query.Get(SignatureParam))
|
||||
if err != nil {
|
||||
log.Error("VerifyURL fail 7", log.E(err))
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
expected, err := hex.DecodeString(signature(parsed.EscapedPath(), expires, secret))
|
||||
if err != nil || !hmac.Equal(provided, expected) {
|
||||
log.Error("VerifyURL fail 8", log.Any("expires", expires))
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RedactText removes signed pull bearer values from logs and diagnostics,
|
||||
// including when the nested URL has been query-escaped by another API.
|
||||
func RedactText(text string) string {
|
||||
return signatureTextPattern.ReplaceAllString(text, `${1}[REDACTED]`)
|
||||
}
|
||||
|
||||
// RedactURL returns a diagnostic form of a signed pull URL that is safe to
|
||||
// persist. It is not a usable pull URL.
|
||||
func RedactURL(rawURL string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return RedactText(rawURL)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if query.Has(SignatureParam) {
|
||||
query.Set(SignatureParam, "[REDACTED]")
|
||||
parsed.RawQuery = query.Encode()
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func signature(escapedPath string, expires int64, secret string) string {
|
||||
canonical := fmt.Sprintf("%s\n%d", escapedPath, expires)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package hevcpull
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignAndVerifyURL(t *testing.T) {
|
||||
now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
||||
signed, err := SignURL(
|
||||
"https://app.example.com/api/app/vid/transcode/m3u8/laosiji/m3m/demo.m3u8",
|
||||
"test-pull-secret-strong-32-bytes!!",
|
||||
now.Add(480*time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SignURL failed: %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(signed)
|
||||
if err != nil {
|
||||
t.Fatalf("parse signed URL: %v", err)
|
||||
}
|
||||
if err = VerifyURL(parsed, "test-pull-secret-strong-32-bytes!!", now); err != nil {
|
||||
t.Fatalf("VerifyURL failed: %v", err)
|
||||
}
|
||||
|
||||
parsed.Path += ".tampered"
|
||||
if err = VerifyURL(parsed, "test-pull-secret-strong-32-bytes!!", now); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("tampered path returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyURLRejectsExpiredOrExtraQuery(t *testing.T) {
|
||||
now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
||||
signed, err := SignURL(
|
||||
"https://app.example.com/api/app/vid/transcode/m3u8/source.m3u8",
|
||||
"test-pull-secret-strong-32-bytes!!",
|
||||
now.Add(time.Minute),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SignURL failed: %v", err)
|
||||
}
|
||||
parsed, _ := url.Parse(signed)
|
||||
if err = VerifyURL(parsed, "test-pull-secret-strong-32-bytes!!", now.Add(time.Minute)); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("expired URL returned %v", err)
|
||||
}
|
||||
|
||||
parsed, _ = url.Parse(signed)
|
||||
query := parsed.Query()
|
||||
query.Set("c", "unbound-cdn")
|
||||
parsed.RawQuery = query.Encode()
|
||||
if err = VerifyURL(parsed, "test-pull-secret-strong-32-bytes!!", now); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("extra query returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignURLRejectsWeakSecret(t *testing.T) {
|
||||
_, err := SignURL("https://app.example.com/source.m3u8", "short", time.Now().Add(time.Hour))
|
||||
if !errors.Is(err, ErrInvalidSecret) {
|
||||
t.Fatalf("weak secret returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSignedPullURL(t *testing.T) {
|
||||
const signature = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
raw := "https://app.example/source.m3u8?hevc_exp=1&hevc_sig=" + signature
|
||||
redacted := RedactURL(raw)
|
||||
if redacted == raw || RedactText(redacted) != redacted {
|
||||
t.Fatalf("URL was not redacted: %s", redacted)
|
||||
}
|
||||
if got := RedactText("file_url=" + url.QueryEscape(raw)); got == "file_url="+url.QueryEscape(raw) {
|
||||
t.Fatalf("escaped nested URL was not redacted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{source: " /laosiji/m3m/demo.m3u8 ", want: "laosiji/m3m/demo.m3u8"},
|
||||
{source: "sp/movie/index.m3u8", want: "sp/movie/index.m3u8"},
|
||||
{source: "sp/movie/../index.m3u8"},
|
||||
{source: `sp\movie\index.m3u8`},
|
||||
{source: `sp/movie%5Cindex.m3u8`},
|
||||
{source: `sp/movie%0Aindex.m3u8`},
|
||||
{source: "https://cdn.example.com/index.m3u8"},
|
||||
{source: "sp/movie/index.m3u8?token=x"},
|
||||
{source: "sp/movie/index.mp4"},
|
||||
{source: "sp/movie/index.M3U8"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := NormalizeSource(tt.source)
|
||||
if tt.want == "" {
|
||||
if err == nil {
|
||||
t.Errorf("NormalizeSource(%q) = %q, want error", tt.source, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || got != tt.want {
|
||||
t.Errorf("NormalizeSource(%q) = %q, %v; want %q", tt.source, got, err, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChildSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parent string
|
||||
child string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "relative child",
|
||||
parent: "sp/movie/master.m3u8",
|
||||
child: "720/index.m3u8",
|
||||
want: "sp/movie/720/index.m3u8",
|
||||
},
|
||||
{
|
||||
name: "parent traversal",
|
||||
parent: "sp/movie/master.m3u8",
|
||||
child: "../audio/index.m3u8",
|
||||
want: "sp/audio/index.m3u8",
|
||||
},
|
||||
{
|
||||
name: "root child remains on default SP origin",
|
||||
parent: "sp/movie/master.m3u8",
|
||||
child: "/shared/index.m3u8",
|
||||
want: "shared/index.m3u8",
|
||||
},
|
||||
{
|
||||
name: "explicit PMS root namespace is preserved",
|
||||
parent: "pms/movie/master.m3u8",
|
||||
child: "/pms/shared/index.m3u8",
|
||||
want: "pms/shared/index.m3u8",
|
||||
},
|
||||
{
|
||||
name: "explicit laosiji root namespace is preserved",
|
||||
parent: "laosiji/m3m/movie/master.m3u8",
|
||||
child: "/laosiji/shared/index.m3u8",
|
||||
want: "laosiji/shared/index.m3u8",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResolveChildSource(tt.parent, tt.child)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveChildSource error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ResolveChildSource = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
parent string
|
||||
child string
|
||||
}{
|
||||
{parent: "sp/movie/master.m3u8", child: "https://cdn.example.com/index.m3u8"},
|
||||
{parent: "sp/movie/master.m3u8", child: "index.m3u8?token=secret"},
|
||||
{parent: "sp/movie/master.m3u8", child: "index.ts"},
|
||||
{parent: "sp/movie/master.m3u8", child: `..\index.m3u8`},
|
||||
{parent: "sp/movie/master.m3u8", child: "../../outside/index.m3u8"},
|
||||
{parent: "sp/movie/master.m3u8", child: "../../../sp/outside/index.m3u8"},
|
||||
{parent: "sp/movie/master.m3u8", child: "/pms/outside/index.m3u8"},
|
||||
{parent: "pms/movie/master.m3u8", child: "/shared/index.m3u8"},
|
||||
{parent: "pms/movie/master.m3u8", child: "/sp/outside/index.m3u8"},
|
||||
{parent: "laosiji/movie/master.m3u8", child: "/shared/index.m3u8"},
|
||||
} {
|
||||
if _, err := ResolveChildSource(tt.parent, tt.child); err == nil {
|
||||
t.Fatalf("unsafe child accepted: parent=%q child=%q", tt.parent, tt.child)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user