WebDAV: Bound sync download size to the originals limit

This commit is contained in:
Michael Mayer 2026-06-11 09:51:36 +00:00
parent 487b0c0ec3
commit 0f2de0fc15
3 changed files with 90 additions and 7 deletions

View file

@ -23,12 +23,25 @@ import (
// Client represents a webdav client.
type Client struct {
client *webdav.Client
ctx context.Context
endpoint *url.URL
timeout time.Duration
mkdir map[string]bool
cidrs []*net.IPNet
client *webdav.Client
ctx context.Context
endpoint *url.URL
timeout time.Duration
mkdir map[string]bool
cidrs []*net.IPNet
downloadLimit int64
}
// SetDownloadLimit bounds the size of a single downloaded file in bytes; a value
// of zero or less leaves downloads unbounded. The remote endpoint is a separate
// trust domain, so this caps how much one response can write to local storage —
// files above the configured originals limit are rejected by the indexer anyway.
func (c *Client) SetDownloadLimit(maxBytes int64) {
if c == nil {
return
}
c.downloadLimit = maxBytes
}
// clientUrl returns the validated server url including username and password, if specified.
@ -466,7 +479,21 @@ func (c *Client) Download(src, dest string, force bool) (err error) {
return fmt.Errorf("webdav: failed to create %s", clean.Log(path.Base(dest)))
}
if _, err = f.ReadFrom(reader); err != nil {
if c.downloadLimit > 0 {
// Read one byte past the limit so an exact-size overflow is detected
// instead of being silently truncated into a corrupt local file.
if n, copyErr := io.Copy(f, io.LimitReader(reader, c.downloadLimit+1)); copyErr != nil {
err = copyErr
} else if n > c.downloadLimit {
_ = f.Close()
_ = os.Remove(dest)
return fmt.Errorf("webdav: %s exceeds the maximum size of %d bytes", clean.Log(path.Base(dest)), c.downloadLimit)
}
} else {
_, err = f.ReadFrom(reader)
}
if err != nil {
_ = f.Close()
log.Errorf("webdav: %s", clean.Error(err))
return fmt.Errorf("webdav: failed writing to %s", clean.Log(path.Base(dest)))

View file

@ -640,3 +640,55 @@ func TestClient_DownloadDir(t *testing.T) {
}
})
}
func TestClient_DownloadLimit(t *testing.T) {
const bodySize = 2048
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
//nolint:gosec // test fixture writes a locally generated payload only
_, _ = w.Write(make([]byte, bodySize))
}))
t.Cleanup(server.Close)
newClient := func(t *testing.T) *Client {
t.Helper()
c, err := NewClient(server.URL+"/", "", "", TimeoutLow, "")
require.NoError(t, err)
return c
}
t.Run("OverLimitRejected", func(t *testing.T) {
client := newClient(t)
client.SetDownloadLimit(1024)
dest := filepath.Join(t.TempDir(), "big.bin")
err := client.Download("/big.bin", dest, false)
require.Error(t, err)
// The oversized partial file must not be left behind.
assert.False(t, fs.FileExists(dest))
})
t.Run("UnderLimitAccepted", func(t *testing.T) {
client := newClient(t)
client.SetDownloadLimit(4096)
dest := filepath.Join(t.TempDir(), "ok.bin")
err := client.Download("/ok.bin", dest, false)
require.NoError(t, err)
info, statErr := os.Stat(dest)
require.NoError(t, statErr)
assert.Equal(t, int64(bodySize), info.Size())
})
t.Run("ZeroLimitUnbounded", func(t *testing.T) {
client := newClient(t)
client.SetDownloadLimit(-1)
dest := filepath.Join(t.TempDir(), "unbounded.bin")
err := client.Download("/unbounded.bin", dest, false)
require.NoError(t, err)
info, statErr := os.Stat(dest)
require.NoError(t, statErr)
assert.Equal(t, int64(bodySize), info.Size())
})
}

View file

@ -95,6 +95,10 @@ func (w *Sync) download(a entity.Service) (complete bool, err error) {
return false, err
}
// Bound each download to the originals size limit so a remote sync target
// cannot overrun local storage with a single oversized file.
client.SetDownloadLimit(w.conf.OriginalsLimitBytes())
var baseDir string
if a.SyncFilenames {