Skip to content

Commit 4f01e6d

Browse files
committed
WebDAV: Cap lock lifetime to prevent never-expiring locks photoprism#5736
1 parent da843e2 commit 4f01e6d

6 files changed

Lines changed: 237 additions & 1 deletion

File tree

internal/mutex/webdav.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ package mutex
22

33
import (
44
"sync"
5+
"time"
56

67
"golang.org/x/net/webdav"
78
)
89

10+
// WebDAVMaxLockLifetime caps how long a WebDAV lock may be held, so a client
11+
// cannot mint infinite or over-long locks that leak memory and lock-null files
12+
// until restart. A negative value disables the cap (upstream infinite default).
13+
var WebDAVMaxLockLifetime = time.Hour
14+
915
var webdavMutex = sync.Mutex{}
1016
var webdavLocks = make(map[string]webdav.LockSystem)
1117

@@ -17,11 +23,42 @@ func WebDAV(dir string) webdav.LockSystem {
1723
// Return existing LockSystem, or create a new one otherwise.
1824
if lock, ok := webdavLocks[dir]; ok {
1925
return lock
20-
} else if lock = webdav.NewMemLS(); lock != nil {
26+
} else if memLS := webdav.NewMemLS(); memLS != nil {
27+
lock = &webdavLockSystem{LockSystem: memLS}
2128
webdavLocks[dir] = lock
2229
return lock
2330
}
2431

2532
// Should never happen.
2633
return nil
2734
}
35+
36+
// ClampLockDuration limits a requested lock duration to WebDAVMaxLockLifetime,
37+
// treating a negative request or cap as infinite (webdav's convention).
38+
func ClampLockDuration(d time.Duration) time.Duration {
39+
if limit := WebDAVMaxLockLifetime; limit < 0 {
40+
return d
41+
} else if d < 0 || d > limit {
42+
return limit
43+
}
44+
45+
return d
46+
}
47+
48+
// webdavLockSystem wraps a webdav.LockSystem to clamp lock lifetimes; it is the
49+
// authoritative cap, while the server also clamps the LOCK timeout header so the
50+
// granted lifetime reported to the client stays honest.
51+
type webdavLockSystem struct {
52+
webdav.LockSystem
53+
}
54+
55+
// Create clamps the requested lock duration before creating the lock.
56+
func (ls *webdavLockSystem) Create(now time.Time, details webdav.LockDetails) (string, error) {
57+
details.Duration = ClampLockDuration(details.Duration)
58+
return ls.LockSystem.Create(now, details)
59+
}
60+
61+
// Refresh clamps the requested lock duration before refreshing the lock.
62+
func (ls *webdavLockSystem) Refresh(now time.Time, token string, duration time.Duration) (webdav.LockDetails, error) {
63+
return ls.LockSystem.Refresh(now, token, ClampLockDuration(duration))
64+
}

internal/mutex/webdav_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,94 @@ package mutex
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/stretchr/testify/assert"
8+
"golang.org/x/net/webdav"
79
)
810

