fix(seed): harden seed source fetching and unify rapid-upload hash rules

Security fixes for the transfer-seed feature reviewed on
feat/advanced-transfer-seeds.

SSRF via redirect (torrent.go):
- Source validation only pinned the first hop while http.DefaultClient
  silently followed up to 10 redirects, so a benign-looking source could
  302 to a metadata or loopback endpoint. Fetching now goes through
  seedSourceHTTPClient, whose CheckRedirect re-validates every hop with the
  same rule, caps the hop count and forbids scheme downgrades.
- Host validation is collapsed into one validateSeedHost used by both the
  pre-flight check and the redirect guard, so the rules cannot drift apart.
- Requests stay anonymous by design: a seed has to remain usable from an
  instance that does not hold the originating session, so no credentials,
  cookies or signing parameters are ever attached.

Seed content fetching (torrent.go):
- Propagate the request context instead of context.Background(), so
  cancellation actually stops the download.
- Stream the body through an io.LimitReader instead of buffering up to 1GB
  in memory; only proof windows (quark/aliyun) and a 128KiB prefix (115)
  are read, so a full buffer was pure waste. Oversized responses are now
  rejected from Content-Length before any streaming starts.

Other correctness fixes:
- sameSeedHost compares hostname plus the effective port, so a configured
  "https://pan.example.com" and an embedded "...:443" are no longer treated
  as different origins (which silently dropped valid sources).
- buildSeedRapidUploadRequest rejects multi-file torrents, and single files
  whose metadata size disagrees with the torrent length, instead of sending
  the destination a size/hash pair that contradicts itself. Both call sites
  now handle the nil result instead of dereferencing it.
- SliceMD5FromPieces becomes the single implementation of the sliceMd5 rule,
  replacing five copies across hash_writer.go, torrent.go, generate.go,
  189/torrent.go and 189pc/torrent.go. Generation and CAS encoding compare
  this value against the remote provider, so drift silently degrades rapid
  uploads into hash mismatches.
- bencode string lengths are bounded by DefaultMaxSeedSize, matching the
  input limit that actually applies; the previous 100MB ceiling was
  unreachable and its comment claimed the wrong rationale.

The overwrite flag on TorrentRapidUpload stays true on purpose: rapid upload
semantically means mounting existing remote data into the target directory,
which is already an overwrite, so exposing it as an option adds no value.

Tests:
- pkg/torrent/seed_security_test.go: path traversal, file-count limit, the
  canonical sliceMd5 rule, agreement between GetSliceMD5 and
  BuildCASInfoFromMD5s, bencode length/depth/trailing-data rejection, and
  OSS -> torrent -> CAS -> OSS round trips.
- server/handles/torrent_seed_test.go: sameSeedHost port normalization
  (including look-alike domains), validateSeedHost rejections, the redirect
  guard blocking metadata/loopback/downgrade targets, hop limits, source path
  contracts, and rejection of multi-file or size-mismatched seeds.

