webdav: avoid poisoning of the UA-based logic

Massimo Melina committed Mar 9, 2026 at 15:53 UTC 9a1c44cba7675240719a2e3ac017e6ccb6e3bcf2
1 file changed +12 -9
src/webdav.ts
+12 -9
@@ -25,7 +25,7 @@ import _ from 'lodash'
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)
27 const webdavPrompted = expiringCache<boolean>(DAY)
28 -const webdavDetectedAgents = new Set<string>()
28 +const webdavDetectedAgents = expiringCache<boolean>(DAY)
29
30 const TOKEN_HEADER = 'lock-token'
31 const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
@@ -56,19 +56,17 @@ function hasToken(header: string, token: string) {
56
57 export async function handledWebdav(ctx: Koa.Context) {
58 let {path} = ctx
59 - path = path.replace(/^\/+/, '/') // double-slash is causing empty listing in filezilla pro
59 + path = path.replace(/^\/+/, '/') // double-slash is causing empty listing in filezilla-pro
60
61 - const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => !!ctx.get(h))
61 const ua = ctx.get('user-agent')
63 - if (isWebdavAuthRequest && getCurrentUsername(ctx)) {
64 - if (ua)
65 - webdavDetectedAgents.add(ua)
66 - }
67 -
62 if (path.includes('/._') && ua?.startsWith('WebDAVFS')) {// too much spam from Finder for these files that can contain metas
63 ctx.state.dontLog = true
64 return ctx.status = HTTP_FORBIDDEN
65 }
66 + const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h))
67 + if (isWebdavAuthRequest && ua && getCurrentUsername(ctx))
68 + webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true)
69 +
70 if (ctx.method === 'OPTIONS') {
71 if (ctx.get('Access-Control-Request-Method')) return // it's a preflight cors request, not webdav
72 setWebdavHeaders()
@@ -94,7 +92,7 @@ export async function handledWebdav(ctx: Koa.Context) {
92 if (x && ctx.length === undefined) // missing length can make PUT fail
93 ctx.req.headers['content-length'] = x
94
97 - if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(ua))
95 + if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
96 ctx.query.existing ??= 'overwrite' // with webdav this is our default
97 return // default handling
98 }
@@ -317,6 +315,11 @@ function compileWebdavAgentRegex(v: boolean|string) {
315 return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i')
316 }
317
318 +function webdavAgentKey(ctx: Koa.Context, ua: string) {
319 + // tying detection to source IP avoids promoting one spoofed UA to global WebDAV behavior
320 + return `${ctx.ip}|${ua}`
321 +}
322 +
323 // Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
324 const cleaners: Dict<Timeout> = {}
325 function dotClean(path: string) {