@samitouri / QOSami-HFS / commits / 9fc69008

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