fix: vscode webdav (#3610)

This commit is contained in:
Daniel Salazar
2026-08-19 14:04:56 -07:00
committed by GitHub
parent a552ce0b87
commit 2dd7073034
6 changed files with 1070 additions and 257 deletions
@@ -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<boolean> => {
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<void> => {
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 owners 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<ReturnType<typeof makeUser>>,
to: Awaited<ReturnType<typeof makeUser>>,
) => {
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 owners 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(`<D:href>/${recipient.username}/</D:href>`);
expect(xml).toContain(`<D:href>/${owner.username}/</D:href>`);
});
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 sharers 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(`<D:href>/${owner.username}/</D:href>`);
// The `<uuid>` level, which the share root hangs off. It borrows
// the shared item's name so a client has something to show.
expect(xml).toContain(
`<D:href>/${owner.username}/${dirUuid}/</D:href>`,
);
expect(xml).toContain('<D:displayname>Secrets</D:displayname>');
// 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 sharers 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<ReturnType<typeof makeUser>>,
to: Awaited<ReturnType<typeof makeUser>>,
) => {
const mk = async (
method: string,
path: string,
extra: Record<string, string> = {},
) => {
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 owners 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 holders 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();
});
});
});
+427 -231
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
return this.#serve(req, res, (ctx) => this.#propfind(req, res, ctx));
}
@Proppatch(DAV_ROUTE, DAV_ROUTE_OPTIONS)
proppatch(req: Request, res: Response): Promise<void> {
return this.#serve(req, res, (ctx) => this.#proppatch(res, ctx));
}
@Mkcol(DAV_ROUTE, DAV_ROUTE_OPTIONS)
mkcol(req: Request, res: Response): Promise<void> {
return this.#serve(req, res, (ctx) => this.#mkcol(req, res, ctx));
}
@Put(DAV_ROUTE, DAV_ROUTE_OPTIONS)
put(req: Request, res: Response): Promise<void> {
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<void> {
return this.#serve(req, res, (ctx) => this.#delete(res, ctx));
}
@Copy(DAV_ROUTE, DAV_ROUTE_OPTIONS)
copy(req: Request, res: Response): Promise<void> {
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<void> {
return this.#serve(req, res, (ctx) => this.#move(req, res, ctx));
}
@Lock(DAV_ROUTE, DAV_ROUTE_OPTIONS)
lock(req: Request, res: Response): Promise<void> {
return this.#serve(req, res, (ctx) => this.#lock(req, res, ctx));
}
@Unlock(DAV_ROUTE, DAV_ROUTE_OPTIONS)
unlock(req: Request, res: Response): Promise<void> {
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<void> {
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<boolean> {
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<void>,
opts: { credits?: boolean } = {},
): Promise<void> {
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<void> {
// Authenticate
/** The actor, path and lock token for one request; null once 401'd. */
async #context(
req: Request,
res: Response,
opts: { credits?: boolean },
): Promise<DavContext | null> {
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<void> {
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<void> {
const depth = req.headers.depth ?? '1';
const entry =
davPath === '/'
? null // root always exists
: await this.stores.fsEntry.getEntryByPath(davPath);
if (davPath !== '/' && !entry) {
// `/<owner>/<uuid>` — 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 — `/<owner>` and
// `/<owner>/<uuid>` — have no entry behind them, so they answer as
// virtual collections. Tried before the ACL check because `/<owner>`
// _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 `/<owner>/<uuid>/<name>`, where the uuid stands in
* for the folder the owner keeps it in. Only the `<owner>` 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<string>();
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 `/<owner>`, 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 `<uuid>` 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<string[] | null> {
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<ResolvedShare[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
return await this.services.acl.check(
actor,
{
path,
resolveAncestors: () => this.services.fs.getAncestorChain(path),
},
'read',
);
}
async #assertRead(actor: Actor, path: string): Promise<void> {
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 `<D:response>` 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<FSEntry, 'name' | 'uuid' | 'modified' | 'created' | 'size'>
>;
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 <D:getcontentlength>${entry.size ?? 0}</D:getcontentlength>`;
const mime = mimeFromExt(pathPosix.extname(entry.name));
const mime = mimeFromExt(pathPosix.extname(name));
props += `\n <D:getcontenttype>${escapeXml(mime)}</D:getcontenttype>`;
}
+79
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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');
});
});
+15 -1
View File
@@ -143,7 +143,21 @@ const createMethodDecorator = (method: Exclude<RouteMethod, 'use'>) => {
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,
+67
View File
@@ -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',
+25
View File
@@ -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);