webdav: better support for ms-office
Massimo Melina committed
May 5, 2026 at 13:58 UTC
9fc69008c4ac7b2b251226a7c2172ce202314483
5 files changed
+73
-16
src/basicWeb.ts
+2
-3
@@ -1,5 +1,5 @@
1
import { getCurrentUsername, setLoggedIn } from './auth'
2
-import { HTTP_UNAUTHORIZED } from './cross-const'
2
+import { BASIC_AUTHENTICATE_HEADER, HTTP_UNAUTHORIZED } from './cross-const'
3
import Koa from 'koa'
4
import { defineConfig } from './config'
5
import { getNodeName, hasDefaultFile, nodeIsFolder, VfsNode, walkNode } from './vfs'
@@ -16,7 +16,7 @@ export function basicWeb(ctx: Koa.Context, node: VfsNode) {
16
if (getCurrentUsername(ctx))
17
ctx.redirect(ctx.get('referer'))
18
else {
19
- ctx.set('WWW-Authenticate', 'Basic')
19
+ ctx.set('WWW-Authenticate', BASIC_AUTHENTICATE_HEADER)
20
ctx.status = HTTP_UNAUTHORIZED
21
}
22
return true
@@ -63,4 +63,3 @@ export function detectBasicAgent(ctx: Koa.Context) {
63
return autoBasic.get() && /^$|Mozilla\/4|WebKit\/([234]\d\d|5[012]\d|53[0123456])[. ]|Trident|Lynx|curl|Firefox\/(\d|[1234]\d)\./.test(ua)
64
|| autoBasic.compiled()?.test(ua)
65
}
66
-
src/cross-const.ts
+1
-1
@@ -15,6 +15,7 @@ export const ALLOW_SESSION_IP_CHANGE = 'allow_session_ip_change'
15
export const HIDE_IN_TESTS = 'hideInTests' // elements that have variable size, where masking would produce changes, must be hidden
16
export const MASK_IN_TESTS = 'maskInTests'
17
export const EMBEDDED_LANGUAGE = 'en' // frontend includes this language in the code, and not need to import the translation-json
18
+export const BASIC_AUTHENTICATE_HEADER = 'Basic realm="HFS"'
19
20
export const HTTP_OK = 200
21
export const HTTP_CREATED = 201
@@ -49,4 +50,3 @@ export const HTTP_MESSAGES: Record<number, string> = {
50
[HTTP_SERVER_ERROR]: "Server error",
51
[HTTP_TOO_MANY_REQUESTS]: "Too many requests",
52
}
52
-
src/serveGuiAndSharedFiles.ts
+4
-3
@@ -5,7 +5,8 @@ import { sendErrorPage } from './errorPages'
5
import events from './events'
6
import {
7
ADMIN_URI, FRONTEND_URI, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
8
- HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK, ICONS_URI, HTTP_FAILED_DEPENDENCY, UPLOAD_TEMP_HASH
8
+ HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK, ICONS_URI, HTTP_FAILED_DEPENDENCY, UPLOAD_TEMP_HASH,
9
+ BASIC_AUTHENTICATE_HEADER
10
} from './cross-const'
11
import { getUploadTempFor, uploadWriter } from './upload'
12
import { handleMultipartUpload } from './multipartUpload'
@@ -131,7 +132,7 @@ export const serveSharedFiles: Koa.Middleware = async (ctx, next) => {
132
: !node.source ? sendErrorPage(ctx, HTTP_METHOD_NOT_ALLOWED) // !dir && !source is not supported at this moment
133
: !statusCodeForMissingPerm(node, 'can_read', ctx) ? serveFileNode(ctx, node) // all good
134
: ctx.status !== HTTP_UNAUTHORIZED ? null // all errors don't need extra handling, except unauthorized
134
- : detectBasicAgent(ctx) ? (ctx.set('WWW-Authenticate', 'Basic'), sendErrorPage(ctx))
135
+ : detectBasicAgent(ctx) ? (ctx.set('WWW-Authenticate', BASIC_AUTHENTICATE_HEADER), sendErrorPage(ctx))
136
: ctx.query.dl === undefined && (ctx.state.serveApp = true) && serveFrontendFiles(ctx, next)
137
if (!path.endsWith('/'))
138
return ctx.redirect(ctx.state.revProxyPath + ctx.originalUrl.replace(/(\?|$)/, '/$1')) // keep query-string, if any
@@ -142,7 +143,7 @@ export const serveSharedFiles: Koa.Middleware = async (ctx, next) => {
143
const { authenticate } = ctx.query
144
const downloadManagerDetected = /DAP|FDM|[Mm]anager/.test(ctx.get('user-agent'))
145
if (downloadManagerDetected || authenticate || detectBasicAgent(ctx))
145
- return ctx.set('WWW-Authenticate', authenticate || 'Basic') // basic authentication for DMs getting the folder as a zip
146
+ return ctx.set('WWW-Authenticate', authenticate || BASIC_AUTHENTICATE_HEADER) // basic authentication for DMs getting the folder as a zip
147
ctx.state.serveApp = true
148
return serveFrontendFiles(ctx, next)
149
}
src/webdav.ts
+29
-9
@@ -7,7 +7,7 @@ import {
7
HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_OK,
8
HTTP_PRECONDITION_FAILED, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED, HTTP_LOCKED, HTTP_FORBIDDEN, HTTP_MESSAGES,
9
DAY, CFG, enforceFinal, removeFinal, pathEncode, prefix, getOrSet, Dict, Timeout, join as crossJoin, try_,
10
- safeDecodeURIComponent, wantArray,
10
+ safeDecodeURIComponent, wantArray, BASIC_AUTHENTICATE_HEADER,
11
} from './cross'
12
import { PassThrough } from 'stream'
13
import { mkdir, rm, utimes } from 'fs/promises'
@@ -33,7 +33,7 @@ const webdavDetectedAgents = expiringCache<boolean>(DAY)
33
const TOKEN_HEADER = 'lock-token'
34
const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
35
const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length']
36
-const KNOWN_UA = /webdav|miniredir|davclnt/i
36
+const KNOWN_UA = /webdav|miniredir|davclnt|microsoft office|ms-office/i
37
const LOCK_DEFAULT_SECONDS = 3600
38
const LOCK_MAX_SECONDS = DAY / 1000
39
const xmlParser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true })
@@ -102,16 +102,25 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
102
ctx.state.webdavDetected = true
103
return ctx.status = HTTP_FORBIDDEN
104
}
105
- const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h))
105
+ // office starts document access with OPTIONS, then LOCK/GET; challenging OPTIONS keeps the whole exchange in the same WebDAV auth realm
106
+ const isCorsPreflight = ctx.method === 'OPTIONS' && ctx.get('Access-Control-Request-Method')
107
+ const isKnownWebdavAgent = KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua))
108
+ const isWebdavAuthRequest = !isCorsPreflight && (ctx.method === 'OPTIONS' || WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h))
109
+ || ctx.method === 'GET' && isKnownWebdavAgent
110
+ )
111
if (isWebdavAuthRequest)
112
ctx.state.webdavDetected = true
113
if (isWebdavAuthRequest && ua && getCurrentUsername(ctx))
114
webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true)
115
111
- if (ctx.method === 'OPTIONS')
112
- return handleOptions()
116
+ if (isCorsPreflight)
117
+ return next()
118
if (isWebdavAuthRequest && shouldChallengeWebdav())
119
return
120
+ if (ctx.method === 'OPTIONS')
121
+ return handleOptions()
122
+ if (ctx.method === 'GET' && isWebdavAuthRequest)
123
+ return handleGet()
124
switch (ctx.method) {
125
case 'PUT': return handlePut()
126
case 'MKCOL': return handleMkcol()
@@ -125,12 +134,23 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
134
return next()
135
136
async function handleOptions() {
128
- if (ctx.get('Access-Control-Request-Method')) // it's a preflight cors request, not webdav
129
- return next()
137
setWebdavHeaders()
138
ctx.body = ''
139
}
140
141
+ async function handleGet() {
142
+ const node = await urlToNode(path, ctx)
143
+ if (!node || nodeIsFolder(node))
144
+ return next()
145
+ // webdav file reads must not fall through to the browser frontend when auth rejects them
146
+ if (statusCodeForMissingPerm(node, 'can_read', ctx)) {
147
+ if (ctx.status === HTTP_UNAUTHORIZED)
148
+ setWebdavHeaders(true)
149
+ return
150
+ }
151
+ return next()
152
+ }
153
+
154
async function handlePut() {
155
if (await isLocked(path, ctx)) return
156
const overwriteGraceKey = path + prefix('|', getCurrentUsername(ctx)) // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
@@ -147,7 +167,7 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
167
if (x && ctx.length === undefined) // missing length can make PUT fail
168
ctx.req.headers['content-length'] = x
169
150
- if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
170
+ if (isKnownWebdavAgent)
171
ctx.query.existing ??= 'overwrite' // with webdav this is our default
172
await next()
173
if (ctx.status === HTTP_OK)
@@ -359,7 +379,7 @@ export const webdav: Koa.Middleware = async (ctx, next) => {
379
ctx.set('MS-Author-Via', 'DAV')
380
ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
381
if (authenticate)
362
- 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
382
+ ctx.set('WWW-Authenticate', BASIC_AUTHENTICATE_HEADER)
383
}
384
385
function shouldChallengeWebdav() {
tests/test.ts
+37
@@ -13,6 +13,7 @@ import { ThrottledStream, ThrottleGroup } from '../src/ThrottledStream'
13
import { mkdir, rm, rename, writeFile, access } from 'fs/promises'
14
import { Readable } from 'stream'
15
import { XMLValidator } from 'fast-xml-parser'
16
+import { BASIC_AUTHENTICATE_HEADER } from '../src/cross'
17
/*
18
import { PORT, srv } from '../src'
19
@@ -42,6 +43,7 @@ const BIG_CONTENT = _.repeat(randomId(10), 300_000) // 3MB, big enough to satura
43
const throttle = BIG_CONTENT.length /1000 /0.8 // KB, finish in 0.8s, quick but still overlapping downloads
44
const SAMPLE_FILE_PATH = resolve(__dirname, 'page/gpl.png')
45
const WEBDAV_UA = 'Microsoft-WebDAV-MiniRedir/10.0.22000'
46
+const OFFICE_WEBDAV_UA = 'Microsoft Office Existence Discovery'
47
const TOKEN_HEADER = 'lock-token'
48
const WEBDAV_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?>
49
<lockinfo xmlns="DAV:">
@@ -312,6 +314,41 @@ describe('webdav', () => {
314
const jar = {}
315
after(() => rmAny(resolve(__dirname, UPLOAD_DIR)))
316
test('webdav force login.scope propfind', req('/f1/', 401, { method: 'PROPFIND', headers: { depth: '0' }, jar }))
317
+ test('webdav force login.scope options', req('/f1/', (_data, res) =>
318
+ res.statusCode === 401 && res.headers?.['www-authenticate'] === BASIC_AUTHENTICATE_HEADER, {
319
+ method: 'OPTIONS',
320
+ headers: { 'user-agent': OFFICE_WEBDAV_UA },
321
+ jar: {},
322
+ }))
323
+ test('webdav force login.scope get', req('/f1/protected', (_data, res) =>
324
+ res.statusCode === 401 && res.headers?.['www-authenticate'] === BASIC_AUTHENTICATE_HEADER, {
325
+ headers: { 'user-agent': OFFICE_WEBDAV_UA },
326
+ jar: {},
327
+ }))
328
+ test('webdav.get keeps webdav challenge after denied read', async () => {
329
+ const user = `wd-read-${randomId(6)}`.toLowerCase()
330
+ const pass = `pw-${randomId(8)}`
331
+ const adminReq = { auth, jar: {} }
332
+ try {
333
+ await reqApi('add_account', { username: user, overwrite: true, password: pass }, res => res?.username === user, adminReq)()
334
+ await req('/f1/protected', (_data, res) =>
335
+ res.statusCode === 401 && res.headers?.['www-authenticate'] === BASIC_AUTHENTICATE_HEADER, {
336
+ auth: `${user}:${pass}`,
337
+ headers: { 'user-agent': OFFICE_WEBDAV_UA },
338
+ jar: {},
339
+ })()
340
+ }
341
+ finally {
342
+ await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {})
343
+ }
344
+ })
345
+ test('webdav options works after auth', req('/f1/', (_data, res) =>
346
+ res.statusCode === 200 && res.headers?.dav === '1,2', {
347
+ method: 'OPTIONS',
348
+ auth,
349
+ headers: { 'user-agent': OFFICE_WEBDAV_UA },
350
+ jar: {},
351
+ }))
352
test('webdav.put detects client after propfind', async () => {
353
const name = `wd-detected-${randomId(6)}.txt`
354
const ua = `hfs-test-detected-${randomId(6)}`