911
func TestWebDAV(t *testing.T) {
1012
lockSystem := WebDAV("test")
1113
assert.NotNil(t, lockSystem)
14+
// Locks are clamped to a finite lifetime by default.
15+
assert.IsType(t, &webdavLockSystem{}, lockSystem)
16+
}
17+
18+
func TestClampLockDuration(t *testing.T) {
19+
original := WebDAVMaxLockLifetime
20+
defer func() { WebDAVMaxLockLifetime = original }()
21+
t.Run("InfiniteRequestClampedToCap", func(t *testing.T) {
22+
WebDAVMaxLockLifetime = time.Hour
23+
assert.Equal(t, time.Hour, ClampLockDuration(-1))
24+
})
25+
t.Run("OverLongRequestClampedToCap", func(t *testing.T) {
26+
WebDAVMaxLockLifetime = time.Hour
27+
assert.Equal(t, time.Hour, ClampLockDuration(24*time.Hour))
28+
})
29+
t.Run("ShortRequestKept", func(t *testing.T) {
30+
WebDAVMaxLockLifetime = time.Hour
31+
assert.Equal(t, 10*time.Minute, ClampLockDuration(10*time.Minute))
32+
})
33+
t.Run("ExactCapKept", func(t *testing.T) {
34+
WebDAVMaxLockLifetime = time.Hour
35+
assert.Equal(t, time.Hour, ClampLockDuration(time.Hour))
36+
})
37+
t.Run("NegativeCapDisablesClamp", func(t *testing.T) {
38+
WebDAVMaxLockLifetime = -1
39+
assert.Equal(t, time.Duration(-1), ClampLockDuration(-1))
40+
assert.Equal(t, 24*time.Hour, ClampLockDuration(24*time.Hour))
41+
})
42+
}
43+
44+
func TestWebDAVLockSystem_Create(t *testing.T) {
45+
original := WebDAVMaxLockLifetime
46+
defer func() { WebDAVMaxLockLifetime = original }()
47+
WebDAVMaxLockLifetime = time.Hour
48+
now := time.Unix(1700000000, 0)
49+
t.Run("InfiniteLockExpires", func(t *testing.T) {
50+
ls := &webdavLockSystem{LockSystem: webdav.NewMemLS()}
51+
// Request an infinite lock; it must be clamped so it eventually expires.
52+
// A conflicting Create on the same root is the observable: it is refused
53+
// while the lock is held and succeeds once the clamped lifetime elapses.
54+
token, err := ls.Create(now, webdav.LockDetails{Root: "/infinite", Duration: -1, ZeroDepth: true})
55+
assert.NoError(t, err)
56+
assert.NotEmpty(t, token)
57+
// Still held just before the cap.
58+
_, err = ls.Create(now.Add(59*time.Minute), webdav.LockDetails{Root: "/infinite", Duration: time.Minute, ZeroDepth: true})
59+
assert.ErrorIs(t, err, webdav.ErrLocked)
60+
// Expired after the cap, so the root can be locked again.
61+
_, err = ls.Create(now.Add(61*time.Minute), webdav.LockDetails{Root: "/infinite", Duration: time.Minute, ZeroDepth: true})
62+
assert.NoError(t, err)
63+
})
64+
t.Run("ShortLockKept", func(t *testing.T) {
65+
ls := &webdavLockSystem{LockSystem: webdav.NewMemLS()}
66+
token, err := ls.Create(now, webdav.LockDetails{Root: "/short", Duration: 5 * time.Minute, ZeroDepth: true})
67+
assert.NoError(t, err)
68+
assert.NotEmpty(t, token)
69+
// Still held within the requested five minutes (under the cap, so kept).
70+
_, err = ls.Create(now.Add(1*time.Minute), webdav.LockDetails{Root: "/short", Duration: time.Minute, ZeroDepth: true})
71+
assert.ErrorIs(t, err, webdav.ErrLocked)
72+
// Expired after the requested five minutes.
73+
_, err = ls.Create(now.Add(6*time.Minute), webdav.LockDetails{Root: "/short", Duration: time.Minute, ZeroDepth: true})
74+
assert.NoError(t, err)
75+
})
76+
}
77+
78+
func TestWebDAVLockSystem_Refresh(t *testing.T) {
79+
original := WebDAVMaxLockLifetime
80+
defer func() { WebDAVMaxLockLifetime = original }()
81+
WebDAVMaxLockLifetime = time.Hour
82+
now := time.Unix(1700000000, 0)
83+
ls := &webdavLockSystem{LockSystem: webdav.NewMemLS()}
84+
token, err := ls.Create(now, webdav.LockDetails{Root: "/refresh", Duration: 5 * time.Minute, ZeroDepth: true})
85+
assert.NoError(t, err)
86+
t.Run("InfiniteRefreshClampedToCap", func(t *testing.T) {
87+
details, refreshErr := ls.Refresh(now, token, -1)
88+
assert.NoError(t, refreshErr)
89+
assert.Equal(t, time.Hour, details.Duration)
90+
})
91+
t.Run("NoSuchLock", func(t *testing.T) {
92+
_, refreshErr := ls.Refresh(now, "unknown-token", time.Minute)
93+
assert.Error(t, refreshErr)
94+
})
1295
}

