webdav: harden PUT grace period

Massimo Melina committed Mar 19, 2026 at 10:25 UTC ad2bac37a7224b9698de57023196b6878916a932
2 files changed +44 -5
src/webdav.ts
+6 -5
@@ -35,7 +35,7 @@ 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()
38 +const canOverwrite = new Set<string>()
39 const locks = new Map<string, { token: string, timeout: NodeJS.Timeout, seconds: number }>()
40
41 function isLocked(path: string, ctx: Koa.Context) {
@@ -77,14 +77,15 @@ export async function handledWebdav(ctx: Koa.Context) {
77 return true
78 if (ctx.method === 'PUT') {
79 if (isLocked(path, ctx)) return true
80 + const overwriteGraceKey = path + prefix('|', getCurrentUsername(ctx)) // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
81 // Finder first creates an empty file (a test?) then wants to overwrite it, which requires deletion permission, but the user may not have it, causing a renamed upload. To solve, so we give it special permission for a few seconds.
82 const x = ctx.get('x-expected-entity-length') // field used by Finder's webdav on actual upload, after
83 if (!x && !ctx.length) {
83 - canOverwrite.add(path)
84 - setTimeout(() => canOverwrite.delete(path), 10_000) // grace period
84 + canOverwrite.add(overwriteGraceKey)
85 + setTimeout(() => canOverwrite.delete(overwriteGraceKey), 10_000) // grace period
86 }
86 - else if (canOverwrite.has(path)) {
87 - canOverwrite.delete(path)
87 + else if (canOverwrite.has(overwriteGraceKey)) {
88 + canOverwrite.delete(overwriteGraceKey)
89 const node = await urlToNode(path, ctx)
90 if (node?.source)
91 await rm(node.source).catch(() => {})
tests/test.ts
+38
@@ -313,6 +313,44 @@ describe('webdav', () => {
313 await rmAny(destPath)
314 }
315 })
316 + test('webdav.put grace is bound to username', async () => {
317 + const firstUser = `wd-grace-a-${randomId(6)}`.toLowerCase()
318 + const secondUser = `wd-grace-b-${randomId(6)}`.toLowerCase()
319 + const firstPass = `pw-${randomId(8)}`
320 + const secondPass = `pw-${randomId(8)}`
321 + const name = `wd-grace-${randomId(6)}.txt`
322 + const uri = `${CANT_OVERWRITE_URI}${name}`
323 + const dir = await ensureCantOverwriteDir()
324 + const destPath = resolve(dir, name)
325 + const adminReq = { auth, jar: {} }
326 + try {
327 + await reqApi('add_account', { username: firstUser, overwrite: true, password: firstPass, belongs: ['admins'] }, res => res?.username === firstUser, adminReq)()
328 + await reqApi('add_account', { username: secondUser, overwrite: true, password: secondPass, belongs: ['admins'] }, res => res?.username === secondUser, adminReq)()
329 + await rmAny(destPath)
330 + await req(uri, x => x?.uri === uri, {
331 + method: 'PUT',
332 + auth: `${firstUser}:${firstPass}`,
333 + jar: {},
334 + headers: { 'content-length': '0', 'user-agent': WEBDAV_UA, },
335 + body: '',
336 + })()
337 + if (!existsSync(destPath))
338 + throw "first upload did not create the file"
339 + await req(uri, 403, {
340 + method: 'PUT',
341 + auth: `${secondUser}:${secondPass}`,
342 + jar: {},
343 + headers: { 'content-length': String(Buffer.byteLength('source')), 'user-agent': WEBDAV_UA },
344 + body: 'source',
345 + })()
346 + if (readFileSync(destPath, 'utf8') !== '')
347 + throw "second upload unexpectedly overwrote destination"
348 + }
349 + finally {
350 + await reqApi('del_account', { username: [firstUser, secondUser] }, 200, adminReq)().catch(() => {})
351 + await rmAny(destPath)
352 + }
353 + })
354 test('webdav.lock refresh keeps token', async () => {
355 const name = `wd-lock-${randomId(6)}.txt`
356 const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`