From 2dd7073034b8ea0af49c6289b019122f425c901a Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 19 Aug 2026 14:04:56 -0700 Subject: [PATCH] fix: vscode webdav (#3610) --- .../webdav/WebDAVController.test.ts | 482 ++++++++++++- .../controllers/webdav/WebDAVController.ts | 658 ++++++++++++------ src/backend/core/http/decorators.test.ts | 79 +++ src/backend/core/http/decorators.ts | 16 +- src/backend/server.test.ts | 67 ++ src/backend/server.ts | 25 + 6 files changed, 1070 insertions(+), 257 deletions(-) create mode 100644 src/backend/core/http/decorators.test.ts diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts index 1c8fd3016..970aa1361 100644 --- a/src/backend/controllers/webdav/WebDAVController.test.ts +++ b/src/backend/controllers/webdav/WebDAVController.test.ts @@ -1,11 +1,12 @@ // This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus // but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter -import type { Request, Response } from 'express'; +import type { Request, RequestHandler, Response } from 'express'; import { Readable, Writable } from 'node:stream'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import { hash as bcryptHash } from 'bcrypt'; import { PuterRouter } from '../../core/http/PuterRouter.js'; +import type { RouteDescriptor } from '../../core/http/types.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { runWithContext } from '../../core/context.js'; @@ -14,7 +15,7 @@ import type { WebDAVController } from './WebDAVController.js'; let server: PuterServer; let controller: WebDAVController; -let dispatchMiddleware: Function; +let routes: RouteDescriptor[]; beforeAll(async () => { server = await setupTestServer(); @@ -22,12 +23,60 @@ beforeAll(async () => { const router = new PuterRouter(); controller.registerRoutes(router); - - // WebDAVController registers a single `use()` middleware on the `dav` - // subdomain. Grab it to call directly in tests. - dispatchMiddleware = router.routes[0]!.handler; + routes = router.routes; }); +/** Run one handler; true when it called `next` instead of answering. */ +const runHandler = async ( + handler: RequestHandler, + req: Request, + res: Response, +): Promise => { + let advanced = false; + await handler(req, res, (() => { + advanced = true; + }) as never); + return advanced; +}; + +/** + * Stand in for express's router: walk the controller's collected routes in + * order and run the first one that matches the request's method, plus any + * `middleware` it declares. + * + * The options the real server materializes into gates — subdomain, rate limit, + * concurrency — are deliberately not applied here; those belong to the + * materializer's own tests, and reproducing them would make every case below + * share one DAV budget. `server.test.ts` covers the wiring on a real port. + */ +const dispatchMiddleware = async ( + req: Request, + res: Response, + next: () => void, +): Promise => { + const method = req.method.toLowerCase(); + for (const route of routes) { + if ( + route.method !== 'use' && + route.method !== 'all' && + route.method !== method + ) { + continue; + } + let advanced = true; + for (const handler of [ + ...(route.options.middleware ?? []), + route.handler, + ]) { + advanced = await runHandler(handler, req, res); + if (!advanced) break; + } + // A chain that stopped short of `next` answered the request. + if (!advanced) return; + } + next(); +}; + afterAll(async () => { await server?.shutdown(); }); @@ -152,10 +201,45 @@ const makeUser = async () => { describe('WebDAVController', () => { describe('route registration', () => { - it('registers a single catch-all use() route', () => { + it('registers one catch-all per DAV verb, all on the dav subdomain', () => { const router = new PuterRouter(); controller.registerRoutes(router); - expect(router.routes.length).toBeGreaterThanOrEqual(1); + + for (const verb of [ + 'options', + 'head', + 'get', + 'propfind', + 'proppatch', + 'mkcol', + 'put', + 'delete', + 'copy', + 'move', + 'lock', + 'unlock', + ]) { + const route = router.routes.find((r) => r.method === verb); + expect(route, verb).toBeDefined(); + expect(route!.path).toBe('/{*splat}'); + expect(route!.options.subdomain).toBe('dav'); + // The gates the materializer installs ahead of the handler. + expect(route!.options.rateLimit).toBeDefined(); + expect(route!.options.concurrent).toBeDefined(); + } + + // `all` catches the verbs we don't implement, so it has to come + // after every route that does. + const allIndex = router.routes.findIndex((r) => r.method === 'all'); + expect(allIndex).toBe(router.routes.length - 1); + }); + + it('registers HEAD ahead of GET so express does not fold the two', () => { + const router = new PuterRouter(); + controller.registerRoutes(router); + const head = router.routes.findIndex((r) => r.method === 'head'); + const get = router.routes.findIndex((r) => r.method === 'get'); + expect(head).toBeLessThan(get); }); }); @@ -241,6 +325,48 @@ describe('WebDAVController', () => { }); }); + describe('CORS preflight', () => { + it('answers a browser preflight before the auth gate', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'OPTIONS', + path: '/someone/.vscode/settings.json', + headers: { + origin: 'https://code.puter.com', + 'access-control-request-method': 'PROPFIND', + }, + }), + res, + noop, + ); + // A non-2xx here is what made every browser DAV client give up + // before it could send a token. + expect(captured.statusCode).toBe(200); + expect(captured.headers['dav']).toContain('1'); + expect(captured.headers['allow']).toContain('PROPFIND'); + expect(captured.headers['access-control-max-age']).toBe('86400'); + expect(captured.headers['www-authenticate']).toBeUndefined(); + }); + + it('still authenticates an OPTIONS that is not a preflight', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'OPTIONS', + // An Origin alone isn't a preflight — only the + // `Access-Control-Request-Method` probe is. + headers: { origin: 'https://code.puter.com' }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(401); + // macOS opens a mount on this reply, so it keeps the DAV header. + expect(captured.headers['dav']).toContain('1'); + }); + }); + describe('pending-verification gate', () => { // WebDAV must enforce the same gate every other authenticated route // gets from requireVerifiedAccount — it dispatches off a single use() @@ -1766,14 +1892,11 @@ describe('WebDAVController verbs', () => { }); describe('shared-path masking', () => { - // The DAV controller authenticates in-controller, after the request - // context snapshotted an empty `req.actor` — so it has to place the - // actor into the context itself or outbound masking silently turns - // off and PROPFIND lists the owner's real paths. - it('lists a shared folder under its masked path, not the owner’s real one', async () => { - const owner = await makeUser(); - const recipient = await makeUser(); - + /** A `Secrets/plan.txt` folder in `owner`'s Documents, shared with `to`. */ + const shareFolder = async ( + owner: Awaited>, + to: Awaited>, + ) => { const dirUuid = uuidv4(); const dirPath = `/${owner.username}/Documents/Secrets`; const now = Math.floor(Date.now() / 1000); @@ -1782,44 +1905,353 @@ describe('WebDAVController verbs', () => { ))!; await server.clients.db.write( 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 1, ?, ?, ?)', - [dirUuid, 'Secrets', dirPath, owner.userId, now, parent.id, parent.uuid], + [ + dirUuid, + 'Secrets', + dirPath, + owner.userId, + now, + parent.id, + parent.uuid, + ], ); const dir = (await server.stores.fsEntry.getEntryByPath(dirPath))!; await server.clients.db.write( 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)', - [uuidv4(), 'plan.txt', `${dirPath}/plan.txt`, owner.userId, now, dir.id, dir.uuid], + [ + uuidv4(), + 'plan.txt', + `${dirPath}/plan.txt`, + owner.userId, + now, + dir.id, + dir.uuid, + ], ); await runWithContext({ actor: owner.actor as never }, () => server.services.share.share(owner.actor as never, { uid: dirUuid, - recipient: { username: recipient.username! }, + recipient: { username: to.username! }, mode: 'read', }), ); + return { dirUuid, dirPath }; + }; - const masked = `/${owner.username}/${dirUuid}/Secrets`; + /** + * PROPFIND under the ALS wrap a real request gets, pre-auth actor + * empty. + */ + const propfind = async (path: string, actor: unknown, depth = '1') => { const { res, captured } = makeRes(); - // The same ALS wrap every real request gets from the - // request-context middleware, with the pre-auth (empty) actor. await runWithContext({ actor: undefined }, () => dispatchMiddleware( makeReq({ method: 'PROPFIND', - path: masked, - headers: { depth: '1' }, - actor: recipient.actor, + path, + headers: { depth }, + actor, }), res, noop, ), ); + return captured; + }; + + // The DAV controller authenticates in-controller, after the request + // context snapshotted an empty `req.actor` — so it has to place the + // actor into the context itself or outbound masking silently turns + // off and PROPFIND lists the owner's real paths. + it('lists a shared folder under its masked path, not the owner’s real one', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dirUuid } = await shareFolder(owner, recipient); + + const masked = `/${owner.username}/${dirUuid}/Secrets`; + const captured = await propfind(masked, recipient.actor); expect(captured.statusCode).toBe(207); const xml = String(captured.body); expect(xml).toContain(`${masked}/plan.txt`); 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(); + await shareFolder(owner, recipient); + + const captured = await propfind('/', recipient.actor); + expect(captured.statusCode).toBe(207); + const xml = String(captured.body); + // The recipient's own home, plus the owner who shared with them. + expect(xml).toContain(`/${recipient.username}/`); + expect(xml).toContain(`/${owner.username}/`); + }); + + it('omits the sharers at depth 0', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + await shareFolder(owner, recipient); + + const captured = await propfind('/', recipient.actor, '0'); + expect(captured.statusCode).toBe(207); + expect(String(captured.body)).not.toContain(`/${owner.username}/`); + }); + + it('answers a sharer’s collection with the shares they issued', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dirUuid } = await shareFolder(owner, recipient); + + const captured = await propfind( + `/${owner.username}`, + recipient.actor, + ); + expect(captured.statusCode).toBe(207); + const xml = String(captured.body); + expect(xml).toContain(`/${owner.username}/`); + // The `` level, which the share root hangs off. It borrows + // the shared item's name so a client has something to show. + expect(xml).toContain( + `/${owner.username}/${dirUuid}/`, + ); + expect(xml).toContain('Secrets'); + // Nothing else of the owner's shows through — not their home + // directory's other children, not the folder the share sits in. + expect(xml).not.toContain('Documents'); + }); + + it('keeps a sharer’s collection a 403 for a caller with no share', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + await shareFolder(owner, await makeUser()); + + const captured = await propfind( + `/${owner.username}`, + stranger.actor, + ); + expect(captured.statusCode).toBe(403); + }); }); + // The virtual collections invent two levels of path that no fsentry backs. + // Everything here checks that they only ever widen to what was actually + // shared — the rest of the owner's tree has to stay unreachable and + // unnamed. + describe('shared-space containment', () => { + /** + * `owner` shares `Shared/` (holding `in.txt`) with `to`, and keeps + * `Private/` (holding `out.txt`) to themselves. + */ + const shareOneOfTwo = async ( + owner: Awaited>, + to: Awaited>, + ) => { + const mk = async ( + method: string, + path: string, + extra: Record = {}, + ) => { + const { res, captured } = makeRes(); + await runWithContext({ actor: undefined }, () => + dispatchMiddleware( + makeReq({ + method, + path, + actor: owner.actor, + headers: extra, + }), + res, + noop, + ), + ); + return captured; + }; + await mk('MKCOL', `/${owner.username}/Documents/Shared`); + await mk('MKCOL', `/${owner.username}/Documents/Private`); + + const shared = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Documents/Shared`, + ))!; + const private_ = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Documents/Private`, + ))!; + + await runWithContext({ actor: owner.actor as never }, () => + server.services.share.share(owner.actor as never, { + uid: shared.uuid, + recipient: { username: to.username! }, + mode: 'read', + }), + ); + return { shared, private: private_ }; + }; + + const propfind = async (path: string, actor: unknown, depth = '1') => { + const { res, captured } = makeRes(); + await runWithContext({ actor: undefined }, () => + dispatchMiddleware( + makeReq({ + method: 'PROPFIND', + path, + headers: { depth }, + actor, + }), + res, + noop, + ), + ); + return captured; + }; + + it('lists only the items that were shared, not the owner’s siblings', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const { shared, private: hidden } = await shareOneOfTwo( + owner, + holder, + ); + + const captured = await propfind(`/${owner.username}`, holder.actor); + expect(captured.statusCode).toBe(207); + const xml = String(captured.body); + expect(xml).toContain(shared.uuid); + expect(xml).not.toContain(hidden.uuid); + expect(xml).not.toContain('Private'); + }); + + it('does not recurse past the uuid level at Depth: infinity', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const { shared } = await shareOneOfTwo(owner, holder); + + const captured = await propfind( + `/${owner.username}`, + holder.actor, + 'infinity', + ); + expect(captured.statusCode).toBe(207); + const xml = String(captured.body); + // The share's own href lives one segment deeper; asking for the + // world must not walk into it. + expect(xml).toContain(`/${shared.uuid}/`); + expect(xml).not.toContain(`/${shared.uuid}/Shared`); + }); + + it('refuses every way out of a share', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const { shared, private: hidden } = await shareOneOfTwo( + owner, + holder, + ); + const root = `/${owner.username}/${shared.uuid}/Shared`; + + for (const escape of [ + // Up and sideways out of the shared subtree. + `${root}/../Private`, + `${root}/../../Documents`, + // Percent-encoded, because the path is decoded before it is + // resolved — the guard has to sit after that, not before. + `${root}/%2e%2e/Private`, + // The owner's real path, named directly rather than walked to. + `/${owner.username}/Documents/Private`, + `/${owner.username}/Documents`, + // A mask pointing at an entry the holder was never given — + // both a bad tail under a uuid they do hold, and the uuid of + // one they don't. + `/${owner.username}/${shared.uuid}/Private`, + `/${owner.username}/${hidden.uuid}`, + `/${owner.username}/${hidden.uuid}/Private`, + ]) { + const captured = await propfind(escape, holder.actor); + expect(captured.statusCode, escape).toBeGreaterThanOrEqual(400); + expect(captured.statusCode, escape).toBeLessThan(500); + expect(String(captured.body), escape).not.toContain('Private'); + } + }); + + it('does not leak one holder’s shares to another', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const outsider = await makeUser(); + await shareOneOfTwo(owner, holder); + + const captured = await propfind('/', outsider.actor); + expect(captured.statusCode).toBe(207); + expect(String(captured.body)).not.toContain(owner.username); + }); + + it('drops the sharer once the share is revoked', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const { shared } = await shareOneOfTwo(owner, holder); + + expect(String((await propfind('/', holder.actor)).body)).toContain( + `/${owner.username}/`, + ); + + await runWithContext({ actor: owner.actor as never }, () => + server.services.share.unshare(owner.actor as never, { + uid: shared.uuid, + recipient: { username: holder.username! }, + }), + ); + + const root = await propfind('/', holder.actor); + expect(String(root.body)).not.toContain(`/${owner.username}/`); + // And the level it used to stand in for is a plain 403 again. + const owned = await propfind(`/${owner.username}`, holder.actor); + expect(owned.statusCode).toBe(403); + }); + + it('refuses writes and reads aimed at the invented levels', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const { shared } = await shareOneOfTwo(owner, holder); + + const attempt = async (method: string, path: string) => { + const { res, captured } = makeRes(); + await runWithContext({ actor: undefined }, () => + dispatchMiddleware( + makeReq({ + method, + path, + actor: holder.actor, + headers: { 'content-length': '3' }, + }), + res, + noop, + ), + ); + return captured.statusCode; + }; + + // A collection, so GET is a 400 either way — what matters is that + // neither the owner level nor the uuid level accepts a write. + expect(await attempt('GET', `/${owner.username}`)).not.toBe(200); + expect( + await attempt('MKCOL', `/${owner.username}/intruder`), + ).toBeGreaterThanOrEqual(400); + expect( + await attempt( + 'MKCOL', + `/${owner.username}/${shared.uuid}/intruder`, + ), + ).toBeGreaterThanOrEqual(400); + expect( + await attempt('DELETE', `/${owner.username}/${shared.uuid}`), + ).toBeGreaterThanOrEqual(400); + + // Nothing landed in the owner's tree. + expect( + await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/intruder`, + ), + ).toBeFalsy(); + }); + }); }); diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index fa8a822e9..d4e2eca67 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -27,7 +27,23 @@ import { assertNotSuspended, assertVerifiedAccount, } from '../../core/http/middleware/gates.js'; -import type { PuterRouter } from '../../core/http/PuterRouter.js'; +import { + All, + Controller, + Copy, + Delete, + Get, + Head, + Lock, + Mkcol, + Move, + Options, + Propfind, + Proppatch, + Put, + Unlock, +} from '../../core/http/decorators.js'; +import type { RouteOptions } from '../../core/http/types.js'; import { verify as verifyOtp } from '../../services/auth/OTPUtil.js'; import { expandTildePath } from '../../services/fs/resolveNode.js'; import { @@ -49,12 +65,8 @@ import { refreshLock, } from './locks.js'; import { DAV_CONCURRENT, DAV_LIMIT } from '../fs/limits.js'; -import { - acquireConcurrent, - checkRateLimit, - computeNetworkFingerprint, -} from '../../core/http/middleware/rateLimit.js'; import { assertActorHasCredits } from '../../services/metering/enforcement.js'; +import type { ResolvedShare } from '../../services/share/ShareService.js'; const DAV_HEADERS = { DAV: '1, 2, ordered-collections', @@ -68,11 +80,41 @@ const ALLOW_METHODS = const MACOS_JUNK_REGEX = /(?:^\.DS_Store$|^\._)/; /** - * Verbs that move file content or duplicate it in the object store, and so are - * refused to an account with nothing left of its budget. HEAD is here with GET - * because a client asking for headers is a client about to fetch the body. + * Every DAV verb mounts on the same catch-all: on a DAV host the path _is_ the + * resource, so there is nothing else to route on. `{*splat}` rather than plain + * `*splat` because only the braced form also matches `/`, which is the first + * collection a client PROPFINDs. */ -const CREDIT_GATED_DAV_METHODS = new Set(['GET', 'HEAD', 'PUT', 'COPY']); +const DAV_ROUTE = '/{*splat}'; + +/** + * Everything the route table declares. The subdomain keeps DAV off every other + * host, and both limits key on the network fingerprint because nothing has + * authenticated yet when they run — which is the point of putting them here + * rather than inside the handler. A DAV client sends credentials on every + * request anyway, so there is no unauthenticated browsing phase that a per-user + * key would protect. + */ +const DAV_ROUTE_OPTIONS: RouteOptions = { + subdomain: 'dav', + rateLimit: DAV_LIMIT, + concurrent: DAV_CONCURRENT, +}; + +/** How long a browser may cache a DAV preflight. */ +const PREFLIGHT_MAX_AGE = '86400'; + +/** How many shared items the virtual share collections list. */ +const SHARE_LISTING_CAP = 200; + +/** What `#context` resolved for one request, handed to every verb handler. */ +interface DavContext { + actor: Actor; + /** The real path being addressed, with `~` and any share mask resolved. */ + davPath: string; + /** Lock token from `If:` / `Lock-Token:`, when the client sent one. */ + lockToken: string | null; +} /** * WebDAV controller — full RFC 4918 surface on the `dav.*` subdomain. @@ -85,99 +127,162 @@ const CREDIT_GATED_DAV_METHODS = new Set(['GET', 'HEAD', 'PUT', 'COPY']); * `-token` username for token-based auth). Falls back to the global authProbe's * `req.actor` if a session cookie is present. */ +@Controller() export class WebDAVController extends PuterController { - registerRoutes(router: PuterRouter): void { - // Single catch-all on the `dav` subdomain. We dispatch by req.method - // inside the handler because WebDAV uses non-standard HTTP verbs that - // Express doesn't have first-class router methods for in all versions. - // - // The rate limit is applied inside the handler rather than through - // `RouteOptions`. For a `use` mount the subdomain check lives in the - // handler wrapper, not in the middleware chain — so a `rateLimit` - // here would run for every request on every subdomain and count - // non-DAV traffic against the DAV budget. - router.use( - { subdomain: 'dav' }, - async (req: Request, res: Response, _next) => { - try { - if (!(await this.#admit(req, res))) return; - await this.#dispatch(req, res); - } catch (err) { - if (err instanceof HttpError) { - res.status(err.statusCode).send(err.message); - return; - } - console.error('[webdav] unhandled error', err); - res.status(500).send('Internal Server Error'); - } - // Don't call next — we always handle or error. - }, - ); + // -- Routes ------------------------------------------------------ + // + // One route per verb, every one of them the same catch-all. Declaration + // order is registration order: HEAD before GET, because express falls a + // HEAD request back onto a GET route when it meets one first, and `@All` + // last, because it matches any method. + // + // `DAV_ROUTE_OPTIONS` is the whole declarative half of the chain. The + // credential work happens in `#serve` instead, which is what keeps the + // limits ahead of it: HTTP Basic means a bcrypt compare per request, and a + // caller already over budget shouldn't get one. + + @Options(DAV_ROUTE, DAV_ROUTE_OPTIONS) + options(req: Request, res: Response): Promise { + // Before the auth gate, and only for a real preflight — see + // `answerPreflight`. + if (answerPreflight(req, res)) return Promise.resolve(); + return this.#serve(req, res, () => this.#options(res)); } + // GET/HEAD/PUT/COPY move file content or duplicate it in the object store, + // so they carry the same budget gate the FS routes declare with + // `requireCredits`: DAV serves the same files over a metered host, and + // leaving it out would make mounting the drive the way around enforcement. + // The verbs that only describe or remove things stay open, as they do over + // HTTP. HEAD is metered with GET because a client asking for headers is a + // client about to fetch the body. + // + // `requireCredits` itself can't be used for this: it reads `req.actor`, and + // on the DAV host nothing has authenticated by the time route options run. + + @Head(DAV_ROUTE, DAV_ROUTE_OPTIONS) + head(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#get(req, res, ctx, true), { + credits: true, + }); + } + + @Get(DAV_ROUTE, DAV_ROUTE_OPTIONS) + get(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#get(req, res, ctx, false), { + credits: true, + }); + } + + @Propfind(DAV_ROUTE, DAV_ROUTE_OPTIONS) + propfind(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#propfind(req, res, ctx)); + } + + @Proppatch(DAV_ROUTE, DAV_ROUTE_OPTIONS) + proppatch(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#proppatch(res, ctx)); + } + + @Mkcol(DAV_ROUTE, DAV_ROUTE_OPTIONS) + mkcol(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#mkcol(req, res, ctx)); + } + + @Put(DAV_ROUTE, DAV_ROUTE_OPTIONS) + put(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#put(req, res, ctx), { + credits: true, + }); + } + + @Delete(DAV_ROUTE, DAV_ROUTE_OPTIONS) + delete(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#delete(res, ctx)); + } + + @Copy(DAV_ROUTE, DAV_ROUTE_OPTIONS) + copy(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#copy(req, res, ctx), { + credits: true, + }); + } + + @Move(DAV_ROUTE, DAV_ROUTE_OPTIONS) + move(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#move(req, res, ctx)); + } + + @Lock(DAV_ROUTE, DAV_ROUTE_OPTIONS) + lock(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#lock(req, res, ctx)); + } + + @Unlock(DAV_ROUTE, DAV_ROUTE_OPTIONS) + unlock(req: Request, res: Response): Promise { + return this.#serve(req, res, (ctx) => this.#unlock(req, res, ctx)); + } + + /** POST, TRACE, an extension verb we don't implement. */ + @All(DAV_ROUTE, DAV_ROUTE_OPTIONS) + unsupported(req: Request, res: Response): Promise { + return this.#serve(req, res, () => { + res.status(405) + .set('Allow', ALLOW_METHODS) + .send('Method Not Allowed'); + }); + } + + // -- Request plumbing --------------------------------------------- + /** - * Rate + concurrency gate for the whole DAV surface. Returns false when the - * request was rejected (429 already sent). - * - * Runs before `#dispatch` authenticates, so it keys on the network - * fingerprint rather than an actor. That is the coarser bucket, but a DAV - * client sends credentials on every request anyway — there is no - * unauthenticated browsing phase to protect a per-user key from. + * Authenticate, gate, resolve the target path, and run `handler` — or + * answer the request instead when any of that fails. A thrown `HttpError` + * becomes the plain-text reply a DAV client reads; nothing calls `next`, + * because a request that gets here is ours to answer. */ - async #admit(req: Request, res: Response): Promise { - const key = computeNetworkFingerprint(req); - if ( - !(await checkRateLimit( - `${DAV_LIMIT.scope}:${key}`, - DAV_LIMIT.limit, - DAV_LIMIT.window, - )) - ) { - res.status(429).send('Too many requests.'); - return false; + async #serve( + req: Request, + res: Response, + handler: (ctx: DavContext) => void | Promise, + opts: { credits?: boolean } = {}, + ): Promise { + try { + const ctx = await this.#context(req, res, opts); + if (!ctx) return; // 401 already sent + await handler(ctx); + } catch (err) { + sendDavError(res, err); } - const slot = await acquireConcurrent( - `${DAV_CONCURRENT.scope}:${key}`, - DAV_CONCURRENT.limit, - ); - if (!slot.ok) { - res.status(429).send('Too many concurrent requests.'); - return false; - } - // `finish` and `close` can both fire; release is once-only. - res.once('finish', () => void slot.release()); - res.once('close', () => void slot.release()); - return true; } - async #dispatch(req: Request, res: Response): Promise { - // Authenticate + /** The actor, path and lock token for one request; null once 401'd. */ + async #context( + req: Request, + res: Response, + opts: { credits?: boolean }, + ): Promise { const actor = await this.#resolveActor(req, res); - if (!actor) return; // 401 already sent + if (!actor) return null; - // Apply the same suspension + pending-verification gates every other - // authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`. - // WebDAV dispatches every method off a single `router.use` with no route - // options, so that middleware is never inserted into its chain — without - // these calls a suspended account (or one still pending email / phone / - // card verification) could read, write, and delete its entire filesystem - // over the `dav` subdomain, bypassing the gates. Both throw a 403 - // HttpError, surfaced by the catch in registerRoutes. + // The same suspension + pending-verification gates every other + // authenticated route gets from `requireAuthGate` / + // `requireVerifiedAccount`. DAV authenticates itself rather than + // through the auth probe, so that middleware is never in its chain — + // without these calls a suspended account (or one still pending email / + // phone / card verification) could read, write, and delete its entire + // filesystem over the `dav` subdomain, bypassing the gates. assertNotSuspended(actor.user); assertVerifiedAccount(actor.user); - // DAV authenticates here rather than in the auth probe, so the actor - // was absent when the request context snapshotted `req.actor`. Set it - // now: shared-path masking (and anything else downstream that asks the - // context who is acting) is blind without it. + // The actor was absent when the auth probe ran, so anything that reads + // it off the request or the context — shared-path masking, egress + // metering, which bills the `dav` host — is blind until it's published + // here. + req.actor = actor; if (Context.current()) Context.set('actor', actor); - // And the same budget gate the FS routes declare with - // `requireCredits`, for the verbs that move content — DAV serves the - // same files over a metered host, so leaving it out would make mounting - // the drive the way around enforcement. The verbs that only describe or - // remove things stay open, as they do over HTTP. - if (CREDIT_GATED_DAV_METHODS.has(req.method.toUpperCase())) { + if (opts.credits) { await assertActorHasCredits( this.services.metering, actor, @@ -193,47 +298,15 @@ export class WebDAVController extends PuterController { actor, expandTildePath(decodeURIComponent(req.path), actor.user.username), ); - const redis = this.clients.redis; - const lockToken = extractLockToken( - (req.headers['if'] as string | undefined) ?? - (req.headers['lock-token'] as string | undefined), - ); - switch (req.method.toUpperCase()) { - case 'OPTIONS': - return this.#options(res); - case 'HEAD': - case 'GET': - return this.#get( - req, - res, - actor, - davPath, - req.method === 'HEAD', - ); - case 'PROPFIND': - return this.#propfind(req, res, actor, davPath); - case 'PROPPATCH': - return this.#proppatch(res, davPath, redis, lockToken); - case 'MKCOL': - return this.#mkcol(req, res, actor, davPath, redis, lockToken); - case 'PUT': - return this.#put(req, res, actor, davPath, redis, lockToken); - case 'DELETE': - return this.#delete(res, actor, davPath, redis, lockToken); - case 'COPY': - return this.#copy(req, res, actor, davPath, redis, lockToken); - case 'MOVE': - return this.#move(req, res, actor, davPath, redis, lockToken); - case 'LOCK': - return this.#lock(req, res, actor, davPath, redis, lockToken); - case 'UNLOCK': - return this.#unlock(req, res, davPath, redis); - default: - res.status(405) - .set('Allow', ALLOW_METHODS) - .send('Method Not Allowed'); - } + return { + actor, + davPath, + lockToken: extractLockToken( + (req.headers['if'] as string | undefined) ?? + (req.headers['lock-token'] as string | undefined), + ), + }; } // -- Auth --------------------------------------------------------- @@ -362,8 +435,7 @@ export class WebDAVController extends PuterController { async #get( req: Request, res: Response, - actor: Actor, - davPath: string, + { actor, davPath }: DavContext, headOnly: boolean, ): Promise { const entry = await this.stores.fsEntry.getEntryByPath(davPath); @@ -411,47 +483,47 @@ export class WebDAVController extends PuterController { async #propfind( req: Request, res: Response, - actor: Actor, - davPath: string, + { actor, davPath }: DavContext, ): Promise { const depth = req.headers.depth ?? '1'; - const entry = - davPath === '/' - ? null // root always exists - : await this.stores.fsEntry.getEntryByPath(davPath); - if (davPath !== '/' && !entry) { - // `//` — the parent of a share root — is not a real - // path: the uuid stands in for the owner's folder, which the - // recipient cannot see. A client walking up from a share (or down - // toward one, as the Windows redirector does segment by segment) - // lands here, so answer with a virtual collection whose only - // member is the share root, rather than a dead end. - const virtual = await this.#shareRootParentPropfind( - actor, - davPath, - depth, - ); - if (virtual) { - res.status(207) - .set({ 'Content-Type': 'application/xml; charset=utf-8' }) - .send(wrapMultistatus(virtual.join('\n'))); - return; - } + if (davPath === '/') { + await this.#assertRead(actor, davPath); + this.#sendMultistatus(res, await this.#rootPropfind(actor, depth)); + return; + } + + const entry = await this.stores.fsEntry.getEntryByPath(davPath); + + // The two levels a share mask invents — `/` and + // `//` — have no entry behind them, so they answer as + // virtual collections. Tried before the ACL check because `/` + // _is_ a real path (the owner's home directory) that the recipient + // can't read: a 403 there would hide every share they do have. + const virtual = + (await this.#shareOwnerPropfind(actor, davPath, entry, depth)) ?? + (entry + ? null + : await this.#shareRootParentPropfind(actor, davPath, depth)); + if (virtual) { + this.#sendMultistatus(res, virtual); + return; + } + + if (!entry) { throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' }); } await this.#assertRead(actor, davPath); - const isDir = davPath === '/' || !!entry?.isDir; // `davPath` is the resolved real path; the href has to be the masked // one, like every child below it, or the self-entry names the owner's // real folder. const responses = [ - propfindEntry(entry ? maskEntryPath(entry) : davPath, entry, isDir), + propfindEntry(maskEntryPath(entry), entry, entry.isDir), ]; - if (depth !== '0' && isDir && entry) { + if (depth !== '0' && entry.isDir) { const children = await this.services.fs.listDirectory( entry.uuid, {}, @@ -461,22 +533,122 @@ export class WebDAVController extends PuterController { propfindEntry(maskEntryPath(child), child, child.isDir), ); } - } else if (depth !== '0' && davPath === '/') { - // Root: list top-level user directories - const rootEntry = await this.stores.fsEntry.getEntryByPath( - `/${actor.user!.username}`, + } + + this.#sendMultistatus(res, responses); + } + + /** + * The root collection: the caller's own home directory, plus one collection + * per person who has shared something with them. + * + * A share is addressed `///`, where the uuid stands in + * for the folder the owner keeps it in. Only the `` segment is a + * child of the root, so without this level nothing shared is reachable from + * a mount at all — which is why a Finder mount showed only the caller's own + * files. + */ + async #rootPropfind(actor: Actor, depth: string | string[]) { + const responses = [propfindEntry('/', null, true)]; + if (depth === '0') return responses; + + const home = await this.stores.fsEntry.getEntryByPath( + `/${actor.user!.username}`, + ); + if (home) { + responses.push( + propfindEntry(maskEntryPath(home), home, home.isDir), ); - if (rootEntry) { + } + + const owners = new Set(); + for (const share of await this.#sharedWithMe(actor)) { + const owner = share.owner?.username; + // Self-owned entries never appear in the share index, but the home + // directory above already covers them if one ever did. + if (owner && owner !== actor.user!.username) owners.add(owner); + } + for (const owner of owners) { + responses.push(propfindEntry(`/${owner}`, { name: owner }, true)); + } + + return responses; + } + + /** + * PROPFIND responses for the virtual collection at `/`, listing one + * child per item that owner shared with the actor, or null when `davPath` + * isn't a bare username, is the actor's own, is readable for real, or the + * actor holds nothing of theirs — the caller then continues down the normal + * path and gets the 403 or 404 it would otherwise have given. + * + * Children are the `` level rather than the shared item itself: the + * item's own href sits one segment deeper, and a collection may only report + * its direct members. Nothing here reaches past that level, so `Depth: + * infinity` on a sharer's collection still enumerates only what they + * shared, and everything below it goes through the ordinary resolve + ACL + * path. + */ + async #shareOwnerPropfind( + actor: Actor, + davPath: string, + entry: FSEntry | null, + depth: string | string[], + ): Promise { + const segments = davPath.split('/').filter(Boolean); + if (segments.length !== 1) return null; + const owner = segments[0]!; + if (owner === actor.user?.username) return null; + // Every provisioned user's home directory is a real entry and a + // sharer is necessarily provisioned, so no directory here means no + // share to stand in for. Checked before the share lookup so an + // unknown name costs a 404 rather than a listing. + if (!entry?.isDir) return null; + // An owner who shared their whole home directory has a real listing + // here, and it beats the virtual one. + if (await this.#canRead(actor, davPath)) return null; + + const shares = (await this.#sharedWithMe(actor)).filter( + (share) => share.owner?.username === owner, + ); + if (shares.length === 0) return null; + + const responses = [propfindEntry(davPath, { name: owner }, true)]; + if (depth !== '0') { + for (const share of shares) { responses.push( propfindEntry( - maskEntryPath(rootEntry), - rootEntry, - rootEntry.isDir, + `/${owner}/${share.entryUid}`, + { + name: share.name, + uuid: share.entryUid, + modified: share.modified, + }, + true, ), ); } } + return responses; + } + /** + * What has been shared with the actor, for the virtual collections above. + * One page: WebDAV has no way to ask for the next one, so a caller holding + * more shares than {@link SHARE_LISTING_CAP} sees the oldest that many. + * + * Listing also teaches the request's path masker every root it returns, so + * an entry reached through one keeps that masked prefix on the way back out + * instead of being masked against itself. + */ + async #sharedWithMe(actor: Actor): Promise { + const page = await this.services.share.listSharedWithMe(actor, { + limit: SHARE_LISTING_CAP, + }); + return page.items; + } + + #sendMultistatus(res: Response, responses: string[]): void { res.status(207) .set({ 'Content-Type': 'application/xml; charset=utf-8' }) .send(wrapMultistatus(responses.join('\n'))); @@ -486,16 +658,10 @@ export class WebDAVController extends PuterController { async #proppatch( res: Response, - davPath: string, - redis: unknown, - lockToken: string | null, + { davPath, lockToken }: DavContext, ): Promise { if ( - !(await hasWritePermission( - redis as import('ioredis').Cluster, - davPath, - lockToken, - )) + !(await hasWritePermission(this.clients.redis, davPath, lockToken)) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } @@ -511,10 +677,7 @@ export class WebDAVController extends PuterController { async #mkcol( req: Request, res: Response, - actor: Actor, - davPath: string, - redis: unknown, - lockToken: string | null, + { actor, davPath, lockToken }: DavContext, ): Promise { if (davPath === '/') throw new HttpError(403, 'Cannot create at root', { @@ -529,11 +692,7 @@ export class WebDAVController extends PuterController { }); } if ( - !(await hasWritePermission( - redis as import('ioredis').Cluster, - davPath, - lockToken, - )) + !(await hasWritePermission(this.clients.redis, davPath, lockToken)) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } @@ -561,10 +720,7 @@ export class WebDAVController extends PuterController { async #put( req: Request, res: Response, - actor: Actor, - davPath: string, - redis: unknown, - lockToken: string | null, + { actor, davPath, lockToken }: DavContext, ): Promise { const name = pathPosix.basename(davPath); if (MACOS_JUNK_REGEX.test(name)) { @@ -572,11 +728,7 @@ export class WebDAVController extends PuterController { return; } if ( - !(await hasWritePermission( - redis as import('ioredis').Cluster, - davPath, - lockToken, - )) + !(await hasWritePermission(this.clients.redis, davPath, lockToken)) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } @@ -634,17 +786,10 @@ export class WebDAVController extends PuterController { async #delete( res: Response, - actor: Actor, - davPath: string, - redis: unknown, - lockToken: string | null, + { actor, davPath, lockToken }: DavContext, ): Promise { if ( - !(await hasWritePermission( - redis as import('ioredis').Cluster, - davPath, - lockToken, - )) + !(await hasWritePermission(this.clients.redis, davPath, lockToken)) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } @@ -665,18 +810,11 @@ export class WebDAVController extends PuterController { async #copy( req: Request, res: Response, - actor: Actor, - davPath: string, - redis: unknown, - lockToken: string | null, + { actor, davPath, lockToken }: DavContext, ): Promise { const destPath = await this.#parseDestination(req); if ( - !(await hasWritePermission( - redis as import('ioredis').Cluster, - destPath, - lockToken, - )) + !(await hasWritePermission(this.clients.redis, destPath, lockToken)) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } @@ -723,13 +861,10 @@ export class WebDAVController extends PuterController { async #move( req: Request, res: Response, - actor: Actor, - davPath: string, - redis: unknown, - lockToken: string | null, + { actor, davPath, lockToken }: DavContext, ): Promise { const destPath = await this.#parseDestination(req); - const r = redis as import('ioredis').Cluster; + const r = this.clients.redis; if (!(await hasWritePermission(r, davPath, lockToken))) throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); if (!(await hasWritePermission(r, destPath, lockToken))) @@ -779,12 +914,9 @@ export class WebDAVController extends PuterController { async #lock( req: Request, res: Response, - actor: Actor, - davPath: string, - redis: unknown, - headerToken: string | null, + { actor, davPath, lockToken: headerToken }: DavContext, ): Promise { - const r = redis as import('ioredis').Cluster; + const r = this.clients.redis; // ACL must succeed before any lock state is touched — otherwise // an authenticated user could lock paths they don't own (e.g. `/`) @@ -846,10 +978,9 @@ export class WebDAVController extends PuterController { async #unlock( req: Request, res: Response, - davPath: string, - redis: unknown, + { davPath }: DavContext, ): Promise { - const r = redis as import('ioredis').Cluster; + const r = this.clients.redis; const tokenHeader = req.headers['lock-token'] as string | undefined; const token = extractLockToken(tokenHeader); if (!token) @@ -906,7 +1037,10 @@ export class WebDAVController extends PuterController { if (!allowed) return null; const maskedRoot = `/${parsed.ownerUsername}/${root.uuid}/${root.name}`; - const responses = [propfindEntry(davPath, null, true)]; + // The uuid segment has no name of its own, so it borrows the shared + // item's — a client that reads `displayname` shows something + // recognizable rather than a bare uuid. + const responses = [propfindEntry(davPath, { name: root.name }, true)]; if (depth !== '0') { responses.push(propfindEntry(maskedRoot, root, root.isDir)); } @@ -915,13 +1049,19 @@ export class WebDAVController extends PuterController { // -- ACL helpers ------------------------------------------------- + async #canRead(actor: Actor, path: string): Promise { + return await this.services.acl.check( + actor, + { + path, + resolveAncestors: () => this.services.fs.getAncestorChain(path), + }, + 'read', + ); + } + async #assertRead(actor: Actor, path: string): Promise { - const descriptor = { - path, - resolveAncestors: () => this.services.fs.getAncestorChain(path), - }; - const ok = await this.services.acl.check(actor, descriptor, 'read'); - if (!ok) + if (!(await this.#canRead(actor, path))) throw new HttpError(403, 'Permission denied', { legacyCode: 'permission_denied', }); @@ -989,6 +1129,53 @@ export class WebDAVController extends PuterController { } } +// -- Middleware helpers ----------------------------------------------- + +/** + * Answer a CORS preflight before anything can reject it. True when this request + * was one and has been answered. + * + * A browser sends a credential-less `OPTIONS` ahead of any cross-origin + * PROPFIND/GET/PUT and treats a non-2xx reply as a failed preflight, never + * sending the real request. The authenticated OPTIONS path answers 401, which + * blocked every browser DAV client — the VS Code app's mount included — before + * it could present a token. + * + * Only an actual preflight is answered here: it's the one OPTIONS that carries + * `Access-Control-Request-Method`. A native client's plain OPTIONS still goes + * through auth, so macOS keeps getting the 401-with-`DAV:` reply it opens a + * mount with. + */ +function answerPreflight(req: Request, res: Response): boolean { + if (!req.headers.origin || !req.headers['access-control-request-method']) { + return false; + } + // `Allow-Origin`/`-Methods`/`-Headers` were set on the way in by the + // server's CORS middleware. `Max-Age` is what belongs here specifically: + // without it a browser re-preflights every URL every few seconds, doubling + // the request count of a mount and its share of the DAV rate limit. + res.status(200) + .set({ + ...DAV_HEADERS, + Allow: ALLOW_METHODS, + 'Access-Control-Max-Age': PREFLIGHT_MAX_AGE, + 'Content-Length': '0', + }) + .end(); + return true; +} + +/** DAV clients read a status and a plain-text reason, not our JSON error shape. */ +function sendDavError(res: Response, err: unknown): void { + if (res.headersSent) return; + if (err instanceof HttpError) { + res.status(err.statusCode).send(err.message); + return; + } + console.error('[webdav] unhandled error', err); + res.status(500).send('Internal Server Error'); +} + // -- XML helpers ------------------------------------------------------ function escapeXml(text: string): string { @@ -1028,9 +1215,18 @@ function entryEtag(uid: string, ts: number | string): string { return `"${uid}-${toEpochSeconds(ts)}"`; } +/** + * What a `` needs to describe one resource. An {@link FSEntry} + * satisfies it; the virtual collections a share mask invents pass whatever they + * know, which is sometimes only a name. + */ +type PropfindTarget = Partial< + Pick +>; + function propfindEntry( href: string, - entry: FSEntry | null, + entry: PropfindTarget | null, isDir: boolean, ): string { const encodedHref = @@ -1056,7 +1252,7 @@ function propfindEntry( if (!isDir && entry) { props += `\n ${entry.size ?? 0}`; - const mime = mimeFromExt(pathPosix.extname(entry.name)); + const mime = mimeFromExt(pathPosix.extname(name)); props += `\n ${escapeXml(mime)}`; } diff --git a/src/backend/core/http/decorators.test.ts b/src/backend/core/http/decorators.test.ts new file mode 100644 index 000000000..6957f9023 --- /dev/null +++ b/src/backend/core/http/decorators.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { Controller, Get, Propfind } from './decorators.ts'; +import { PuterRouter } from './PuterRouter.ts'; + +interface Registrable { + registerRoutes: (router: PuterRouter) => void; +} + +const collect = (controller: Registrable) => { + const router = new PuterRouter(); + controller.registerRoutes(router); + return router.routes; +}; + +@Controller('/demo') +class DemoController { + @Get('/thing') + thing() {} + + @Propfind('/{*splat}', { subdomain: 'dav' }) + browse() {} +} + +describe('route decorators', () => { + it('collects each decorated method once, in declaration order', () => { + const routes = collect(new DemoController() as unknown as Registrable); + expect(routes.map((r) => r.method)).toEqual(['get', 'propfind']); + expect(routes[1]!.options).toEqual({ subdomain: 'dav' }); + }); + + it('does not re-collect when a second instance is constructed', () => { + // Method initializers run per instance but collect onto the shared + // prototype, so without a guard a process that builds two servers + // registers every route on the second one twice over. + const first = collect(new DemoController() as unknown as Registrable); + const second = collect(new DemoController() as unknown as Registrable); + const third = collect(new DemoController() as unknown as Registrable); + expect(second).toHaveLength(first.length); + expect(third).toHaveLength(first.length); + expect(third.map((r) => r.method)).toEqual(['get', 'propfind']); + }); + + it('binds handlers to the instance that registered them', () => { + @Controller() + class Bound { + marker = 'mine'; + seen: string | undefined; + + @Get('/x') + handle() { + this.seen = this.marker; + } + } + + const instance = new Bound(); + const [route] = collect(instance as unknown as Registrable); + (route!.handler as unknown as () => void)(); + expect(instance.seen).toBe('mine'); + }); +}); diff --git a/src/backend/core/http/decorators.ts b/src/backend/core/http/decorators.ts index a3586ce65..63babb7fe 100644 --- a/src/backend/core/http/decorators.ts +++ b/src/backend/core/http/decorators.ts @@ -143,7 +143,21 @@ const createMethodDecorator = (method: Exclude) => { const proto = Object.getPrototypeOf( this as object, ) as DecoratedPrototype; - getOrInitRoutes(proto).push({ + const routes = getOrInitRoutes(proto); + // Initializers run once per *instance* but collect onto the + // shared prototype, so a process that builds a second server + // (every multi-server test file) would otherwise register + // every route twice. The decorated method and the path literal + // are the same references each time, so identity recognizes a + // repeat without comparing option objects. + const already = routes.some( + (r) => + r.handler === (target as unknown as RequestHandler) && + r.method === method && + r.path === path, + ); + if (already) return; + routes.push({ method, path, options, diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index 37f32f386..8afdf8b7b 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -205,6 +205,73 @@ describe('PuterServer host header validation', () => { expect(res.headers['dav']).toContain('2'); }); + // The DAV controller declares one route per verb, so these check what only + // a real server can: that express materializes the WebDAV verbs, that the + // catch-all matches the root collection as well as deep paths, and that it + // stays on the `dav` subdomain. + it('routes every WebDAV verb on the dav subdomain, root included', async () => { + const dav = { host: `dav.puter.localhost:${port}` }; + for (const [method, path] of [ + ['PROPFIND', '/'], + ['PROPFIND', '/some-user/Documents'], + ['PROPPATCH', '/some-user/a.txt'], + ['MKCOL', '/some-user/new-folder'], + ['LOCK', '/some-user/a.txt'], + // Not a verb the controller implements; the catch-all that answers + // 405 has to authenticate first, like every other route. + ['SEARCH', '/some-user'], + ] as const) { + const res = await request(path, dav, method); + // Unauthenticated, so the reply is the auth challenge — what + // matters is that it came from the DAV controller and not from the + // 404 handler. + expect(res.status, `${method} ${path}`).toBe(401); + expect(res.headers['www-authenticate']).toContain('Basic'); + } + }); + + it('leaves WebDAV verbs on other hosts alone', async () => { + // The DAV catch-all matches any path, so the subdomain gate is the only + // thing keeping it off the main domain. + const res = await request( + '/', + { host: `puter.localhost:${port}` }, + 'PROPFIND', + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(405); + expect(res.headers['www-authenticate']).toBeUndefined(); + }); + + it('answers a browser CORS preflight on the dav subdomain', async () => { + // Browsers send this credential-less probe before any cross-origin DAV + // verb and abandon the request unless it comes back 2xx. + const res = await request( + '/some-user/.vscode/settings.json', + { + host: `dav.puter.localhost:${port}`, + origin: 'https://code.puter.localhost', + 'access-control-request-method': 'PROPFIND', + 'access-control-request-headers': 'authorization,depth', + }, + 'OPTIONS', + ); + expect(res.status).toBe(200); + expect(res.headers['dav']).toContain('1'); + expect(res.headers['access-control-max-age']).toBe('86400'); + expect(String(res.headers['access-control-allow-headers'])).toContain( + 'Depth', + ); + expect(res.headers['access-control-allow-origin']).toBe( + 'https://code.puter.localhost', + ); + // A browser client reads ETags and lock tokens off the reply, so they + // have to be exposed to it. + expect(String(res.headers['access-control-expose-headers'])).toContain( + 'ETag', + ); + }); + it('still short-circuits OPTIONS preflight off the dav subdomain', async () => { const res = await request( '/some-path', diff --git a/src/backend/server.ts b/src/backend/server.ts index bf9883b08..2e604a8b4 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -681,10 +681,31 @@ export class PuterServer { 'Overwrite', 'If', 'Lock-Token', + 'Timeout', + 'X-Expected-Entity-Length', 'DAV', 'stripe-signature', ].join(', '); + // What a browser DAV client is allowed to read back off a response. + // Everything below is a header the WebDAV controller sets and a client + // acts on: without this list `fetch` hands the page a response whose + // ETag, lock token and content range are all invisible, so it can't + // cache, lock, or resume anything. + const davExposedHeaders = [ + 'DAV', + 'MS-Author-Via', + 'Allow', + 'ETag', + 'Last-Modified', + 'Content-Length', + 'Content-Range', + 'Accept-Ranges', + 'Location', + 'Lock-Token', + 'WWW-Authenticate', + ].join(', '); + this.#app.use((req, res, next) => { const origin = req.headers.origin; const subdomain = activeSubdomain(req); @@ -700,6 +721,10 @@ export class PuterServer { res.setHeader('Access-Control-Allow-Credentials', 'true'); } else if (subdomain === 'dav') { res.setHeader('Access-Control-Allow-Credentials', 'false'); + res.setHeader( + 'Access-Control-Expose-Headers', + davExposedHeaders, + ); } res.setHeader('Access-Control-Allow-Methods', allowedMethods);