mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-10 07:15:53 +00:00
fix: publishing a directory takes ownership, not write (PUT-1619) (#3654)
Creating a hosted subdomain gated `root_dir` on `write`, and hosting serves everything under that directory with the ACL deliberately bypassed. So a recipient of a `write` share could point a `*.puter.site` subdomain at the owner's folder and make the subtree world-readable — continuously, covering files the owner added later, with the row under the recipient's account where nothing the owner can list would show it. `update` had the same gate for a changed `root_dir`. `#checkPublishAccess` now decides both: the actor's own tree still takes `write`, anyone else's takes `manage` — "Can edit & share", the level that delegates the decision. Keyed on who owns the entry rather than asking for `manage` outright, which is what the ticket proposed. `manage`'s is-owner implicator declines to answer for app actors, so a flat `manage` would refuse every app publishing a directory its user handed it, with no way for the app to obtain the grant. The write check still runs first — it is what masks a directory the caller cannot see as a 404 — and `manage` satisfies every lower mode, so the order costs a manage-holder nothing. The GUI's Publish As Website item reuses the own-it-or-`manage` answer it already computes for sharing, so it is not offered where this would refuse. Docs state the rule on `hosting.create()` and in `share()`'s level list. Regression tests fail without the driver change: a write-share recipient is refused on create and on repointing an existing subdomain, while `manage` and the actor's own directory are accepted.
This commit is contained in:
@@ -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<void> => {
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
|
||||
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<string, unknown> | null;
|
||||
|
||||
expect(created?.subdomain).toBe(sub);
|
||||
});
|
||||
});
|
||||
|
||||
// ── upsert ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('SubdomainDriver.upsert', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<string, unknown>,
|
||||
actor: Actor,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user