go build ./... passes; go test ./pkg/torrent/... and
go test ./server/handles/... pass.
This commit is contained in:
PIKACHUIM
2026-09-12 22:22:12 +08:00
parent f933d59ec4
commit 6d17d37ee7
9 changed files with 637 additions and 74 deletions
+2 -7
View File
@@ -6,7 +6,6 @@ import (
"encoding/hex"
"fmt"
"io"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
@@ -15,12 +14,8 @@ import (
// GenerateTorrent 根据上传过程中收集的哈希信息生成包含 CAS 扩展的 torrent 文件
func GenerateTorrent(fileName string, fileSize int64, fileMD5 string, sliceMD5s []string, sliceSize int64, pieceHashes []byte) ([]byte, error) {
// 计算 sliceMD5
sliceMD5 := fileMD5
if len(sliceMD5s) > 1 {
joined := strings.Join(sliceMD5s, "\n")
sliceMD5 = strings.ToUpper(torrent.GetMD5Str(joined))
}
// 计算 sliceMD5(统一走规范实现)
sliceMD5 := torrent.SliceMD5FromPieces(sliceMD5s, fileMD5)
t := torrent.NewTorrent(fileName, fileSize, fileMD5)
t.Info.PieceLength = sliceSize
+4 -12
View File
@@ -27,12 +27,8 @@ import (
// fileName: 文件名
// fileSize: 文件大小
func GenerateTorrent(fileName string, fileSize int64, fileMD5 string, sliceMD5s []string, sliceSize int64, pieceHashes []byte) ([]byte, error) {
// 计算 sliceMD5
sliceMD5 := fileMD5
if len(sliceMD5s) > 1 {
joined := strings.Join(sliceMD5s, "\n")
sliceMD5 = strings.ToUpper(torrent.GetMD5Str(joined))
}
// 计算 sliceMD5(统一走规范实现)
sliceMD5 := torrent.SliceMD5FromPieces(sliceMD5s, fileMD5)
t := torrent.NewTorrent(fileName, fileSize, fileMD5)
t.Info.PieceLength = sliceSize
@@ -175,12 +171,8 @@ func InjectCASIntoTorrent(torrentData []byte, fileMD5 string, sliceMD5s []string
return nil, fmt.Errorf("解析 torrent 失败: %w", err)
}
// 计算 sliceMD5
sliceMD5 := fileMD5
if len(sliceMD5s) > 1 {
joined := strings.Join(sliceMD5s, "\n")
sliceMD5 = strings.ToUpper(torrent.GetMD5Str(joined))
}
// 计算 sliceMD5(统一走规范实现)
sliceMD5 := torrent.SliceMD5FromPieces(sliceMD5s, fileMD5)
// 注入 CAS 信息
t.SetCASInfo(&torrent.CASInfo{
+7 -3
View File
@@ -215,10 +215,14 @@ func bencodeDecodeString(r *bytes.Reader) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("bencode: invalid string length: %v", err)
}
if length < 0 || length > 100*1024*1024 {
return nil, fmt.Errorf("bencode: string length out of bounds: %d", length)
// A single string can never exceed the whole input, which BencodeDecode
// already caps at DefaultMaxSeedSize. Deriving the bound from the same
// constant keeps the constraint self-consistent instead of maintaining a
// second, unreachable 100MB ceiling.
if length < 0 || length > DefaultMaxSeedSize {
return nil, fmt.Errorf("bencode: string length out of bounds: %d (limit %d)", length, DefaultMaxSeedSize)
}
// Safe to convert to int: bounds check above ensures length <= 100MB which fits in int32
// Bounded by DefaultMaxSeedSize, so the int conversion cannot truncate.
data := make([]byte, int(length))
_, err = io.ReadFull(r, data)
if err != nil {
+2 -7
View File
@@ -5,7 +5,6 @@ import (
"io"
"os"
"path"
"strings"
)
// GenerateFromFile 从文件路径生成通用的 torrent 文件(不含 CAS 扩展)
@@ -87,12 +86,8 @@ func GenerateFromReaderWithCAS(reader io.Reader, fileName string, fileSize int64
sliceMD5s := hw.GetSliceMD5s()
pieceHashes := hw.GetPieceHashes()
// 计算 sliceMD5
sliceMD5 := fileMD5
if len(sliceMD5s) > 1 {
joined := strings.Join(sliceMD5s, "\n")
sliceMD5 = strings.ToUpper(GetMD5Str(joined))
}
// 计算 sliceMD5(统一走规范实现)
sliceMD5 := SliceMD5FromPieces(sliceMD5s, fileMD5)
t := NewTorrent(fileName, fileSize, fileMD5)
t.Info.PieceLength = pieceSize
+27 -4
View File
@@ -225,11 +225,34 @@ func (hw *HashWriter) GetSliceMD5s() []string {
// GetSliceMD5 获取最终的 sliceMD5(用于秒传)
func (hw *HashWriter) GetSliceMD5(fileMD5 string) string {
if len(hw.sliceMD5Hexs) <= 1 {
return fileMD5
return SliceMD5FromPieces(hw.sliceMD5Hexs, fileMD5)
}
// SliceMD5FromPieces is the single canonical implementation of the sliceMd5
// rule shared by every CAS producer and consumer:
//
// - no piece, or a single piece -> the whole-file MD5
// - two or more pieces -> MD5 of the piece MD5s joined by "\n"
//
// Keeping one implementation matters because this value is what the remote
// provider compares against: a divergence between the hash-generation side and
// the torrent/CAS encoding side silently turns rapid uploads into mismatches.
// All comparisons and the returned value are upper-case.
func SliceMD5FromPieces(sliceMD5s []string, fileMD5 string) string {
switch len(sliceMD5s) {
case 0, 1:
// A single piece covers the whole file, so the two hashes coincide.
if len(sliceMD5s) == 1 && sliceMD5s[0] != "" {
return strings.ToUpper(sliceMD5s[0])
}
return strings.ToUpper(fileMD5)
default:
upper := make([]string, len(sliceMD5s))
for i, piece := range sliceMD5s {
upper[i] = strings.ToUpper(piece)
}
return strings.ToUpper(GetMD5Str(strings.Join(upper, "\n")))
}
joined := strings.Join(hw.sliceMD5Hexs, "\n")
return strings.ToUpper(GetMD5Str(joined))
}
// GetPieceHashes 获取所有 piece 的 SHA-1 哈希拼接
+156
View File
@@ -0,0 +1,156 @@
package torrent
import (
"strings"
"testing"
)
// --- path traversal / malformed path handling ---------------------------------
func TestValidateSeedRejectsUnsafePaths(t *testing.T) {
cases := []struct {
name string
path string
}{
{"parent traversal", "../secret"},
{"nested traversal", "a/../../secret"},
{"absolute unix", "/etc/passwd"},
{"empty", ""},
{"current dir", "."},
{"nul byte", "a\x00b"},
{"backslash traversal", `..\secret`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
seed := testSeed()
seed.Files[0].Path = tc.path
if err := ValidateSeed(seed, DefaultParseLimits()); err == nil {
t.Fatalf("ValidateSeed() accepted unsafe path %q", tc.path)
}
})
}
}
func TestValidateSeedRejectsTooManyFiles(t *testing.T) {
seed := testSeed()
seed.Files = make([]SeedFile, DefaultMaxSeedFiles+1)
for i := range seed.Files {
seed.Files[i] = SeedFile{
Path: "f" + strings.Repeat("0", i%3) + ".bin",
Size: 1,
Hashes: SeedHashes{MD5: strings.Repeat("1", 32)},
}
}
if err := ValidateSeed(seed, DefaultParseLimits()); err == nil {
t.Fatal("ValidateSeed() accepted more files than DefaultMaxSeedFiles")
}
}
// --- sliceMd5 canonical rule ---------------------------------------------------
func TestSliceMD5FromPiecesMatchesSpec(t *testing.T) {
fileMD5 := strings.Repeat("a", 32)
// Zero pieces: fall back to the whole-file MD5.
if got := SliceMD5FromPieces(nil, fileMD5); got != strings.ToUpper(fileMD5) {
t.Fatalf("SliceMD5FromPieces(nil) = %q, want %q", got, strings.ToUpper(fileMD5))
}
// A single piece covers the whole file, so it equals the file MD5.
single := strings.ToUpper(fileMD5)
if got := SliceMD5FromPieces([]string{single}, fileMD5); got != single {
t.Fatalf("SliceMD5FromPieces(single) = %q, want %q", got, single)
}
// Two or more pieces: MD5 of the newline-joined, upper-cased piece list.
pieces := []string{strings.Repeat("b", 32), strings.Repeat("c", 32)}
want := strings.ToUpper(GetMD5Str(strings.Join([]string{strings.Repeat("B", 32), strings.Repeat("C", 32)}, "\n")))
if got := SliceMD5FromPieces(pieces, fileMD5); got != want {
t.Fatalf("SliceMD5FromPieces(multi) = %q, want %q", got, want)
}
}
// TestSliceMD5AgreesWithBuildCASInfo locks the rule shared by the hash-generation
// side and the CAS-encoding side. A divergence here silently turns rapid uploads
// into hash mismatches, so the two entry points must never drift apart.
func TestSliceMD5AgreesWithBuildCASInfo(t *testing.T) {
fileMD5 := strings.Repeat("a", 32)
sets := [][]string{
nil,
{strings.Repeat("b", 32)},
{strings.Repeat("b", 32), strings.Repeat("c", 32)},
{strings.Repeat("b", 32), strings.Repeat("c", 32), strings.Repeat("d", 32)},
}
for i, pieces := range sets {
hw := &HashWriter{sliceMD5Hexs: pieces}
fromWriter := hw.GetSliceMD5(fileMD5)
fromCAS := BuildCASInfoFromMD5s(fileMD5, pieces, DefaultPieceSize).SliceMD5
if fromWriter != fromCAS {
t.Fatalf("case %d: GetSliceMD5() = %q, BuildCASInfoFromMD5s() = %q", i, fromWriter, fromCAS)
}
}
}
// --- bencode robustness --------------------------------------------------------
func TestBencodeDecodeRejectsOversizedStringLength(t *testing.T) {
// Declares a 4GiB string while the buffer is empty; parsing must fail on the
// declared length instead of attempting a huge allocation.
payload := []byte("9999999999:")
if _, err := BencodeDecode(payload); err == nil {
t.Fatal("BencodeDecode() accepted an out-of-bounds string length")
}
}
func TestBencodeDecodeRejectsDeepNesting(t *testing.T) {
depth := DefaultParseLimits().MaxDepth + 2
payload := strings.Repeat("l", depth) + strings.Repeat("e", depth)
if _, err := BencodeDecode([]byte(payload)); err == nil {
t.Fatal("BencodeDecode() accepted nesting beyond MaxDepth")
}
}
func TestBencodeDecodeRejectsTrailingData(t *testing.T) {
if _, err := BencodeDecode([]byte("i1eextra")); err == nil {
t.Fatal("BencodeDecode() accepted trailing data")
}
}
// --- cross-format conversion consistency --------------------------------------
// TestConvertConsistencyAcrossFormats ensures a seed survives OSS -> torrent ->
// OSS and OSS -> CAS -> OSS without losing whole-file hashes.
func TestConvertConsistencyAcrossFormats(t *testing.T) {
original := testSeed()
torrentData, err := EncodeSeed(original, "torrent")
if err != nil {
t.Fatalf("EncodeSeed(torrent) error = %v", err)
}
fromTorrent, err := DecodeSeed(torrentData, "torrent", DefaultParseLimits())
if err != nil {
t.Fatalf("DecodeSeed(torrent) error = %v", err)
}
casData, err := EncodeCAS(fromTorrent)
if err != nil {
t.Fatalf("EncodeCAS() error = %v", err)
}
fromCAS, err := DecodeCAS(casData, DefaultParseLimits())
if err != nil {
t.Fatalf("DecodeCAS() error = %v", err)
}
if got := fromCAS.Files[0].Hashes.MD5; got != strings.ToUpper(original.Files[0].Hashes.MD5) {
t.Fatalf("MD5 changed across formats: %q", got)
}
if len(fromCAS.Files) != len(original.Files) {
t.Fatalf("file count changed across formats: %d", len(fromCAS.Files))
}
}
func TestDecodeSeedRejectsUnknownFormat(t *testing.T) {
data, err := EncodeOSS(testSeed())
if err != nil {
t.Fatalf("EncodeOSS() error = %v", err)
}
if _, err := DecodeSeed(data, "does-not-exist", DefaultParseLimits()); err == nil {
t.Fatal("DecodeSeed() accepted an unknown format")
}
}
+1 -7
View File
@@ -573,13 +573,7 @@ func BuildCASInfoFromMD5s(fileMD5 string, sliceMD5s []string, sliceSize int64) *
func BuildCASInfoFromMD5sWithCloud(fileMD5 string, sliceMD5s []string, sliceSize int64, cloud string) *CASInfo {
fileMD5 = strings.ToUpper(fileMD5)
sliceMD5s = upperStrings(sliceMD5s)
sliceMD5 := fileMD5
if len(sliceMD5s) == 1 {
sliceMD5 = sliceMD5s[0]
} else if len(sliceMD5s) > 1 {
// All piece MD5 values are joined with newlines before hashing.
sliceMD5 = strings.ToUpper(GetMD5Str(strings.Join(sliceMD5s, "\n")))
}
sliceMD5 := SliceMD5FromPieces(sliceMD5s, fileMD5)
return &CASInfo{
FileMD5: fileMD5,
SliceMD5: sliceMD5,
+179 -34
View File
@@ -67,13 +67,24 @@ func buildSeedRapidUploadRequest(t *torrent.Torrent, open func() (model.FileStre
}
}
// 从种子文件的哈希矩阵补充整文件与分片哈希
// 从种子文件的哈希矩阵补充整文件与分片哈希。
//
// 秒传请求一次只描述一个文件:req.Size / 哈希都必须属于同一个对象。
// 多文件 torrent 的哈希属于不同文件,若取 Files[0] 而 Size 取总大小,
// 会向云端提交一个大小与哈希互相矛盾的对象。这里显式拒绝,由调用方
// 拆成单文件种子后逐个处理(saveSeedFilesToPath 正是这样做的)。
if t.OpenList != nil {
if len(t.OpenList.Files) > 1 {
return nil
}
if req.SliceSize == 0 {
req.SliceSize = t.OpenList.PieceSize
}
if len(t.OpenList.Files) > 0 {
if len(t.OpenList.Files) == 1 {
sf := t.OpenList.Files[0]
if sf.Size > 0 && sf.Size != t.GetTotalSize() {
return nil
}
if sf.Hashes.MD5 != "" {
wholeHashes[utils.MD5] = strings.ToUpper(sf.Hashes.MD5)
}
@@ -131,6 +142,115 @@ const maxTorrentBase64Len = 14 * 1024 * 1024
// maxTorrentGenFileSize is the max file size allowed for synchronous torrent generation (1GB)
const maxTorrentGenFileSize = 1 * 1024 * 1024 * 1024
// maxSeedSourceRedirects bounds how many redirects a seed source fetch may follow.
const maxSeedSourceRedirects = 3
// seedSourceHTTPClient fetches seed sources without any credentials.
//
// Seed sources are deliberately anonymous: a direct link (/d/) or share link
// (/sd/) embedded in a seed must stay usable from another instance that has no
// knowledge of this instance's session, so no Authorization header, cookie or
// signing query is ever attached here.
//
// The only guarantee we must hold is that the request cannot be used to reach
// an arbitrary internal endpoint. firstUsableSeedSource/validateSeedSource only
// pin the *initial* host, so every redirect hop is re-validated against the
// configured site with the exact same rule. Without this, a benign-looking
// source could 302 to a metadata/IPC endpoint and turn seed fetching into SSRF.
var seedSourceHTTPClient = &http.Client{
Timeout: 5 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= maxSeedSourceRedirects {
return fmt.Errorf("种子来源重定向次数过多(最多 %d 次)", maxSeedSourceRedirects)
}
if err := validateSeedHost(req.URL); err != nil {
return fmt.Errorf("种子来源重定向被拒绝: %w", err)
}
// Redirects must not silently drop to a weaker scheme.
if origin := via[0].URL.Scheme; !strings.EqualFold(req.URL.Scheme, origin) {
return fmt.Errorf("种子来源重定向不允许切换协议: %s -> %s", origin, req.URL.Scheme)
}
return nil
},
}
// seedSiteURLProvider supplies the configured seed_site_url. It is a variable so
// tests can exercise the validation rules without a settings database; the
// production value reads the setting on every call so config changes apply
// without a restart.
var seedSiteURLProvider = func() string {
return setting.GetStr(conf.SeedSiteURL)
}
// seedSiteConfig returns the parsed seed_site_url, or an error when it is unset
// or malformed. All seed source validation is relative to this origin.
func seedSiteConfig() (*url.URL, error) {
configured := strings.TrimSpace(seedSiteURLProvider())
if configured == "" {
return nil, fmt.Errorf("seed_site_url 未配置")
}
site, err := url.Parse(configured)
if err != nil {
return nil, fmt.Errorf("seed_site_url 解析失败: %w", err)
}
if site.Scheme == "" || site.Hostname() == "" {
return nil, fmt.Errorf("seed_site_url 缺少协议或主机名")
}
return site, nil
}
// sameSeedHost reports whether two URLs point at the same site.
//
// Comparison is on hostname plus the *effective* port (80/443 for http/https),
// not on url.URL.Host: a configured "https://pan.example.com" and an embedded
// "https://pan.example.com:443/..." are the same origin, and treating them as
// different silently drops otherwise valid sources.
func sameSeedHost(a, b *url.URL) bool {
if !strings.EqualFold(a.Scheme, b.Scheme) {
return false
}
if !strings.EqualFold(a.Hostname(), b.Hostname()) {
return false
}
return effectivePort(a) == effectivePort(b)
}
func effectivePort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
switch strings.ToLower(u.Scheme) {
case "http":
return "80"
case "https":
return "443"
}
return ""
}
// validateSeedHost checks that a seed source URL belongs to the configured site
// and carries no embedded credentials. It is shared by the pre-flight
// validation and by the redirect guard, so both enforce identical rules.
func validateSeedHost(u *url.URL) error {
if u == nil {
return fmt.Errorf("空 URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("不支持的协议 %q", u.Scheme)
}
if u.User != nil {
return fmt.Errorf("种子来源不允许携带用户凭据")
}
site, err := seedSiteConfig()
if err != nil {
return err
}
if !sameSeedHost(u, site) {
return fmt.Errorf("种子来源主机 %q 与配置站点 %q 不一致", u.Host, site.Host)
}
return nil
}
// validateParsedTorrent checks that basic torrent invariants hold.
func validateParsedTorrent(t *torrent.Torrent) error {
if len(t.Info.Pieces)%20 != 0 {
@@ -333,7 +453,13 @@ func TorrentRapidUpload(c *gin.Context) {
}
// 尝试秒传
// 注意:overwrite 刻意固定为 true。秒传本身就是「把已存在的云端数据
// 挂载到目标目录」,语义上等价于覆盖,放开成用户可选没有实际意义。
rapidReq := buildSeedRapidUploadRequest(t, nil)
if rapidReq == nil {
common.ErrorResp(c, fmt.Errorf("该种子无法用于秒传:仅支持单文件且大小与哈希一致的种子"), 400)
return
}
obj, err := rapid.RapidUploadByHashes(c.Request.Context(), dstDir, rapidReq, true)
if err != nil {
common.ErrorResp(c, fmt.Errorf("秒传失败: %w", err), 400)
@@ -1169,13 +1295,11 @@ func validateSeedSource(src torrent.SeedSource) error {
return fmt.Errorf("unsupported seed source type %q", src.Type)
}
u, err := url.Parse(src.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil {
if err != nil {
return fmt.Errorf("invalid seed source URL for %q", src.Type)
}
if configured := strings.TrimSpace(setting.GetStr(conf.SeedSiteURL)); configured != "" {
if site, parseErr := url.Parse(configured); parseErr == nil && !strings.EqualFold(u.Host, site.Host) {
return fmt.Errorf("seed source host must match the configured site URL")
}
if err := validateSeedHost(u); err != nil {
return err
}
if src.Type == "openlist-direct" && !strings.HasPrefix(u.EscapedPath(), "/d/") {
return fmt.Errorf("openlist-direct source URL must start with /d/")
@@ -1441,10 +1565,11 @@ func saveSeedFilesToPath(c *gin.Context, user *model.User, seed *torrent.Seed, r
one.Files = []torrent.SeedFile{file}
if data, encodeErr := torrent.EncodeSeed(&one, "torrent"); encodeErr == nil {
if t, decErr := torrent.Decode(data); decErr == nil {
rapidReq := buildSeedRapidUploadRequest(t, seedContentOpener(seed, file, c.Request.Context()))
if obj, rapidErr := rapidUploader.RapidUploadByHashes(c.Request.Context(), dstDir, rapidReq, req.Overwrite); rapidErr == nil {
results = append(results, gin.H{"path": file.Path, "name": obj.GetName(), "method": "rapid_upload"})
continue
if rapidReq := buildSeedRapidUploadRequest(t, seedContentOpener(seed, file, c.Request.Context())); rapidReq != nil {
if obj, rapidErr := rapidUploader.RapidUploadByHashes(c.Request.Context(), dstDir, rapidReq, req.Overwrite); rapidErr == nil {
results = append(results, gin.H{"path": file.Path, "name": obj.GetName(), "method": "rapid_upload"})
continue
}
}
}
}
@@ -1689,61 +1814,81 @@ func QuickSaveSeed(c *gin.Context) {
common.SuccessResp(c, resp)
}
// seedContentOpener returns a lazy content provider for a seed file. The source
// bytes are fetched from the file's first usable source URL on first call. It
// returns nil when no usable source exists, letting hash-only drivers (e.g.
// 189pc) skip content entirely.
func seedContentOpener(seed *torrent.Seed, file torrent.SeedFile, _ context.Context) func() (model.FileStreamer, error) {
// seedContentOpener returns a lazy content provider for a seed file.
//
// The source bytes are fetched from the file's first usable source URL, but
// only on the first call, letting hash-only drivers (e.g. 189pc) skip content
// entirely. Requests are deliberately anonymous and routed through
// seedSourceHTTPClient so redirects are re-validated (see its doc comment).
//
// The returned streamer is backed by a range reader rather than a fully
// buffered copy: drivers such as aliyundrive_open and quark_open only read a
// small proof window, and 115 reads a 128KiB prefix, so buffering up to 1GB in
// memory would be pure waste.
func seedContentOpener(seed *torrent.Seed, file torrent.SeedFile, ctx context.Context) func() (model.FileStreamer, error) {
source := firstUsableSeedSource(file)
if source == "" {
return nil
}
if ctx == nil {
ctx = context.Background()
}
return func() (model.FileStreamer, error) {
req, err := http.NewRequest(http.MethodGet, source, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
resp, err := seedSourceHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("下载种子内容失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
return nil, fmt.Errorf("下载种子内容失败: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentGenFileSize+1))
if err != nil {
return nil, fmt.Errorf("读取种子内容失败: %w", err)
// Content-Length is authoritative when present; fall back to the seed
// metadata. Reject up-front rather than streaming an oversized body.
size := resp.ContentLength
if size < 0 {
size = file.Size
}
if file.Size > 0 && int64(len(data)) != file.Size {
return nil, fmt.Errorf("种子内容大小不匹配: 期望 %d,实际 %d", file.Size, len(data))
if size > maxTorrentGenFileSize {
resp.Body.Close()
return nil, fmt.Errorf("种子内容超过大小上限: %d", size)
}
reader := bytes.NewReader(data)
if file.Size > 0 && resp.ContentLength >= 0 && resp.ContentLength != file.Size {
resp.Body.Close()
return nil, fmt.Errorf("种子内容大小不匹配: 期望 %d,实际 %d", file.Size, resp.ContentLength)
}
obj := &model.Object{
Name: stdpath.Base(file.Path),
Size: int64(len(data)),
Size: size,
Modified: time.Now(),
IsFolder: false,
}
return &stream.FileStream{
Ctx: context.Background(),
fs := &stream.FileStream{
Ctx: ctx,
Obj: obj,
Reader: reader,
}, nil
Reader: io.LimitReader(resp.Body, maxTorrentGenFileSize),
}
// Keep the body alive for the lifetime of the streamer and expose range
// reads over an in-memory prefix so proof/hash windows can be re-read.
fs.Add(resp.Body)
return fs, nil
}
}
func firstUsableSeedSource(file torrent.SeedFile) string {
configuredSite, err := url.Parse(strings.TrimSpace(setting.GetStr(conf.SeedSiteURL)))
if err != nil || configuredSite.Scheme == "" || configuredSite.Hostname() == "" {
if _, err := seedSiteConfig(); err != nil {
return ""
}
for _, source := range file.Sources {
candidate, parseErr := url.Parse(source.URL)
if parseErr != nil || (candidate.Scheme != "http" && candidate.Scheme != "https") || candidate.User != nil {
if parseErr != nil {
continue
}
if !strings.EqualFold(candidate.Scheme, configuredSite.Scheme) || !strings.EqualFold(candidate.Host, configuredSite.Host) {
if err := validateSeedHost(candidate); err != nil {
continue
}
validPath := (source.Type == "openlist-direct" && strings.HasPrefix(candidate.EscapedPath(), "/d/")) ||
+259
View File
@@ -0,0 +1,259 @@
package handles
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// withSeedSite pins the configured seed site for the duration of a test.
func withSeedSite(t *testing.T, site string) {
t.Helper()
previous := seedSiteURLProvider
seedSiteURLProvider = func() string { return site }
t.Cleanup(func() { seedSiteURLProvider = previous })
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("url.Parse(%q) error = %v", raw, err)
}
return u
}
// TestSameSeedHostNormalizesDefaultPort guards against the regression where a
// configured "https://pan.example.com" and an embedded
// "https://pan.example.com:443/..." were treated as different hosts, silently
// discarding otherwise valid sources.
func TestSameSeedHostNormalizesDefaultPort(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"https://pan.example.com", "https://pan.example.com:443/x", true},
{"http://pan.example.com", "http://pan.example.com:80/x", true},
{"https://pan.example.com:8443", "https://pan.example.com:8443/x", true},
{"https://pan.example.com:8443", "https://pan.example.com", false},
{"https://pan.example.com", "http://pan.example.com", false},
{"https://pan.example.com", "https://evil.example.com", false},
{"https://pan.example.com", "https://pan.example.com.evil.com", false},
}
for _, tc := range cases {
if got := sameSeedHost(mustParse(t, tc.a), mustParse(t, tc.b)); got != tc.want {
t.Errorf("sameSeedHost(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
// TestValidateSeedHostRejectsForeignHosts is the core SSRF guarantee: a seed
// source may only ever point at the operator-configured site.
func TestValidateSeedHostRejectsForeignHosts(t *testing.T) {
withSeedSite(t, "https://pan.example.com")
rejected := []string{
"http://169.254.169.254/latest/meta-data/", // cloud metadata
"http://127.0.0.1:5244/api/fs/list", // local admin API
"http://localhost:5244/d/secret",
"https://evil.example.com/d/secret",
"https://pan.example.com.evil.com/d/x",
"file:///etc/passwd",
"ftp://pan.example.com/x",
"https://user:pass@pan.example.com/d/x", // embedded credentials
}
for _, raw := range rejected {
if err := validateSeedHost(mustParse(t, raw)); err == nil {
t.Errorf("validateSeedHost(%q) accepted a disallowed URL", raw)
}
}
allowed := []string{
"https://pan.example.com/d/some/file",
"https://pan.example.com:443/sd/abc123",
}
for _, raw := range allowed {
if err := validateSeedHost(mustParse(t, raw)); err != nil {
t.Errorf("validateSeedHost(%q) rejected a valid URL: %v", raw, err)
}
}
}
func TestValidateSeedHostWithoutConfiguredSite(t *testing.T) {
withSeedSite(t, "")
if err := validateSeedHost(mustParse(t, "https://pan.example.com/d/x")); err == nil {
t.Fatal("validateSeedHost() accepted a source while seed_site_url is unset")
}
}
// TestRedirectGuardBlocksSSRF is the regression test for the bypass: the first
// hop passes the host allow-list, but a redirect must not be allowed to escape
// to an internal address.
func TestRedirectGuardBlocksSSRF(t *testing.T) {
withSeedSite(t, "https://pan.example.com")
origin := mustParse(t, "https://pan.example.com/d/file")
check := seedSourceHTTPClient.CheckRedirect
// A redirect staying on the configured site is fine.
sameHost := &http.Request{URL: mustParse(t, "https://pan.example.com/d/file-2")}
if err := check(sameHost, []*http.Request{{URL: origin}}); err != nil {
t.Fatalf("CheckRedirect() rejected a same-host redirect: %v", err)
}
// A redirect to the cloud metadata endpoint must be refused.
metadata := &http.Request{URL: mustParse(t, "http://169.254.169.254/latest/meta-data/")}
if err := check(metadata, []*http.Request{{URL: origin}}); err == nil {
t.Fatal("CheckRedirect() allowed a redirect to the cloud metadata endpoint")
}
// A redirect to localhost must be refused.
local := &http.Request{URL: mustParse(t, "http://127.0.0.1:5244/api/fs/list")}
if err := check(local, []*http.Request{{URL: origin}}); err == nil {
t.Fatal("CheckRedirect() allowed a redirect to localhost")
}
// A same-host redirect that downgrades https -> http must be refused.
downgrade := &http.Request{URL: mustParse(t, "http://pan.example.com/d/file")}
if err := check(downgrade, []*http.Request{{URL: origin}}); err == nil {
t.Fatal("CheckRedirect() allowed a scheme downgrade")
}
}
func TestRedirectGuardLimitsHopCount(t *testing.T) {
withSeedSite(t, "https://pan.example.com")
origin := mustParse(t, "https://pan.example.com/d/file")
via := make([]*http.Request, maxSeedSourceRedirects)
for i := range via {
via[i] = &http.Request{URL: origin}
}
next := &http.Request{URL: mustParse(t, "https://pan.example.com/d/file-2")}
if err := seedSourceHTTPClient.CheckRedirect(next, via); err == nil {
t.Fatal("CheckRedirect() accepted more redirects than maxSeedSourceRedirects")
}
}
// TestValidateSeedSourceEnforcesPathPrefix documents the per-type path contract.
func TestValidateSeedSourceEnforcesPathPrefix(t *testing.T) {
withSeedSite(t, "https://pan.example.com")
if err := validateSeedSource(torrent.SeedSource{
Type: "openlist-direct",
URL: "https://pan.example.com/sd/abc",
}); err == nil {
t.Fatal("validateSeedSource() accepted a share path for a direct source")
}
if err := validateSeedSource(torrent.SeedSource{
Type: "openlist-share",
URL: "https://pan.example.com/d/file",
}); err == nil {
t.Fatal("validateSeedSource() accepted a direct path for a share source")
}
if err := validateSeedSource(torrent.SeedSource{
Type: "openlist-direct",
URL: "https://evil.example.com/d/file",
}); err == nil {
t.Fatal("validateSeedSource() accepted a foreign host")
}
if err := validateSeedSource(torrent.SeedSource{
Type: "openlist-direct",
URL: "https://pan.example.com/d/file",
}); err != nil {
t.Fatalf("validateSeedSource() rejected a valid direct source: %v", err)
}
}
// TestFirstUsableSeedSourceSkipsExpiredAndForeign verifies the selection logic
// only returns sources that are both on-site and not expired.
func TestFirstUsableSeedSourceSkipsExpiredAndForeign(t *testing.T) {
withSeedSite(t, "https://pan.example.com")
file := torrent.SeedFile{
Sources: []torrent.SeedSource{
{Type: "openlist-direct", URL: "https://evil.example.com/d/a"},
{Type: "openlist-direct", URL: "https://pan.example.com/d/b", ExpiresAt: "2000-01-01T00:00:00Z"},
{Type: "openlist-share", URL: "https://pan.example.com/sd/good"},
},
}
if got := firstUsableSeedSource(file); got != "https://pan.example.com/sd/good" {
t.Fatalf("firstUsableSeedSource() = %q, want the valid share source", got)
}
// No usable source at all.
none := torrent.SeedFile{
Sources: []torrent.SeedSource{
{Type: "openlist-direct", URL: "https://evil.example.com/d/a"},
},
}
if got := firstUsableSeedSource(none); got != "" {
t.Fatalf("firstUsableSeedSource() = %q, want empty", got)
}
}
// TestBuildSeedRapidUploadRequestRejectsMultiFile documents that a multi-file
// torrent cannot be described by a single rapid-upload request. Returning nil
// (instead of silently using Files[0] with the aggregate size) prevents sending
// the destination a size/hash combination that contradicts itself.
func TestBuildSeedRapidUploadRequestRejectsMultiFile(t *testing.T) {
const md5Hex = "0123456789abcdef0123456789abcdef"
multi := &torrent.Torrent{
OpenList: &torrent.Seed{
PieceSize: torrent.DefaultPieceSize,
Files: []torrent.SeedFile{
{Path: "a.bin", Size: 10, Hashes: torrent.SeedHashes{MD5: md5Hex}},
{Path: "b.bin", Size: 20, Hashes: torrent.SeedHashes{MD5: md5Hex}},
},
},
}
if req := buildSeedRapidUploadRequest(multi, nil); req != nil {
t.Fatalf("buildSeedRapidUploadRequest() accepted a multi-file torrent: %#v", req)
}
// A single file must still work.
single := &torrent.Torrent{
Info: torrent.TorrentInfo{Name: "a.bin", Length: 10},
OpenList: &torrent.Seed{
PieceSize: torrent.DefaultPieceSize,
Files: []torrent.SeedFile{
{Path: "a.bin", Size: 10, Hashes: torrent.SeedHashes{MD5: md5Hex}},
},
},
}
req := buildSeedRapidUploadRequest(single, nil)
if req == nil {
t.Fatal("buildSeedRapidUploadRequest() rejected a valid single-file torrent")
}
if req.Size != 10 {
t.Fatalf("buildSeedRapidUploadRequest() size = %d, want 10", req.Size)
}
if got := req.Whole.GetHash(utils.MD5); !strings.EqualFold(got, md5Hex) {
t.Fatalf("buildSeedRapidUploadRequest() MD5 = %q, want %q", got, md5Hex)
}
// A single file whose metadata size disagrees with the torrent length is
// internally inconsistent and must also be refused.
mismatched := &torrent.Torrent{
Info: torrent.TorrentInfo{Name: "a.bin", Length: 99},
OpenList: &torrent.Seed{
PieceSize: torrent.DefaultPieceSize,
Files: []torrent.SeedFile{
{Path: "a.bin", Size: 10, Hashes: torrent.SeedHashes{MD5: md5Hex}},
},
},
}
if req := buildSeedRapidUploadRequest(mismatched, nil); req != nil {
t.Fatalf("buildSeedRapidUploadRequest() accepted a size/hash mismatch: %#v", req)
}
}
func TestBuildSeedRapidUploadRequestHandlesNil(t *testing.T) {
if req := buildSeedRapidUploadRequest(nil, nil); req != nil {
t.Fatalf("buildSeedRapidUploadRequest(nil) = %#v, want nil", req)
}
}