🐛 Fix custom domain deletion ownership check (#2541)

- Added a scoped custom-domain lookup before provider deletion.
- Used the verified stored domain name for provider and database
deletion.
- Added regression tests for denied cross-workspace deletion attempts
and valid deletion.
This commit is contained in:
Baptiste Arnaud 2026-06-27 14:34:34 +02:00 committed by GitHub
parent c7ae1c162e
commit 06575dfcd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 122 additions and 2 deletions

View File

@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
const workspaceFindFirst = mock();
const customDomainFindFirst = mock();
const customDomainDeleteMany = mock();
const kyDelete = mock();
process.env.SKIP_ENV_CHECK = "true";
process.env.ENCRYPTION_SECRET = "12345678901234567890123456789012";
process.env.NEXTAUTH_URL = "https://app.typebot.io";
process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME = "viewer-project";
process.env.VERCEL_TEAM_ID = "team-id";
process.env.VERCEL_TOKEN = "vercel-token";
mock.module("@typebot.io/lib/ky", () => ({
ky: {
delete: kyDelete,
},
}));
mock.module("@typebot.io/prisma", () => ({
default: {
workspace: {
findFirst: workspaceFindFirst,
},
customDomain: {
findFirst: customDomainFindFirst,
deleteMany: customDomainDeleteMany,
},
},
}));
const { handleDeleteCustomDomain } = await import("./handleDeleteCustomDomain");
describe("handleDeleteCustomDomain", () => {
beforeEach(() => {
workspaceFindFirst.mockReset();
customDomainFindFirst.mockReset();
customDomainDeleteMany.mockReset();
kyDelete.mockReset();
workspaceFindFirst.mockResolvedValue({
members: [{ userId: "user-id", role: "ADMIN" }],
});
kyDelete.mockResolvedValue({});
customDomainDeleteMany.mockResolvedValue({ count: 1 });
});
it("does not delete an external domain when it is not owned by the workspace", async () => {
customDomainFindFirst.mockResolvedValue(null);
await expect(
handleDeleteCustomDomain({
input: {
workspaceId: "workspace-id",
name: "other-workspace.example.com",
},
context: {
user: { id: "user-id" },
},
}),
).rejects.toBeDefined();
expect(customDomainFindFirst).toHaveBeenCalledWith({
where: {
name: "other-workspace.example.com",
workspaceId: "workspace-id",
},
select: {
name: true,
},
});
expect(kyDelete).not.toHaveBeenCalled();
expect(customDomainDeleteMany).not.toHaveBeenCalled();
});
it("deletes an owned external domain and the matching database record", async () => {
customDomainFindFirst.mockResolvedValue({
name: "owned.example.com",
});
await expect(
handleDeleteCustomDomain({
input: {
workspaceId: "workspace-id",
name: "owned.example.com",
},
context: {
user: { id: "user-id" },
},
}),
).resolves.toEqual({ message: "success" });
expect(kyDelete).toHaveBeenCalledWith(
"https://api.vercel.com/v9/projects/viewer-project/domains/owned.example.com?teamId=team-id",
{
headers: { Authorization: "Bearer vercel-token" },
},
);
expect(customDomainDeleteMany).toHaveBeenCalledWith({
where: {
name: "owned.example.com",
workspaceId: "workspace-id",
},
});
});
});

View File

@ -34,8 +34,21 @@ export const handleDeleteCustomDomain = async ({
if (!workspace || isWriteWorkspaceForbidden(workspace, user))
throw new ORPCError("NOT_FOUND", { message: "Workspace not found" });
const customDomain = await prisma.customDomain.findFirst({
where: {
name,
workspaceId,
},
select: {
name: true,
},
});
if (!customDomain)
throw new ORPCError("NOT_FOUND", { message: "Custom domain not found" });
try {
await deleteDomainOnVercel(name);
await deleteDomainOnVercel(customDomain.name);
} catch (error) {
console.error(error);
if (error instanceof HTTPError)
@ -50,7 +63,7 @@ export const handleDeleteCustomDomain = async ({
}
await prisma.customDomain.deleteMany({
where: {
name,
name: customDomain.name,
workspaceId,
},
});