@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user