From 5bf698156c77065ee7be7f896e1067f60da2ed01 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 18 Oct 2026 03:21:48 -0400 Subject: [PATCH] fix(webdav): PROPPATCH checks write access and answers with the masked path PROPPATCH now requires write access on the target, like PUT and DELETE, and its response href goes through the request's share-path masker the way PROPFIND's do. --- .../webdav/WebDAVController.test.ts | 66 ++++++++++++++++--- .../controllers/webdav/WebDAVController.ts | 6 +- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts index 169d5a74b..ed763e9f8 100644 --- a/src/backend/controllers/webdav/WebDAVController.test.ts +++ b/src/backend/controllers/webdav/WebDAVController.test.ts @@ -1893,6 +1893,17 @@ describe('WebDAVController verbs', () => { expect(captured.statusCode).toBe(207); expect(captured.body as string).toContain('HTTP/1.1 200 OK'); }); + + it("refuses a property update on another user's path", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const captured = await dispatch({ + method: 'PROPPATCH', + path: `/${owner.username}/Documents`, + actor: other.actor, + }); + expect(captured.statusCode).toBe(403); + }); }); describe('MKCOL', () => { @@ -2451,6 +2462,7 @@ describe('WebDAVController verbs', () => { const shareFolder = async ( owner: Awaited>, to: Awaited>, + mode: 'read' | 'write' = 'read', ) => { const dirUuid = uuidv4(); const dirPath = `/${owner.username}/Documents/Secrets`; @@ -2488,32 +2500,34 @@ describe('WebDAVController verbs', () => { server.services.share.share(owner.actor as never, { uid: dirUuid, recipient: { username: to.username! }, - mode: 'read', + mode, }), ); return { dirUuid, dirPath }; }; /** - * PROPFIND under the ALS wrap a real request gets, pre-auth actor + * A request under the ALS wrap a real request gets, pre-auth actor * empty. */ - const propfind = async (path: string, actor: unknown, depth = '1') => { + const davRequest = async ( + method: string, + path: string, + actor: unknown, + headers: Record = {}, + ) => { const { res, captured } = makeRes(); await runWithContext({ actor: undefined }, () => dispatchMiddleware( - makeReq({ - method: 'PROPFIND', - path, - headers: { depth }, - actor, - }), + makeReq({ method, path, headers, actor }), res, noop, ), ); return captured; }; + const propfind = (path: string, actor: unknown, depth = '1') => + davRequest('PROPFIND', path, actor, { depth }); // The DAV controller authenticates in-controller, after the request // context snapshotted an empty `req.actor` — so it has to place the @@ -2533,6 +2547,40 @@ describe('WebDAVController verbs', () => { expect(xml).not.toContain('/Documents/Secrets'); }); + it('refuses PROPPATCH on a share the recipient can only read', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dirUuid } = await shareFolder(owner, recipient); + + const masked = `/${owner.username}/${dirUuid}/Secrets`; + const captured = await davRequest( + 'PROPPATCH', + masked, + recipient.actor, + ); + + expect(captured.statusCode).toBe(403); + expect(String(captured.body)).not.toContain('/Documents/Secrets'); + }); + + it('answers PROPPATCH on a writable share under its masked path', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dirUuid } = await shareFolder(owner, recipient, 'write'); + + const masked = `/${owner.username}/${dirUuid}/Secrets`; + const captured = await davRequest( + 'PROPPATCH', + masked, + recipient.actor, + ); + + expect(captured.statusCode).toBe(207); + const xml = String(captured.body); + expect(xml).toContain(`${masked}`); + expect(xml).not.toContain('/Documents/Secrets'); + }); + it('lists each sharer as a collection under the root', async () => { const owner = await makeUser(); const recipient = await makeUser(); diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index a79eb1b66..a0a9dbab5 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -50,6 +50,7 @@ import { expandTildePath } from '../../services/fs/resolveNode.js'; import { rootShowsHome } from '../../services/fs/rootListing.js'; import { maskEntryPath, + maskPathForRequest, parseMaskedSharePath, resolveSharePath, } from '../../services/fs/sharePathMask.js'; @@ -745,17 +746,18 @@ export class WebDAVController extends PuterController { async #proppatch( res: Response, - { davPath, lockToken }: DavContext, + { actor, davPath, lockToken }: DavContext, ): Promise { if ( !(await hasWritePermission(this.clients.redis, davPath, lockToken)) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } + await this.#assertWrite(actor, davPath); res.status(207) .set({ 'Content-Type': 'application/xml; charset=utf-8' }) .send( - `\n${escapeXml(encodeURI(davPath))}HTTP/1.1 200 OK`, + `\n${escapeXml(encodeURI(maskPathForRequest(davPath)))}HTTP/1.1 200 OK`, ); }