mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 13:03:09 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 哈希拼接
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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/")) ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user