|
2 | 2 | // Each test seeds a User + session cookie via seedTestUser / seedTestSession |
3 | 3 | // (helpers/seedTestSession.ts) and hits the shared webapp container. |
4 | 4 |
|
| 5 | +import { randomBytes } from "node:crypto"; |
| 6 | +import type { PrismaClient } from "@trigger.dev/database"; |
5 | 7 | import { describe, expect, it } from "vitest"; |
6 | 8 | import { getTestServer } from "./helpers/sharedTestServer"; |
7 | 9 | import { seedTestSession, seedTestUser } from "./helpers/seedTestSession"; |
@@ -115,4 +117,66 @@ describe("Dashboard", () => { |
115 | 117 | expect(new URL(location, "http://localhost").pathname).toBe("/"); |
116 | 118 | }); |
117 | 119 | }); |
| 120 | + |
| 121 | + // Cross-tenant tenant floor on org settings routes. settings/roles is the case |
| 122 | + // the route-level membership scoping (SSO/Team) did NOT cover, so it exercises |
| 123 | + // the RBAC fallback's org-membership floor specifically: the fallback ability |
| 124 | + // is permissive (can: () => true), so that floor is the only thing stopping a |
| 125 | + // non-member from reading the org's role and permission catalogue. |
| 126 | + // |
| 127 | + // The request hits the route's own loader directly via Remix's `?_data`, which |
| 128 | + // is the exact exploit shape: a plain document GET 404s at the org layout |
| 129 | + // (membership) and never reaches this leaf, so it wouldn't test the leaf floor. |
| 130 | + // Both users have confirmedBasicDetails set so the `_app` onboarding redirect |
| 131 | + // can't stand in for the deny. |
| 132 | + describe("Org settings — cross-tenant tenant floor (settings/roles)", () => { |
| 133 | + const ROLES_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings.roles"; |
| 134 | + const rolesData = (slug: string) => |
| 135 | + `/orgs/${slug}/settings/roles?_data=${encodeURIComponent(ROLES_ROUTE_ID)}`; |
| 136 | + |
| 137 | + async function seedConfirmedUser(prisma: PrismaClient) { |
| 138 | + const user = await seedTestUser(prisma); |
| 139 | + await prisma.user.update({ where: { id: user.id }, data: { confirmedBasicDetails: true } }); |
| 140 | + return user; |
| 141 | + } |
| 142 | + |
| 143 | + async function seedOrgWithOwner() { |
| 144 | + const server = getTestServer(); |
| 145 | + const owner = await seedConfirmedUser(server.prisma); |
| 146 | + const org = await server.prisma.organization.create({ |
| 147 | + data: { |
| 148 | + title: "E2E tenant-floor org", |
| 149 | + slug: `e2e-tenant-${randomBytes(6).toString("hex")}`, |
| 150 | + members: { create: { userId: owner.id, role: "ADMIN" } }, |
| 151 | + }, |
| 152 | + }); |
| 153 | + return { server, owner, org }; |
| 154 | + } |
| 155 | + |
| 156 | + it("denies a non-member: no roles catalogue leaked", async () => { |
| 157 | + const { server, org } = await seedOrgWithOwner(); |
| 158 | + const outsider = await seedConfirmedUser(server.prisma); |
| 159 | + const cookie = await seedTestSession({ userId: outsider.id }); |
| 160 | + const res = await server.webapp.fetch(rolesData(org.slug), { |
| 161 | + redirect: "manual", |
| 162 | + headers: { Cookie: cookie }, |
| 163 | + }); |
| 164 | + const body = await res.text(); |
| 165 | + // With the tenant floor a non-member is denied (a redirect), so they never |
| 166 | + // get the loader's 200 payload. Before the fix the permissive ability let |
| 167 | + // the loader return the org's role/permission catalogue. |
| 168 | + expect(res.status).not.toBe(200); |
| 169 | + expect(body).not.toContain("manage:members"); |
| 170 | + }); |
| 171 | + |
| 172 | + it("allows a member: the loader returns the catalogue", async () => { |
| 173 | + const { server, owner, org } = await seedOrgWithOwner(); |
| 174 | + const cookie = await seedTestSession({ userId: owner.id }); |
| 175 | + const res = await server.webapp.fetch(rolesData(org.slug), { |
| 176 | + redirect: "manual", |
| 177 | + headers: { Cookie: cookie }, |
| 178 | + }); |
| 179 | + expect(res.status).toBe(200); |
| 180 | + }); |
| 181 | + }); |
118 | 182 | }); |
0 commit comments