internal/server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
- Built-in security middleware skips browser-document headers (`Content-Security-Policy`, `X-Frame-Options`) on `/originals` and `/import` paths.
5959
- PROPFIND `207 Multi-Status` responses normalize XML media type to `application/xml; charset=utf-8`.
6060
- Request errors go to the console-only system log (`event.System*`), not the browser log stream, since `x/net/webdav` embeds absolute server paths in its messages. A `MKCOL` on an existing collection is a benign sync-client probe: it returns 405 and is logged at debug rather than as an error.
61+
- `LOCK` lifetimes are capped at `mutex.WebDAVMaxLockLifetime` (default one hour) so a client cannot mint locks that never expire: `WebDAVClampLockTimeout` clamps the requested `Timeout` header and the `webdavLockSystem` wrapper enforces the same bound on the stored lock.
6162
- AutoTLS: uses `autocert` and spins up a redirect listener; ensure ports 80/443 are reachable.
6263
- Unix sockets: optional `force` query removes stale sockets; permissions can be set via `mode` query.
6364
- Health endpoints (`/livez`, `/health`, `/healthz`, `/readyz`) return `Cache-Control: no-store` and `Access-Control-Allow-Origin: *`.

internal/server/webdav.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package server
22

33
import (
44
"errors"
5+
"fmt"
56
"net/http"
67
"os"
78
"path/filepath"
9+
"strconv"
810
"strings"
911
"time"
1012

@@ -135,6 +137,10 @@ func WebDAV(dir string, router *gin.RouterGroup, conf *config.Config) {
135137
}
136138
}
137139

140+
// Clamp the requested LOCK lifetime so a client cannot mint infinite or
141+
// excessively long-lived locks; the lock system enforces the same cap.
142+
WebDAVClampLockTimeout(c.Request)
143+
138144
// Invoke handler callback.
139145
WebDAVHandler(c, router, srv)
140146
}
@@ -184,6 +190,44 @@ func WebDAV(dir string, router *gin.RouterGroup, conf *config.Config) {
184190
}
185191
}
186192

