mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 15:37:12 +00:00
- declare rate + concurrency limits on every route and driver that lacked one - add acquireConcurrent for websocket connections and the DAV mount - bucket AI models by identity key only; keep resold duplicates of any vendor - skip recently-failed provider routes; cap the fallback chain at 3 attempts - let full-access access tokens bind a worker to an app their own user owns - cache resolved subscriptions so tiered limits don't add a round trip
891 lines
32 KiB
TypeScript
891 lines
32 KiB
TypeScript
/*
|
||
* 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import type { Actor } from '../../core/actor.js';
|
||
import { runWithContext } from '../../core/context.js';
|
||
import { PuterServer } from '../../server.js';
|
||
import { setupTestServer } from '../../testUtil.js';
|
||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||
import type { SubdomainDriver } from './SubdomainDriver.js';
|
||
|
||
// ── Test harness ────────────────────────────────────────────────────
|
||
//
|
||
// Boots one PuterServer (in-memory sqlite + dynamo + s3 + mock redis)
|
||
// and exercises the live SubdomainDriver against the real wired stores.
|
||
// Each test makes its own user via `makeUser` so subdomain rows / quota
|
||
// counts don't leak across cases.
|
||
|
||
let server: PuterServer;
|
||
let driver: SubdomainDriver;
|
||
|
||
beforeAll(async () => {
|
||
server = await setupTestServer();
|
||
driver = server.drivers.subdomains as unknown as SubdomainDriver;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await server?.shutdown();
|
||
});
|
||
|
||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||
const username = `sd-${Math.random().toString(36).slice(2, 10)}`;
|
||
const created = await server.stores.user.create({
|
||
username,
|
||
uuid: uuidv4(),
|
||
password: null,
|
||
email: `${username}@test.local`,
|
||
free_storage: 100 * 1024 * 1024,
|
||
requires_email_confirmation: false,
|
||
});
|
||
// Driver checks ACL on `root_dir`, which requires the home tree to
|
||
// exist — without provisioning, every create call would 400.
|
||
await generateDefaultFsentries(
|
||
server.clients.db,
|
||
server.stores.user,
|
||
created,
|
||
);
|
||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||
return {
|
||
userId: refreshed.id,
|
||
actor: {
|
||
user: {
|
||
id: refreshed.id,
|
||
uuid: refreshed.uuid,
|
||
username: refreshed.username,
|
||
email: refreshed.email ?? null,
|
||
email_confirmed: true,
|
||
} as Actor['user'],
|
||
},
|
||
};
|
||
};
|
||
|
||
const withActor = async <T>(actor: Actor, fn: () => Promise<T>): Promise<T> =>
|
||
runWithContext({ actor }, fn);
|
||
|
||
const uniqueSubdomain = (prefix: string) =>
|
||
`${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||
|
||
// ── create ──────────────────────────────────────────────────────────
|
||
|
||
describe('SubdomainDriver.create', () => {
|
||
it('creates a subdomain pointing at an owned fs path', async () => {
|
||
const { actor, userId } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const sub = uniqueSubdomain('site');
|
||
|
||
const result = (await withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown> | null;
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.subdomain).toBe(sub);
|
||
// Owner is hydrated as `{ username, uuid }`, not a numeric id.
|
||
expect(result?.owner).toMatchObject({ username });
|
||
|
||
const row =
|
||
await server.stores.subdomain.getBySubdomain(sub);
|
||
expect(row?.user_id).toBe(userId);
|
||
});
|
||
|
||
it('expands `~/Public` against the actor home before resolving root_dir', async () => {
|
||
const { actor } = await makeUser();
|
||
const sub = uniqueSubdomain('tilde');
|
||
|
||
await withActor(actor, () =>
|
||
driver.create({
|
||
object: { subdomain: sub, root_dir: '~/Public' },
|
||
}),
|
||
);
|
||
|
||
const row =
|
||
await server.stores.subdomain.getBySubdomain(sub);
|
||
expect(row).not.toBeNull();
|
||
});
|
||
|
||
it('rejects an invalid subdomain format with 400', async () => {
|
||
const { actor } = await makeUser();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: 'NOT_VALID!',
|
||
root_dir: `/${actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 400 });
|
||
});
|
||
|
||
it('rejects a reserved subdomain word with 400', async () => {
|
||
const { actor } = await makeUser();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: 'admin',
|
||
root_dir: `/${actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 400 });
|
||
});
|
||
|
||
it('rejects a duplicate subdomain with 409', async () => {
|
||
const a = await makeUser();
|
||
const b = await makeUser();
|
||
const sub = uniqueSubdomain('dup');
|
||
|
||
await withActor(a.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${a.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
);
|
||
|
||
await expect(
|
||
withActor(b.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${b.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 409 });
|
||
});
|
||
|
||
it('reports a lost uniqueness race as 409, not a 500', async () => {
|
||
const { actor } = await makeUser();
|
||
|
||
// The uniqueness check and the insert are two statements, so a name can
|
||
// be claimed in between and only the index catches it. The in-memory
|
||
// sqlite schema has no unique index on `subdomain` (mysql and postgres
|
||
// do), so the losing insert is what gets stubbed here.
|
||
const dup = Object.assign(new Error('Duplicate entry'), {
|
||
code: 'ER_DUP_ENTRY',
|
||
errno: 1062,
|
||
});
|
||
const create = vi
|
||
.spyOn(server.stores.subdomain, 'create')
|
||
.mockRejectedValueOnce(dup);
|
||
|
||
try {
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: uniqueSubdomain('race'),
|
||
root_dir: `/${actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 409 });
|
||
} finally {
|
||
create.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('lets a non-uniqueness insert failure surface as a server error', async () => {
|
||
const { actor } = await makeUser();
|
||
|
||
const create = vi
|
||
.spyOn(server.stores.subdomain, 'create')
|
||
.mockRejectedValueOnce(new Error('connection lost'));
|
||
|
||
try {
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: uniqueSubdomain('boom'),
|
||
root_dir: `/${actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toThrow('connection lost');
|
||
} finally {
|
||
create.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('rejects when root_dir does not exist', async () => {
|
||
const { actor } = await makeUser();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: uniqueSubdomain('missing'),
|
||
root_dir: `/${actor.user!.username}/does-not-exist`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 400 });
|
||
});
|
||
|
||
it("rejects pointing root_dir at another user's tree", async () => {
|
||
const a = await makeUser();
|
||
const b = await makeUser();
|
||
await expect(
|
||
withActor(a.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: uniqueSubdomain('intruder'),
|
||
root_dir: `/${b.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({
|
||
statusCode: expect.any(Number),
|
||
});
|
||
});
|
||
|
||
it('rejects a missing object body with 400', async () => {
|
||
const { actor } = await makeUser();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.create({} as Record<string, unknown>),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 400 });
|
||
});
|
||
|
||
it('throws 401 with no actor in context', async () => {
|
||
await expect(
|
||
driver.create({
|
||
object: {
|
||
subdomain: uniqueSubdomain('noctx'),
|
||
root_dir: '/x',
|
||
},
|
||
}),
|
||
).rejects.toMatchObject({ statusCode: 401 });
|
||
});
|
||
});
|
||
|
||
// ── read / select ───────────────────────────────────────────────────
|
||
|
||
describe('SubdomainDriver.read / select', () => {
|
||
it('reads a subdomain by uid for its owner', async () => {
|
||
const { actor } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const sub = uniqueSubdomain('read');
|
||
|
||
const created = (await withActor(actor, () =>
|
||
driver.create({
|
||
object: { subdomain: sub, root_dir: `/${username}/Public` },
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
const fetched = (await withActor(actor, () =>
|
||
driver.read({ uid: created.uid }),
|
||
)) as Record<string, unknown> | null;
|
||
|
||
expect(fetched?.subdomain).toBe(sub);
|
||
});
|
||
|
||
it('reads via id object with `{ subdomain }`', async () => {
|
||
const { actor } = await makeUser();
|
||
const sub = uniqueSubdomain('read-by-name');
|
||
await withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
);
|
||
|
||
const fetched = (await withActor(actor, () =>
|
||
driver.read({ id: { subdomain: sub } }),
|
||
)) as Record<string, unknown> | null;
|
||
|
||
expect(fetched?.subdomain).toBe(sub);
|
||
});
|
||
|
||
it("rejects reading another user's subdomain with 403", async () => {
|
||
const a = await makeUser();
|
||
const b = await makeUser();
|
||
const sub = uniqueSubdomain('private');
|
||
|
||
const created = (await withActor(a.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${a.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
await expect(
|
||
withActor(b.actor, () => driver.read({ uid: created.uid })),
|
||
).rejects.toMatchObject({ statusCode: 403 });
|
||
});
|
||
|
||
it('returns 404 when reading a missing subdomain', async () => {
|
||
const { actor } = await makeUser();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.read({ uid: 'nonexistent-uuid' }),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 404 });
|
||
});
|
||
|
||
it('select returns only the actor-owned subdomains', async () => {
|
||
const a = await makeUser();
|
||
const b = await makeUser();
|
||
await withActor(a.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: uniqueSubdomain('mine'),
|
||
root_dir: `/${a.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
);
|
||
await withActor(b.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: uniqueSubdomain('theirs'),
|
||
root_dir: `/${b.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
);
|
||
|
||
const result = (await withActor(a.actor, () =>
|
||
driver.select({}),
|
||
)) as Array<Record<string, unknown>>;
|
||
|
||
// Owners surface as `{ username, uuid }`; assert we only see a's.
|
||
for (const row of result) {
|
||
expect((row.owner as { username: string }).username).toBe(
|
||
a.actor.user!.username,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
// -- select pagination --
|
||
|
||
describe('SubdomainDriver.select pagination', () => {
|
||
const makeSubs = async (count: number) => {
|
||
const { actor } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const subs: string[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const sub = uniqueSubdomain(`page${i}`);
|
||
subs.push(sub);
|
||
await withActor(actor, () =>
|
||
driver.create({
|
||
object: { subdomain: sub, root_dir: `/${username}/Public` },
|
||
}),
|
||
);
|
||
}
|
||
return { actor, subs };
|
||
};
|
||
|
||
it('keeps the bare array response for plain limit requests', async () => {
|
||
const { actor } = await makeSubs(2);
|
||
const result = await withActor(actor, () =>
|
||
driver.select({ limit: 10 }),
|
||
);
|
||
expect(Array.isArray(result)).toBe(true);
|
||
});
|
||
|
||
it('pages through subdomains with cursors', async () => {
|
||
const { actor, subs } = await makeSubs(3);
|
||
const seen: string[] = [];
|
||
let cursor: string | null | undefined = null;
|
||
do {
|
||
const page = (await withActor(actor, () =>
|
||
driver.select({ limit: 2, cursor }),
|
||
)) as { items: Array<{ subdomain: string }>; cursor?: string };
|
||
seen.push(...page.items.map((r) => r.subdomain));
|
||
cursor = page.cursor;
|
||
} while (cursor);
|
||
expect(seen.sort()).toEqual([...subs].sort());
|
||
});
|
||
|
||
it('supports offset paging', async () => {
|
||
const { actor, subs } = await makeSubs(3);
|
||
const page = (await withActor(actor, () =>
|
||
driver.select({ limit: 10, offset: 1 }),
|
||
)) as { items: Array<{ subdomain: string }> };
|
||
expect(page.items.length).toBe(subs.length - 1);
|
||
});
|
||
|
||
it('rejects cursor combined with offset', async () => {
|
||
const { actor } = await makeSubs(2);
|
||
const first = (await withActor(actor, () =>
|
||
driver.select({ limit: 1, cursor: null }),
|
||
)) as { cursor?: string };
|
||
expect(first.cursor).toBeDefined();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.select({ offset: 1, cursor: first.cursor }),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 400 });
|
||
});
|
||
|
||
it('excludes worker-backed subdomains from listings and totals', async () => {
|
||
const { actor, subs } = await makeSubs(2);
|
||
await server.stores.subdomain.create({
|
||
userId: actor.user!.id as number,
|
||
subdomain: `workers.puter.wk-${Date.now()}`,
|
||
rootDirId: null,
|
||
associatedAppId: null,
|
||
appOwner: null,
|
||
});
|
||
|
||
const bare = (await withActor(actor, () =>
|
||
driver.select({}),
|
||
)) as Array<{ subdomain: string }>;
|
||
expect(bare.map((r) => r.subdomain).sort()).toEqual([...subs].sort());
|
||
|
||
const page = (await withActor(actor, () =>
|
||
driver.select({ limit: 10, cursor: null, includeTotal: true }),
|
||
)) as { items: Array<{ subdomain: string }>; total?: number };
|
||
expect(page.items.map((r) => r.subdomain).sort()).toEqual(
|
||
[...subs].sort(),
|
||
);
|
||
expect(page.total).toBe(subs.length);
|
||
});
|
||
|
||
it('reports total scoped to the actor with includeTotal', async () => {
|
||
const { actor, subs } = await makeSubs(3);
|
||
await makeSubs(2); // another user's rows must not count
|
||
const page = (await withActor(actor, () =>
|
||
driver.select({ limit: 1, includeTotal: true }),
|
||
)) as { items: unknown[]; total?: number };
|
||
expect(page.items.length).toBe(1);
|
||
expect(page.total).toBe(subs.length);
|
||
});
|
||
});
|
||
|
||
// ── update ──────────────────────────────────────────────────────────
|
||
|
||
describe('SubdomainDriver.update', () => {
|
||
it('updates root_dir to another owned path', async () => {
|
||
const { actor } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const sub = uniqueSubdomain('upd');
|
||
|
||
const created = (await withActor(actor, () =>
|
||
driver.create({
|
||
object: { subdomain: sub, root_dir: `/${username}/Public` },
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
const updated = (await withActor(actor, () =>
|
||
driver.update({
|
||
uid: created.uid,
|
||
object: { root_dir: `/${username}/Documents` },
|
||
}),
|
||
)) as Record<string, unknown> | null;
|
||
|
||
expect(updated).not.toBeNull();
|
||
const rootDir = updated!.root_dir as Record<string, unknown> | null;
|
||
expect(rootDir?.path).toBe(`/${username}/Documents`);
|
||
});
|
||
|
||
it('refuses to update a subdomain owned by another user with 403', async () => {
|
||
const a = await makeUser();
|
||
const b = await makeUser();
|
||
const sub = uniqueSubdomain('cross-upd');
|
||
|
||
const created = (await withActor(a.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${a.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
await expect(
|
||
withActor(b.actor, () =>
|
||
driver.update({
|
||
uid: created.uid,
|
||
object: {
|
||
root_dir: `/${b.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 403 });
|
||
});
|
||
|
||
it('returns 404 for a missing object body', async () => {
|
||
const { actor } = await makeUser();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.update({ uid: 'whatever' } as Record<string, unknown>),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 400 });
|
||
});
|
||
});
|
||
|
||
// ── upsert ──────────────────────────────────────────────────────────
|
||
|
||
describe('SubdomainDriver.upsert', () => {
|
||
it('creates when no row matches the args', async () => {
|
||
const { actor } = await makeUser();
|
||
const sub = uniqueSubdomain('ups');
|
||
const result = (await withActor(actor, () =>
|
||
driver.upsert({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown> | null;
|
||
expect(result?.subdomain).toBe(sub);
|
||
});
|
||
|
||
it('updates when an existing row resolves via id.subdomain', async () => {
|
||
const { actor } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const sub = uniqueSubdomain('ups-existing');
|
||
await withActor(actor, () =>
|
||
driver.create({
|
||
object: { subdomain: sub, root_dir: `/${username}/Public` },
|
||
}),
|
||
);
|
||
|
||
const result = (await withActor(actor, () =>
|
||
driver.upsert({
|
||
id: { subdomain: sub },
|
||
object: { root_dir: `/${username}/Documents` },
|
||
}),
|
||
)) as Record<string, unknown> | null;
|
||
|
||
const rootDir = result!.root_dir as Record<string, unknown> | null;
|
||
expect(rootDir?.path).toBe(`/${username}/Documents`);
|
||
});
|
||
});
|
||
|
||
// ── delete ──────────────────────────────────────────────────────────
|
||
|
||
describe('SubdomainDriver.delete', () => {
|
||
it('deletes an owned subdomain and reports success', async () => {
|
||
const { actor } = await makeUser();
|
||
const sub = uniqueSubdomain('del');
|
||
const created = (await withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
const result = (await withActor(actor, () =>
|
||
driver.delete({ uid: created.uid }),
|
||
)) as { success: boolean; uid: string };
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.uid).toBe(created.uid);
|
||
expect(
|
||
await server.stores.subdomain.getBySubdomain(sub),
|
||
).toBeNull();
|
||
});
|
||
|
||
it("refuses to delete another user's subdomain with 403", async () => {
|
||
const a = await makeUser();
|
||
const b = await makeUser();
|
||
const sub = uniqueSubdomain('cross-del');
|
||
const created = (await withActor(a.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${a.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
await expect(
|
||
withActor(b.actor, () => driver.delete({ uid: created.uid })),
|
||
).rejects.toMatchObject({ statusCode: 403 });
|
||
|
||
// a's row is still there.
|
||
expect(
|
||
await server.stores.subdomain.getBySubdomain(sub),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('returns 404 for a non-existent subdomain', async () => {
|
||
const { actor } = await makeUser();
|
||
await expect(
|
||
withActor(actor, () =>
|
||
driver.delete({ uid: 'nonexistent-uuid' }),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 404 });
|
||
});
|
||
});
|
||
|
||
// ── associated_app derivation (security fix) ───────────────────────
|
||
//
|
||
// `associated_app_uid` was previously a user-writable field on the
|
||
// subdomain row, with no check that the targeted app belonged to the
|
||
// caller. That let an attacker bind their own subdomain to another
|
||
// user's app (notably a private app), tricking the hosted-site
|
||
// middleware into running the entitlement gate for the victim's app on
|
||
// the attacker's subdomain. The field is now ignored on writes and
|
||
// derived on reads from `apps.owner_user_id = subdomain.user_id` plus
|
||
// an `index_url` match against the subdomain's host variants.
|
||
|
||
const createAppWithIndexUrl = async (
|
||
ownerUserId: number | null,
|
||
indexUrl: string,
|
||
opts: { isPrivate?: boolean } = {},
|
||
): Promise<{ id: number; uid: string }> => {
|
||
const uid = `app-${uuidv4()}`;
|
||
await server.clients.db.write(
|
||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
uid,
|
||
`app-${uid}`,
|
||
`app-${uid}`,
|
||
indexUrl,
|
||
ownerUserId,
|
||
opts.isPrivate ? 1 : 0,
|
||
],
|
||
);
|
||
const row = (
|
||
await server.clients.db.read('SELECT id, uid FROM apps WHERE uid = ?', [
|
||
uid,
|
||
])
|
||
)[0] as { id: number; uid: string };
|
||
return row;
|
||
};
|
||
|
||
// ── Launch-origin takeover on re-registration ──────────────────────
|
||
//
|
||
// Deleting a hosted subdomain leaves the app row's `index_url` pointing
|
||
// at the freed name. The GUI launcher appends `puter.auth.token` to that
|
||
// URL, so whoever registers the name next would receive launch tokens
|
||
// for the original app. `create` therefore refuses a name another user's
|
||
// app still references — while leaving the app's own owner free to
|
||
// re-create it.
|
||
|
||
describe('SubdomainDriver.create launch-origin reservation', () => {
|
||
it("refuses a name another user's app still points at", async () => {
|
||
const victim = await makeUser();
|
||
const attacker = await makeUser();
|
||
const sub = uniqueSubdomain('freed');
|
||
|
||
await createAppWithIndexUrl(
|
||
victim.userId,
|
||
`http://${sub}.site.puter.localhost/`,
|
||
);
|
||
|
||
await expect(
|
||
withActor(attacker.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${attacker.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 409 });
|
||
expect(await server.stores.subdomain.getBySubdomain(sub)).toBeFalsy();
|
||
});
|
||
|
||
it('refuses a name an unowned origin-bootstrapped app points at', async () => {
|
||
const attacker = await makeUser();
|
||
const sub = uniqueSubdomain('orphan');
|
||
|
||
await createAppWithIndexUrl(
|
||
null,
|
||
`http://${sub}.site.puter.localhost`,
|
||
);
|
||
|
||
await expect(
|
||
withActor(attacker.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${attacker.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 409 });
|
||
});
|
||
|
||
it('lets the referencing app owner re-create the name', async () => {
|
||
const owner = await makeUser();
|
||
const sub = uniqueSubdomain('recreate');
|
||
|
||
await createAppWithIndexUrl(
|
||
owner.userId,
|
||
`http://${sub}.site.puter.localhost/`,
|
||
);
|
||
|
||
const created = (await withActor(owner.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${owner.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
expect(created.subdomain).toBe(sub);
|
||
});
|
||
|
||
it('leaves names no app references claimable', async () => {
|
||
const other = await makeUser();
|
||
const claimant = await makeUser();
|
||
const sub = uniqueSubdomain('unrelated');
|
||
|
||
// Same owner, unrelated host: the reservation keys off the name,
|
||
// not off the existence of other people's apps.
|
||
await createAppWithIndexUrl(
|
||
other.userId,
|
||
'https://elsewhere.example.test/',
|
||
);
|
||
|
||
const created = (await withActor(claimant.actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${claimant.actor.user!.username}/Public`,
|
||
},
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
expect(created.subdomain).toBe(sub);
|
||
});
|
||
});
|
||
|
||
describe('SubdomainDriver associated_app derivation', () => {
|
||
it('ignores `associated_app_uid` on create and derives null when no app matches', async () => {
|
||
const { actor } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const sub = uniqueSubdomain('assoc-create');
|
||
|
||
// Caller asserts an arbitrary app uid — must be silently dropped.
|
||
const result = (await withActor(actor, () =>
|
||
driver.create({
|
||
object: {
|
||
subdomain: sub,
|
||
root_dir: `/${username}/Public`,
|
||
associated_app_uid: 'app-does-not-exist',
|
||
},
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
expect(result.associated_app).toBeNull();
|
||
const row = await server.stores.subdomain.getBySubdomain(sub);
|
||
expect(row?.associated_app_id).toBeFalsy();
|
||
});
|
||
|
||
it('ignores `associated_app_uid` on update', async () => {
|
||
const { actor, userId } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const sub = uniqueSubdomain('assoc-update');
|
||
|
||
const created = (await withActor(actor, () =>
|
||
driver.create({
|
||
object: { subdomain: sub, root_dir: `/${username}/Public` },
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
// Plant an app owned by another user that the index_url-derive
|
||
// would otherwise reject anyway. Even with the (now-defunct)
|
||
// explicit `associated_app_uid` knob, the field must stay null.
|
||
const other = await makeUser();
|
||
const otherApp = await createAppWithIndexUrl(
|
||
other.userId,
|
||
'http://anything.example.test/',
|
||
);
|
||
const updated = (await withActor(actor, () =>
|
||
driver.update({
|
||
uid: created.uid,
|
||
object: { associated_app_uid: otherApp.uid },
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
expect(updated.associated_app).toBeNull();
|
||
const row = await server.stores.subdomain.getBySubdomain(sub);
|
||
expect(row?.associated_app_id).toBeFalsy();
|
||
// user_id should still be the original owner.
|
||
expect(row?.user_id).toBe(userId);
|
||
});
|
||
|
||
it("derives `associated_app` from the owner's app whose index_url matches the subdomain host", async () => {
|
||
const { actor, userId } = await makeUser();
|
||
const username = actor.user!.username!;
|
||
const sub = uniqueSubdomain('assoc-derive');
|
||
|
||
// App owned by the same user, registered with a URL on the
|
||
// default test hosting domain. The derive logic crosses host
|
||
// variants × protocols × paths, so any reasonable index_url
|
||
// anchored at the subdomain's name should match.
|
||
const indexUrl = `http://${sub}.site.puter.localhost/`;
|
||
const app = await createAppWithIndexUrl(userId, indexUrl);
|
||
|
||
const created = (await withActor(actor, () =>
|
||
driver.create({
|
||
object: { subdomain: sub, root_dir: `/${username}/Public` },
|
||
}),
|
||
)) as Record<string, unknown>;
|
||
|
||
const associated = created.associated_app as {
|
||
uid: string;
|
||
} | null;
|
||
expect(associated?.uid).toBe(app.uid);
|
||
});
|
||
|
||
it("does not derive an `associated_app` for another user's app at the same host", async () => {
|
||
// The core IDOR: attacker (a) holds a subdomain whose host matches
|
||
// the index_url of a victim's (b) private app. Even on exact
|
||
// index_url match, the ownership filter must reject the app from
|
||
// another user's account.
|
||
//
|
||
// `create` now refuses that pairing outright (see the launch-origin
|
||
// reservation above), so the row is planted through the store — this
|
||
// is the legacy-data shape the derive filter still has to handle.
|
||
const a = await makeUser();
|
||
const b = await makeUser();
|
||
const sub = uniqueSubdomain('idor');
|
||
|
||
await createAppWithIndexUrl(
|
||
b.userId,
|
||
`http://${sub}.site.puter.localhost/`,
|
||
{ isPrivate: true },
|
||
);
|
||
|
||
const row = await server.stores.subdomain.create({
|
||
userId: a.userId,
|
||
subdomain: sub,
|
||
});
|
||
|
||
const read = (await withActor(a.actor, () =>
|
||
driver.read({ uid: (row as { uuid: string }).uuid }),
|
||
)) as Record<string, unknown>;
|
||
|
||
expect(read.associated_app).toBeNull();
|
||
});
|
||
});
|