Skip to content

Commit 0a44b88

Browse files
authored
fix: security release 2026-07-21 (#4528)
1 parent db67a85 commit 0a44b88

10 files changed

Lines changed: 342 additions & 16 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Reject alert webhook destinations in reserved benchmarking IP ranges.

apps/webapp/app/models/organization.server.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Prisma as PrismaNamespace,
1616
prisma,
1717
type PrismaClientOrTransaction,
18+
type PrismaReplicaClient,
1819
} from "~/db.server";
1920
import { env } from "~/env.server";
2021
import { featuresForUrl } from "~/features.server";
@@ -41,22 +42,49 @@ const nanoid = customAlphabet("1234567890abcdef", 4);
4142
* miss, so replica lag never leaves a real org unresolved, which the dashboard
4243
* route builder treats as an unauthorized request.
4344
*/
44-
export async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
45-
const fromReplica = await $replica.organization.findFirst({
45+
export async function resolveOrgIdFromSlug(
46+
slug: string,
47+
replicaClient: PrismaReplicaClient = $replica,
48+
prismaClient: PrismaClientOrTransaction = prisma
49+
): Promise<string | null> {
50+
const fromReplica = await replicaClient.organization.findFirst({
4651
where: { slug },
4752
select: { id: true },
4853
});
4954
if (fromReplica) {
5055
return fromReplica.id;
5156
}
5257

53-
const fromPrimary = await prisma.organization.findFirst({
58+
const fromPrimary = await prismaClient.organization.findFirst({
5459
where: { slug },
5560
select: { id: true },
5661
});
5762
return fromPrimary?.id ?? null;
5863
}
5964

65+
/**
66+
* Like `resolveOrgIdFromSlug`, but only resolves an org the user is a member of. `ability.can` is not
67+
* a tenant floor (the OSS fallback and the cloud plugin both return a permissive ability for a
68+
* non-member), so a route that scopes only by slug lets a non-member reach the handler; the
69+
* membership filter here is the tenant floor. Returns null for a non-member, which the dashboard
70+
* route builder treats as no scope and fails closed.
71+
*/
72+
export async function resolveOrgIdFromSlugForUser(
73+
slug: string,
74+
userId: string,
75+
replicaClient: PrismaReplicaClient = $replica,
76+
prismaClient: PrismaClientOrTransaction = prisma
77+
): Promise<string | null> {
78+
const where = { slug, members: { some: { userId } } };
79+
const fromReplica = await replicaClient.organization.findFirst({ where, select: { id: true } });
80+
if (fromReplica) {
81+
return fromReplica.id;
82+
}
83+
84+
const fromPrimary = await prismaClient.organization.findFirst({ where, select: { id: true } });
85+
return fromPrimary?.id ?? null;
86+
}
87+
6088
export async function createOrganization(
6189
{
6290
title,

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { Select, SelectItem } from "~/components/primitives/Select";
3636
import { Switch } from "~/components/primitives/Switch";
3737
import { prisma } from "~/db.server";
38+
import { getUserId } from "~/services/session.server";
3839
import { useOrganization } from "~/hooks/useOrganizations";
3940
import { rbac } from "~/services/rbac.server";
4041
import { ssoController } from "~/services/sso.server";
@@ -53,11 +54,14 @@ export const meta = pageMeta("SSO & Directory Sync");
5354

5455
const Params = z.object({ organizationSlug: z.string() });
5556

56-
async function resolveOrg(slug: string) {
57+
async function resolveOrg(slug: string, userId: string) {
58+
// Scoped to membership: ability.can is not a tenant floor (the cloud RBAC
59+
// plugin returns a permissive ability for a non-member), so without the
60+
// members filter a non-member reaches the handler for any org slug.
5761
// Primary (not replica): this scopes the RBAC/entitlement checks, so lag
5862
// could run them against a stale/missing org.
5963
return prisma.organization.findFirst({
60-
where: { slug },
64+
where: { slug, members: { some: { userId } } },
6165
select: { id: true, title: true },
6266
});
6367
}
@@ -117,8 +121,10 @@ const EMPTY_SSO_STATUS = {
117121
export const loader = dashboardLoader(
118122
{
119123
params: Params,
120-
context: async (params) => {
121-
const org = await resolveOrg(params.organizationSlug);
124+
context: async (params, request) => {
125+
const userId = await getUserId(request);
126+
if (!userId) return {};
127+
const org = await resolveOrg(params.organizationSlug, userId);
122128
return org ? { organizationId: org.id, orgTitle: org.title } : {};
123129
},
124130
// Plan-gated before role-gated: non-Enterprise orgs render the upsell for
@@ -211,8 +217,10 @@ const ActionSchema = z.discriminatedUnion("action", [
211217
export const action = dashboardAction(
212218
{
213219
params: Params,
214-
context: async (params) => {
215-
const org = await resolveOrg(params.organizationSlug);
220+
context: async (params, request) => {
221+
const userId = await getUserId(request);
222+
if (!userId) return {};
223+
const org = await resolveOrg(params.organizationSlug, userId);
216224
return org ? { organizationId: org.id } : {};
217225
},
218226
authorization: { action: "manage", resource: { type: "sso" } },

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ import { useOrganization } from "~/hooks/useOrganizations";
3737
import { useUser } from "~/hooks/useUser";
3838
import { removeTeamMember } from "~/models/removeTeamMember.server";
3939
import { redirectWithSuccessMessage } from "~/models/message.server";
40-
import { resolveOrgIdFromSlug } from "~/models/organization.server";
40+
import { resolveOrgIdFromSlugForUser } from "~/models/organization.server";
41+
import { getUserId } from "~/services/session.server";
4142
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
4243
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
4344
import { rbac } from "~/services/rbac.server";
@@ -66,8 +67,10 @@ const Params = z.object({
6667
export const loader = dashboardLoader(
6768
{
6869
params: Params,
69-
context: async (params) => {
70-
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
70+
context: async (params, request) => {
71+
const userId = await getUserId(request);
72+
if (!userId) return {};
73+
const orgId = await resolveOrgIdFromSlugForUser(params.organizationSlug, userId);
7174
return orgId ? { organizationId: orgId } : {};
7275
},
7376
authorization: { action: "read", resource: { type: "members" } },
@@ -127,8 +130,10 @@ const SetRoleSchema = z.object({
127130
export const action = dashboardAction(
128131
{
129132
params: Params,
130-
context: async (params) => {
131-
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
133+
context: async (params, request) => {
134+
const userId = await getUserId(request);
135+
if (!userId) return {};
136+
const orgId = await resolveOrgIdFromSlugForUser(params.organizationSlug, userId);
132137
return orgId ? { organizationId: orgId } : {};
133138
},
134139
// No top-level authorization — different intents have different

apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ function isUnsafeIPv4(host: string): boolean {
4242
if (a === 169 && b === 254) return true;
4343
// 100.64/10 carrier-grade NAT
4444
if (a === 100 && b >= 64 && b <= 127) return true;
45+
// 198.18/15 benchmarking
46+
if (a === 198 && b >= 18 && b <= 19) return true;
4547
// 224/4 multicast
4648
if (a >= 224 && a <= 239) return true;
4749
// 240/4 reserved

apps/webapp/test/auth-dashboard.e2e.full.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Each test seeds a User + session cookie via seedTestUser / seedTestSession
33
// (helpers/seedTestSession.ts) and hits the shared webapp container.
44

5+
import { randomBytes } from "node:crypto";
6+
import type { PrismaClient } from "@trigger.dev/database";
57
import { describe, expect, it } from "vitest";
68
import { getTestServer } from "./helpers/sharedTestServer";
79
import { seedTestSession, seedTestUser } from "./helpers/seedTestSession";
@@ -115,4 +117,66 @@ describe("Dashboard", () => {
115117
expect(new URL(location, "http://localhost").pathname).toBe("/");
116118
});
117119
});
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+
});
118182
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import plugin from "@trigger.dev/rbac";
3+
import { type PrismaClient } from "@trigger.dev/database";
4+
import { describe, expect, vi } from "vitest";
5+
import {
6+
createTestOrgProjectWithMember,
7+
createTestUser,
8+
} from "./fixtures/environmentVariablesFixtures";
9+
10+
vi.setConfig({ testTimeout: 60_000 });
11+
12+
// The RBAC fallback ability is permissive (`can: () => true` for a non-admin), so
13+
// `ability.can` is not a tenant floor. `authenticateSession` is the gate every
14+
// org-scoped dashboard route relies on; a non-member in an org context must be
15+
// denied here, or a permissive ability lets them act on any org whose slug they
16+
// know. The route-level e2e (auth-dashboard.e2e.full) covers the HTTP path; this
17+
// pins the fallback gate directly since that path can't run without a container.
18+
function fallback(prisma: PrismaClient) {
19+
// forceFallback skips the closed-source plugin and uses the in-repo fallback.
20+
return plugin.create({ primary: prisma, replica: prisma }, { forceFallback: true });
21+
}
22+
23+
const request = new Request("https://app.trigger.dev/orgs/x/settings/roles");
24+
25+
describe("RBAC fallback authenticateSession — org membership floor", () => {
26+
postgresTest("denies a non-member in an org context", async ({ prisma }) => {
27+
const { organization } = await createTestOrgProjectWithMember(prisma);
28+
const outsider = await createTestUser(prisma);
29+
30+
const result = await fallback(prisma).authenticateSession(request, {
31+
userId: outsider.id,
32+
organizationId: organization.id,
33+
});
34+
35+
expect(result).toMatchObject({ ok: false, reason: "unauthorized" });
36+
});
37+
38+
postgresTest("allows a member in an org context", async ({ prisma }) => {
39+
const { user, organization } = await createTestOrgProjectWithMember(prisma);
40+
41+
const result = await fallback(prisma).authenticateSession(request, {
42+
userId: user.id,
43+
organizationId: organization.id,
44+
});
45+
46+
expect(result.ok).toBe(true);
47+
});
48+
49+
postgresTest(
50+
"stays permissive with no org context, even for a non-member",
51+
async ({ prisma }) => {
52+
// Identity-only checks (no organizationId) predate any scope, so the floor
53+
// does not apply and the permissive baseline is preserved.
54+
const outsider = await createTestUser(prisma);
55+
56+
const result = await fallback(prisma).authenticateSession(request, { userId: outsider.id });
57+
58+
expect(result.ok).toBe(true);
59+
}
60+
);
61+
62+
// A project-only scope is still a tenant claim, so the floor resolves the
63+
// project's organization rather than letting the context through unchecked.
64+
postgresTest("denies a non-member scoped only to a project", async ({ prisma }) => {
65+
const { project } = await createTestOrgProjectWithMember(prisma);
66+
const outsider = await createTestUser(prisma);
67+
68+
const result = await fallback(prisma).authenticateSession(request, {
69+
userId: outsider.id,
70+
projectId: project.id,
71+
});
72+
73+
expect(result).toMatchObject({ ok: false, reason: "unauthorized" });
74+
});
75+
76+
postgresTest("allows a member scoped only to a project", async ({ prisma }) => {
77+
const { user, project } = await createTestOrgProjectWithMember(prisma);
78+
79+
const result = await fallback(prisma).authenticateSession(request, {
80+
userId: user.id,
81+
projectId: project.id,
82+
});
83+
84+
expect(result.ok).toBe(true);
85+
});
86+
87+
// The membership probe reads the replica first and the primary on a miss, so a
88+
// member whose row has not replicated yet is not bounced. Modelled by giving
89+
// the controller a replica that cannot see the row and a primary that can.
90+
postgresTest("allows a member the replica has not caught up on", async ({ prisma }) => {
91+
const { user, organization } = await createTestOrgProjectWithMember(prisma);
92+
const blindReplica = {
93+
...prisma,
94+
orgMember: { findFirst: async () => null },
95+
user: prisma.user,
96+
project: prisma.project,
97+
} as unknown as PrismaClient;
98+
99+
const controller = plugin.create(
100+
{ primary: prisma, replica: blindReplica },
101+
{ forceFallback: true }
102+
);
103+
const result = await controller.authenticateSession(request, {
104+
userId: user.id,
105+
organizationId: organization.id,
106+
});
107+
108+
expect(result.ok).toBe(true);
109+
});
110+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import { describe, expect, vi } from "vitest";
3+
4+
vi.setConfig({ testTimeout: 60_000 });
5+
import { resolveOrgIdFromSlug, resolveOrgIdFromSlugForUser } from "~/models/organization.server";
6+
import {
7+
createTestOrgProjectWithMember,
8+
createTestUser,
9+
} from "./fixtures/environmentVariablesFixtures";
10+
11+
// The org settings routes resolve their org through this helper, so a non-member resolving to null
12+
// is what makes the dashboard route builder fail closed. ability.can is not a tenant floor (the RBAC
13+
// plugin and the OSS fallback both return a permissive ability for a non-member), so without the
14+
// membership filter a non-member reached those routes for any org whose slug they knew: a live
15+
// cross-tenant read of SSO/directory-sync config and an open set-role gate, confirmed on test-cloud.
16+
describe("resolveOrgIdFromSlugForUser", () => {
17+
postgresTest("resolves an org the user is a member of", async ({ prisma }) => {
18+
const { user, organization } = await createTestOrgProjectWithMember(prisma);
19+
20+
const resolved = await resolveOrgIdFromSlugForUser(organization.slug, user.id, prisma, prisma);
21+
22+
expect(resolved).toBe(organization.id);
23+
});
24+
25+
postgresTest("returns null for a non-member, the tenant floor", async ({ prisma }) => {
26+
const { organization: target } = await createTestOrgProjectWithMember(prisma);
27+
const outsider = await createTestUser(prisma);
28+
29+
const resolved = await resolveOrgIdFromSlugForUser(target.slug, outsider.id, prisma, prisma);
30+
31+
// The unscoped resolver still hands the same non-member the org id: this is the exact gap the
32+
// membership filter closes, and why scoping by slug alone was the hole.
33+
const unscoped = await resolveOrgIdFromSlug(target.slug, prisma, prisma);
34+
expect(unscoped).toBe(target.id);
35+
expect(resolved).toBeNull();
36+
});
37+
});

0 commit comments

Comments
 (0)