193+
// WebDAVClampLockTimeout rewrites the LOCK "Timeout" request header so the lock the
194+
// upstream handler grants — and the lifetime it reports back — cannot exceed
195+
// mutex.WebDAVMaxLockLifetime. Clamping the header keeps the granted timeout the client
196+
// sees consistent with what is actually enforced, so conformant clients refresh in time.
197+
func WebDAVClampLockTimeout(request *http.Request) {
198+
if request == nil || request.Method != header.MethodLock {
199+
return
200+
}
201+
202+
// A negative cap disables clamping (infinite locks allowed).
203+
maxLifetime := mutex.WebDAVMaxLockLifetime
204+
if maxLifetime < 0 {
205+
return
206+
}
207+
208+
// The upstream handler parses only the first comma-separated timeout value.
209+
first := request.Header.Get(header.Timeout)
210+
if i := strings.IndexByte(first, ','); i >= 0 {
211+
first = first[:i]
212+
}
213+
first = strings.TrimSpace(first)
214+
215+
maxSeconds := int64(maxLifetime / time.Second)
216+
capped := fmt.Sprintf("Second-%d", maxSeconds)
217+
218+
switch {
219+
case first == "" || strings.EqualFold(first, "Infinite"):
220+
// Absent or infinite request: grant the capped lifetime instead.
221+
request.Header.Set(header.Timeout, capped)
222+
case strings.HasPrefix(first, "Second-"):
223+
// Numeric request: clamp only when it exceeds the cap, leave malformed
224+
// values untouched so the upstream handler still rejects them.
225+
if n, err := strconv.ParseInt(first[len("Second-"):], 10, 64); err == nil && n > maxSeconds {
226+
request.Header.Set(header.Timeout, capped)
227+
}
228+
}
229+
}
230+
187231
// WebDAVFileName determines the name and path of an uploaded file and returns its name if it exists.
188232
func WebDAVFileName(request *http.Request, router *gin.RouterGroup, conf *config.Config) (fileName string) {
189233
// Check if this is a PUT request, as used for file uploads.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package server
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
10+
"github.com/photoprism/photoprism/internal/mutex"
11+
"github.com/photoprism/photoprism/pkg/http/header"
12+
)
13+
14+
func TestWebDAVClampLockTimeout(t *testing.T) {
15+
original := mutex.WebDAVMaxLockLifetime
16+
defer func() { mutex.WebDAVMaxLockLifetime = original }()
17+
mutex.WebDAVMaxLockLifetime = time.Hour
18+
19+
lockRequest := func(timeout string) *http.Request {
20+
r, _ := http.NewRequest(header.MethodLock, "http://localhost/originals/x.txt", nil)
21+
if timeout != "" {
22+
r.Header.Set(header.Timeout, timeout)
23+
}
24+
return r
25+
}
26+
27+
t.Run("InfiniteClampedToCap", func(t *testing.T) {
28+
r := lockRequest("Infinite")
29+
WebDAVClampLockTimeout(r)
30+
assert.Equal(t, "Second-3600", r.Header.Get(header.Timeout))
31+
})
32+
t.Run("AbsentClampedToCap", func(t *testing.T) {
33+
r := lockRequest("")
34+
WebDAVClampLockTimeout(r)
35+
assert.Equal(t, "Second-3600", r.Header.Get(header.Timeout))
36+
})
37+
t.Run("OverCapClamped", func(t *testing.T) {
38+
r := lockRequest("Second-99999")
39+
WebDAVClampLockTimeout(r)
40+
assert.Equal(t, "Second-3600", r.Header.Get(header.Timeout))
41+
})
42+
t.Run("UnderCapKept", func(t *testing.T) {
43+
r := lockRequest("Second-600")
44+
WebDAVClampLockTimeout(r)
45+
assert.Equal(t, "Second-600", r.Header.Get(header.Timeout))
46+
})
47+
t.Run("FirstValueClamped", func(t *testing.T) {
48+
r := lockRequest("Infinite, Second-300")
49+
WebDAVClampLockTimeout(r)
50+
assert.Equal(t, "Second-3600", r.Header.Get(header.Timeout))
51+
})
52+
t.Run("MalformedLeftUntouched", func(t *testing.T) {
53+
r := lockRequest("bogus")
54+
WebDAVClampLockTimeout(r)
55+
assert.Equal(t, "bogus", r.Header.Get(header.Timeout))
56+
})
57+
t.Run("NonLockMethodIgnored", func(t *testing.T) {
58+
r, _ := http.NewRequest(header.MethodPut, "http://localhost/originals/x.txt", nil)
59+
r.Header.Set(header.Timeout, "Infinite")
60+
WebDAVClampLockTimeout(r)
61+
assert.Equal(t, "Infinite", r.Header.Get(header.Timeout))
62+
})
63+
t.Run("NegativeCapDisablesClamp", func(t *testing.T) {
64+
mutex.WebDAVMaxLockLifetime = -1
65+
r := lockRequest("Infinite")
66+
WebDAVClampLockTimeout(r)
67+
assert.Equal(t, "Infinite", r.Header.Get(header.Timeout))
68+
})
69+
}

pkg/http/header/webdav.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ const (
55
XFavorite = "X-Favorite"
66
// XModTime conveys modification time in WebDAV headers.
77
XModTime = "X-OC-MTime"
8+
// Timeout is the WebDAV LOCK request header that carries the requested lock lifetime.
9+
Timeout = "Timeout"
810
)

0 commit comments

Comments
 (0)