fix: publishing a directory takes ownership, not write (PUT-1619) (#3654)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

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:
Juan Fernando Castro
2026-08-29 01:46:14 -07:00
committed by GitHub
parent d5e9c14b0d
commit 81d700d146
5 changed files with 150 additions and 5 deletions
@@ -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,
+2 -2
View File
@@ -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)
+2
View File
@@ -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,