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}`