webdav: improved LOCK and removed fake PROPPATCH support
Massimo Melina committed
Mar 8, 2026 at 13:44 UTC
6c536f0479abdc6b66f9396c2d47dafffa552c8f
3 files changed
+115
-61
package.json
+1
@@ -85,6 +85,7 @@
85
"busboy": "^1.6.0",
86
"crc-32": "^1.2.2",
87
"fast-glob": "^3.3.3",
88
+ "fast-xml-parser": "^5.4.2",
89
"find-process": "^2.0.0",
90
"fs-x-attributes": "^1.0.2",
91
"fswin": "^3.24.829",
src/webdav.ts
+70
-41
@@ -1,11 +1,12 @@
1
import Koa from 'koa'
2
+import { text as stream2string } from 'node:stream/consumers'
3
import {
4
getNodeName, nodeIsFolder, nodeIsLink, nodeStats, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode
5
} from './vfs'
6
import {
6
- HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND, HTTP_SERVER_ERROR,
7
- enforceFinal, pathEncode, prefix, getOrSet, Dict, Timeout, HTTP_UNAUTHORIZED, CFG, HTTP_LOCKED, HTTP_FORBIDDEN, DAY,
8
- join as crossJoin
7
+ HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
8
+ HTTP_PRECONDITION_FAILED, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED, HTTP_LOCKED, HTTP_FORBIDDEN,
9
+ DAY, CFG, enforceFinal, pathEncode, prefix, getOrSet, Dict, Timeout, join as crossJoin, try_
10
} from './cross'
11
import { PassThrough } from 'stream'
12
import { mkdir, rm } from 'fs/promises'
@@ -18,6 +19,8 @@ import { exec } from 'child_process'
19
import { getCurrentUsername } from './auth'
20
import { defineConfig } from './config'
21
import { expiringCache } from './expiringCache'
22
+import { XMLParser } from 'fast-xml-parser'
23
+import _ from 'lodash'
24
25
const forceWebdavLogin = defineConfig<boolean|string, null|RegExp>(CFG.force_webdav_login, true, compileWebdavAgentRegex)
26
const webdavInitialAuth = defineConfig<boolean|string, null|RegExp>(CFG.webdav_initial_auth, 'WebDAVFS', compileWebdavAgentRegex)
@@ -25,12 +28,15 @@ const webdavPrompted = expiringCache<boolean>(DAY)
28
const webdavDetectedAgents = new Set<string>()
29
30
const TOKEN_HEADER = 'lock-token'
28
-const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
31
+const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
32
const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length']
33
const KNOWN_UA = /webdav|miniredir|davclnt/i
34
+const LOCK_DEFAULT_SECONDS = 3600
35
+const LOCK_MAX_SECONDS = DAY / 1000
36
+const xmlParser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true })
37
38
const canOverwrite = new Set()
33
-const locks = new Map<string, { token: string, timeout: NodeJS.Timeout }>()
39
+const locks = new Map<string, { token: string, timeout: NodeJS.Timeout, seconds: number }>()
40
41
function isLocked(path: string, ctx: Koa.Context) {
42
const lock = locks.get(path)
@@ -57,7 +63,7 @@ export async function handledWebdav(ctx: Koa.Context) {
63
webdavDetectedAgents.add(ua)
64
}
65
60
- if (ctx.path.includes('/._') && ua?.startsWith('WebDAVFS')) {// too much spam from Finder for these files that can contain metas
66
+ if (path.includes('/._') && ua?.startsWith('WebDAVFS')) {// too much spam from Finder for these files that can contain metas
67
ctx.state.dontLog = true
68
return ctx.status = HTTP_FORBIDDEN
69
}
@@ -160,22 +166,65 @@ export async function handledWebdav(ctx: Koa.Context) {
166
}
167
if (ctx.method === 'LOCK') {
168
setWebdavHeaders()
169
+ const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await stream2string(ctx.req) : ''
170
+ const token = getProvidedLockToken(ctx)
171
+ let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1])
172
+ seconds = _.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS)
173
+
174
+ if (!body) {
175
+ // Finder and similar clients refresh an existing lock by sending LOCK without a body
176
+ if (!token)
177
+ return ctx.status = HTTP_BAD_REQUEST
178
+ const lock = locks.get(path)
179
+ if (token !== lock?.token)
180
+ return ctx.status = HTTP_PRECONDITION_FAILED
181
+ // refresh lock – keep the same token on refresh so clients can continue using the lock they already hold
182
+ clearTimeout(lock.timeout)
183
+ lock.timeout = setTimeout(() => locks.delete(path), seconds * 1000)
184
+ lock.seconds = seconds
185
+ locks.set(path, lock)
186
+
187
+ ctx.set(TOKEN_HEADER, lock.token)
188
+ ctx.body = renderLockResponse(lock.token, lock.seconds)
189
+ return true
190
+ }
191
+ const lockinfo = try_(() => xmlParser.parse(body).lockinfo)
192
+ const scope = _.keys(lockinfo?.lockscope)[0]
193
+ const type = _.keys(lockinfo?.locktype)[0]
194
+ if (!scope || !type)
195
+ return ctx.status = HTTP_BAD_REQUEST
196
+ if (ctx.get('depth') && ctx.get('depth') !== '0')
197
+ return ctx.status = HTTP_CONFLICT
198
+ if (scope !== 'exclusive' || type !== 'write')
199
+ return ctx.status = HTTP_CONFLICT
200
if (locks.has(path))
164
- return ctx.status = 423
165
- const token = 'urn:uuid:' + randomUUID()
166
- ctx.set(TOKEN_HEADER, token)
167
- const seconds = 3600
201
+ return ctx.status = HTTP_LOCKED
202
+ const newToken = 'urn:uuid:' + randomUUID()
203
const timeout = setTimeout(() => locks.delete(path), seconds * 1000)
169
- locks.set(path, { token, timeout })
170
- ctx.body = `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
171
- <locktype><write/></locktype>
172
- <lockscope><exclusive/></lockscope>
173
- <locktoken><href>${token}</href></locktoken>
174
- <lockroot><href>${path}</href></lockroot>
175
- <depth>0</depth>
176
- <timeout>Second-${seconds}</timeout>
177
- </activelock></lockdiscovery></prop>`
204
+ locks.set(path, { token: newToken, timeout, seconds })
205
+ ctx.set(TOKEN_HEADER, newToken)
206
+ ctx.body = renderLockResponse(newToken, seconds)
207
return true
208
+
209
+ function getProvidedLockToken(ctx: Koa.Context) {
210
+ const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '')
211
+ if (direct)
212
+ return direct
213
+ const ifHeader = ctx.get('If')
214
+ return /<([^>]+)>/.exec(ifHeader)?.[1] || ''
215
+ }
216
+
217
+ function renderLockResponse(token: string, seconds: number) {
218
+ return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
219
+ <locktype><write/></locktype>
220
+ <lockscope><exclusive/></lockscope>
221
+ <locktoken><href>${token}</href></locktoken>
222
+ <lockroot><href>${path}</href></lockroot>
223
+ <depth>0</depth>
224
+ <timeout>Second-${seconds}</timeout>
225
+ </activelock></lockdiscovery></prop>`
226
+ }
227
+
228
}
229
if (ctx.method === 'PROPFIND') {
230
setWebdavHeaders()
@@ -226,33 +275,13 @@ export async function handledWebdav(ctx: Koa.Context) {
275
}
276
if (ctx.method === 'PROPPATCH') {
277
setWebdavHeaders()
229
- if (isLocked(path, ctx)) return true
230
- const node = await urlToNode(path, ctx)
231
- if (!node) return
232
- if (statusCodeForMissingPerm(node, 'can_see', ctx)) {
233
- if (ctx.status === HTTP_UNAUTHORIZED)
234
- setWebdavHeaders(true)
235
- return true
236
- }
237
- ctx.type = 'xml'
238
- ctx.status = 207
239
- ctx.body = `<?xml version="1.0" encoding="utf-8"?>
240
- <multistatus xmlns="DAV:">
241
- <response>
242
- <href>${path}</href>
243
- <propstat>
244
- <status>HTTP/1.1 200 OK</status>
245
- <prop/>
246
- </propstat>
247
- </response>
248
- </multistatus>`
249
- return true
278
+ return ctx.status = HTTP_METHOD_NOT_ALLOWED
279
}
280
281
function setWebdavHeaders(authenticate=false) {
282
ctx.set('DAV', '1,2')
283
ctx.set('MS-Author-Via', 'DAV')
255
- ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
284
+ ctx.set('Allow', 'PROPFIND,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
285
if (authenticate)
286
ctx.set('WWW-Authenticate', `Basic realm="HFS WebDAV"`) // keep a dedicated realm for WebDAV so Windows credential cache is isolated from other basic-auth flows
287
}
tests/test.ts
+44
-20
@@ -41,14 +41,17 @@ const BIG_CONTENT = _.repeat(randomId(10), 300_000) // 3MB, big enough to satura
41
const throttle = BIG_CONTENT.length /1000 /0.8 // KB, finish in 0.8s, quick but still overlapping downloads
42
const SAMPLE_FILE_PATH = resolve(__dirname, 'page/gpl.png')
43
const WEBDAV_UA = 'Microsoft-WebDAV-MiniRedir/10.0.22000'
44
-const WEBDAV_PROPPATCH_BODY = `<?xml version="1.0" encoding="utf-8"?>
45
-<propertyupdate xmlns="DAV:">
46
- <set>
47
- <prop>
48
- <displayname>patched</displayname>
49
- </prop>
50
- </set>
51
-</propertyupdate>`
44
+const TOKEN_HEADER = 'lock-token'
45
+const WEBDAV_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?>
46
+<lockinfo xmlns="DAV:">
47
+ <lockscope><exclusive/></lockscope>
48
+ <locktype><write/></locktype>
49
+</lockinfo>`
50
+const WEBDAV_SHARED_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?>
51
+<lockinfo xmlns="DAV:">
52
+ <lockscope><shared/></lockscope>
53
+ <locktype><write/></locktype>
54
+</lockinfo>`
55
let defaultBaseUrl = BASE_URL
56
57
const execP = (cmd: string) => promisify(exec)(cmd).then(x => x.stdout)
@@ -294,27 +297,35 @@ describe('webdav', () => {
297
await rmAny(destPath)
298
}
299
})
297
- test('webdav.proppatch file', async () => {
298
- const name = `wd-proppatch-${randomId(6)}.txt`
300
+ test('webdav.lock refresh keeps token', async () => {
301
+ const name = `wd-lock-${randomId(6)}.txt`
302
const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
303
let destPath = ''
304
+ let token = ''
305
try {
306
destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')()
303
- await webdavProppatch(uri)()
307
+ await webdavLock(uri, (_data, res) => token = res.headers?.[TOKEN_HEADER] || '')()
308
+ if (!token)
309
+ throw "missing lock token"
310
+ await webdavLock(uri, (_data, res) =>
311
+ res.statusCode === 200 && res.headers?.[TOKEN_HEADER] === token, '', { If: `(<${token}>)` })()
312
}
313
finally {
314
+ if (token)
315
+ await webdavUnlock(uri, token)().catch(() => {})
316
await rmAny(destPath)
317
}
318
})
309
- test('webdav.proppatch folder', async () => {
310
- const folder = `wd-proppatch-dir-${randomId(6)}`
311
- const uri = `${UPLOAD_ROOT}${folder}/`
319
+ test('webdav.lock rejects shared lock', async () => {
320
+ const name = `wd-lock-shared-${randomId(6)}.txt`
321
+ const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
322
+ let destPath = ''
323
try {
313
- await reqApi('create_folder', { uri: UPLOAD_ROOT, name: folder }, 200, { auth, jar })()
314
- await webdavProppatch(uri)()
324
+ destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')()
325
+ await webdavLock(uri, 409, WEBDAV_SHARED_LOCK_BODY)()
326
}
327
finally {
317
- await req(uri, 200, { method: 'delete', auth, jar })().catch(() => {})
328
+ await rmAny(destPath)
329
}
330
})
331
@@ -331,20 +342,33 @@ describe('webdav', () => {
342
})().then(res => uploadUriToPath(res?.uri || uri))
343
}
344
334
- function webdavProppatch(uri: string, tester: Tester=207, body=WEBDAV_PROPPATCH_BODY, userAgent=WEBDAV_UA) {
345
+ function webdavLock(uri: string, tester: Tester=200, body=WEBDAV_LOCK_BODY, headers?: Record<string, string>) {
346
return req(uri, tester, {
336
- method: 'PROPPATCH',
347
+ method: 'LOCK',
348
auth,
349
jar,
350
headers: {
351
'content-type': 'text/xml',
352
'content-length': Buffer.byteLength(body),
342
- 'user-agent': userAgent,
353
+ 'user-agent': WEBDAV_UA,
354
+ ...headers,
355
},
356
body,
357
})
358
}
359
360
+ function webdavUnlock(uri: string, token: string, tester: Tester=204) {
361
+ return req(uri, tester, {
362
+ method: 'UNLOCK',
363
+ auth,
364
+ jar,
365
+ headers: {
366
+ 'user-agent': WEBDAV_UA,
367
+ [TOKEN_HEADER]: `<${token}>`,
368
+ },
369
+ })
370
+ }
371
+
372
})
373
374
// do this before login, or max_dl_accounts config will override max_dl