diff --git a/src/backend/drivers/subdomain/SubdomainDriver.test.ts b/src/backend/drivers/subdomain/SubdomainDriver.test.ts index 45ba572c7..b533f73eb 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.test.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.test.ts @@ -545,6 +545,112 @@ describe('SubdomainDriver.update', () => { }); }); +// ── publishing someone else's directory ───────────────────────────── +// +// Hosting serves the root dir with the ACL bypassed, so pointing a subdomain +// at a directory publishes it. A `write` share does not carry that decision: +// the row would live under the recipient's account, cover files the owner adds +// later, and show up in nothing the owner can list. + +const shareDir = async ( + owner: { actor: Actor }, + holder: { actor: Actor }, + path: string, + mode: 'read' | 'write' | 'manage', +): Promise => { + const entry = (await server.stores.fsEntry.getEntryByPath(path))!; + await server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: entry.path, + resolveAncestors: () => + server.services.fs.getAncestorChain(entry.path), + }, + mode, + ); +}; + +describe('SubdomainDriver root_dir publishing rights', () => { + it('refuses a directory shared with the actor at write', async () => { + const owner = await makeUser(); + const writer = await makeUser(); + const shared = `/${owner.actor.user!.username}/Documents`; + await shareDir(owner, writer, shared, 'write'); + + await expect( + withActor(writer.actor, () => + driver.create({ + object: { + subdomain: uniqueSubdomain('borrowed'), + root_dir: shared, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + // "Can edit & share" is the owner delegating the decision. + it('accepts a directory shared with the actor at manage', async () => { + const owner = await makeUser(); + const manager = await makeUser(); + const shared = `/${owner.actor.user!.username}/Documents`; + await shareDir(owner, manager, shared, 'manage'); + const sub = uniqueSubdomain('delegated'); + + const created = (await withActor(manager.actor, () => + driver.create({ + object: { subdomain: sub, root_dir: shared }, + }), + )) as Record | null; + + expect(created?.subdomain).toBe(sub); + }); + + it('refuses to repoint an existing subdomain at a write-shared directory', async () => { + const owner = await makeUser(); + const writer = await makeUser(); + const shared = `/${owner.actor.user!.username}/Documents`; + await shareDir(owner, writer, shared, 'write'); + + const created = (await withActor(writer.actor, () => + driver.create({ + object: { + subdomain: uniqueSubdomain('repoint'), + root_dir: `/${writer.actor.user!.username}/Public`, + }, + }), + )) as Record; + + await expect( + withActor(writer.actor, () => + driver.update({ + uid: created.uid, + object: { root_dir: shared }, + }), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + // The owner's own path still only needs write, so an app hosting a + // directory its user gave it keeps working. + it("still accepts the actor's own directory", async () => { + const { actor } = await makeUser(); + const sub = uniqueSubdomain('own'); + + const created = (await withActor(actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${actor.user!.username}/Documents`, + }, + }), + )) as Record | null; + + expect(created?.subdomain).toBe(sub); + }); +}); + // ── upsert ────────────────────────────────────────────────────────── describe('SubdomainDriver.upsert', () => { diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts index 7e53ab6bc..7fdfba1db 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.ts @@ -30,6 +30,7 @@ import type { Actor } from '../../core/actor.js'; import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import type { UserRow } from '../../stores/user/UserStore.js'; +import { MANAGE_PERM_PREFIX } from '../../services/permission/consts.js'; import { expandTildePath } from '../../services/fs/resolveNode.js'; import { isUniqueViolation } from '../../util/dbError.js'; import { buildHostedSubdomainIndexUrlCandidates } from '../../util/hostedAppBacking.js'; @@ -176,7 +177,7 @@ export class SubdomainDriver extends PuterDriver { legacyCode: 'bad_request', }); } - await this.services.fs.checkFSAccess(entry, actor); + await this.#checkPublishAccess(entry, actor); // A name some other user's app still points at is not free either. // Deleting a hosted subdomain leaves the app row's `index_url` intact, @@ -367,7 +368,7 @@ export class SubdomainDriver extends PuterDriver { }); } if (rootDirId !== (row.root_dir_id ?? null)) { - await this.services.fs.checkFSAccess(entry, actor); + await this.#checkPublishAccess(entry, actor); } patch.root_dir_id = rootDirId; } @@ -574,6 +575,39 @@ export class SubdomainDriver extends PuterDriver { throw new HttpError(403, 'Access denied', { legacyCode: 'forbidden' }); } + /** + * Gate on pointing a subdomain at a directory. Hosting serves everything + * under the root dir with the ACL bypassed, so publishing makes that + * subtree world-readable — for good, and including whatever is written + * there later. That is the owner's call to make. + * + * `write` is not it. A shared folder's writer would be exposing the owner's + * tree, the row belongs to the writer's account, and nothing the owner can + * list would show it. `manage` — "Can edit & share" — is the grant that + * delegates the decision. + */ + async #checkPublishAccess(entry: FSEntry, actor: Actor): Promise { + // First, because this is the check that masks a directory the caller + // cannot see at all as a 404. + await this.services.fs.checkFSAccess(entry, actor); + if (entry.userId === actor.user?.id) return; + try { + await this.services.fs.checkFSAccess( + entry, + actor, + MANAGE_PERM_PREFIX, + ); + } catch { + // Reached only with access to the directory, so its existence is + // not news and there is nothing to mask. + throw new HttpError( + 403, + 'Publishing this directory is up to whoever owns it', + { legacyCode: 'access_denied' }, + ); + } + } + async #checkWriteAccess( row: Record, actor: Actor, diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index 69a807bc6..59f76927f 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -38,8 +38,8 @@ Who to share with. A string containing `@` is treated as an email address, and a How much access to grant. Defaults to `'read'`. - `'read'` - Read the item. -- `'write'` - Read and change the item. Does **not** allow re-sharing it. -- `'manage'` - Everything `'write'` allows, plus re-sharing the item with other people. +- `'write'` - Read and change the item. Does **not** allow re-sharing it, or publishing a directory as a website. +- `'manage'` - Everything `'write'` allows, plus re-sharing the item with other people and publishing a shared directory as a website. - `'list'`, `'see'` - Weaker than `read`; useful for making an item discoverable without exposing its contents. #### `options` (Object) (optional) diff --git a/src/docs/src/Hosting/create.md b/src/docs/src/Hosting/create.md index 9f8674718..0f5811142 100755 --- a/src/docs/src/Hosting/create.md +++ b/src/docs/src/Hosting/create.md @@ -23,6 +23,8 @@ A string containing the name of the subdomain you want to create. A string containing the path to the directory you want to serve. +The directory must be one you own. Hosting serves everything under it publicly, including files added later, so a directory someone shared with you can only be published if they gave you `manage` access — [`share()`](/FS/share/) calls that level "Can edit & share". + #### `options` (Object) (optional) Alternative way to create hosting via options. diff --git a/src/gui/src/helpers/generateFileContextMenu.js b/src/gui/src/helpers/generateFileContextMenu.js index bf26a017d..e0b34e07a 100644 --- a/src/gui/src/helpers/generateFileContextMenu.js +++ b/src/gui/src/helpers/generateFileContextMenu.js @@ -123,7 +123,10 @@ const generate_file_context_menu = async function (options) { if ( !is_trashed && !is_trash && fsentry.is_dir ) { menu_items.push({ html: i18n('publish_as_website'), - disabled: !fsentry.is_dir || fsentry.has_website, + // Publishing serves the folder to anyone, for good, so it takes the + // same own-it-or-`manage` right as sharing it does (SubdomainDriver + // enforces that). `write` on someone else's folder is not enough. + disabled: !fsentry.is_dir || fsentry.has_website || !may_share, onClick: async function () { await publish_as_website({ uid: fsentry.uid,