fix: security release 2026-07-21 (#4528)

This commit is contained in:
Chris Arderne
2026-08-07 12:25:40 +01:00
committed by GitHub
parent db67a856fe
commit 0a44b88b39
10 changed files with 342 additions and 16 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Reject alert webhook destinations in reserved benchmarking IP ranges.
+31 -3
View File
@@ -15,6 +15,7 @@ import {
Prisma as PrismaNamespace,
prisma,
type PrismaClientOrTransaction,
type PrismaReplicaClient,
} from "~/db.server";
import { env } from "~/env.server";
import { featuresForUrl } from "~/features.server";
@@ -41,8 +42,12 @@ const nanoid = customAlphabet("1234567890abcdef", 4);
* miss, so replica lag never leaves a real org unresolved, which the dashboard
* route builder treats as an unauthorized request.
*/
export async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
const fromReplica = await $replica.organization.findFirst({
export async function resolveOrgIdFromSlug(
slug: string,
replicaClient: PrismaReplicaClient = $replica,
prismaClient: PrismaClientOrTransaction = prisma
): Promise<string | null> {
const fromReplica = await replicaClient.organization.findFirst({
where: { slug },
select: { id: true },
});
@@ -50,13 +55,36 @@ export async function resolveOrgIdFromSlug(slug: string): Promise<string | null>
return fromReplica.id;
}
const fromPrimary = await prisma.organization.findFirst({
const fromPrimary = await prismaClient.organization.findFirst({
where: { slug },
select: { id: true },
});
return fromPrimary?.id ?? null;
}
/**
* Like `resolveOrgIdFromSlug`, but only resolves an org the user is a member of. `ability.can` is not
* a tenant floor (the OSS fallback and the cloud plugin both return a permissive ability for a
* non-member), so a route that scopes only by slug lets a non-member reach the handler; the
* membership filter here is the tenant floor. Returns null for a non-member, which the dashboard
* route builder treats as no scope and fails closed.
*/
export async function resolveOrgIdFromSlugForUser(
slug: string,
userId: string,
replicaClient: PrismaReplicaClient = $replica,
prismaClient: PrismaClientOrTransaction = prisma
): Promise<string | null> {
const where = { slug, members: { some: { userId } } };
const fromReplica = await replicaClient.organization.findFirst({ where, select: { id: true } });
if (fromReplica) {
return fromReplica.id;
}
const fromPrimary = await prismaClient.organization.findFirst({ where, select: { id: true } });
return fromPrimary?.id ?? null;
}
export async function createOrganization(
{
title,
@@ -35,6 +35,7 @@ import {
import { Select, SelectItem } from "~/components/primitives/Select";
import { Switch } from "~/components/primitives/Switch";
import { prisma } from "~/db.server";
import { getUserId } from "~/services/session.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
@@ -53,11 +54,14 @@ export const meta = pageMeta("SSO & Directory Sync");
const Params = z.object({ organizationSlug: z.string() });
async function resolveOrg(slug: string) {
async function resolveOrg(slug: string, userId: string) {
// Scoped to membership: ability.can is not a tenant floor (the cloud RBAC
// plugin returns a permissive ability for a non-member), so without the
// members filter a non-member reaches the handler for any org slug.
// Primary (not replica): this scopes the RBAC/entitlement checks, so lag
// could run them against a stale/missing org.
return prisma.organization.findFirst({
where: { slug },
where: { slug, members: { some: { userId } } },
select: { id: true, title: true },
});
}
@@ -117,8 +121,10 @@ const EMPTY_SSO_STATUS = {
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const org = await resolveOrg(params.organizationSlug);
context: async (params, request) => {
const userId = await getUserId(request);
if (!userId) return {};
const org = await resolveOrg(params.organizationSlug, userId);
return org ? { organizationId: org.id, orgTitle: org.title } : {};
},
// Plan-gated before role-gated: non-Enterprise orgs render the upsell for
@@ -211,8 +217,10 @@ const ActionSchema = z.discriminatedUnion("action", [
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const org = await resolveOrg(params.organizationSlug);
context: async (params, request) => {
const userId = await getUserId(request);
if (!userId) return {};
const org = await resolveOrg(params.organizationSlug, userId);
return org ? { organizationId: org.id } : {};
},
authorization: { action: "manage", resource: { type: "sso" } },
@@ -37,7 +37,8 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { useUser } from "~/hooks/useUser";
import { removeTeamMember } from "~/models/removeTeamMember.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { resolveOrgIdFromSlugForUser } from "~/models/organization.server";
import { getUserId } from "~/services/session.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
import { rbac } from "~/services/rbac.server";
@@ -66,8 +67,10 @@ const Params = z.object({
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
context: async (params, request) => {
const userId = await getUserId(request);
if (!userId) return {};
const orgId = await resolveOrgIdFromSlugForUser(params.organizationSlug, userId);
return orgId ? { organizationId: orgId } : {};
},
authorization: { action: "read", resource: { type: "members" } },
@@ -127,8 +130,10 @@ const SetRoleSchema = z.object({
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
context: async (params, request) => {
const userId = await getUserId(request);
if (!userId) return {};
const orgId = await resolveOrgIdFromSlugForUser(params.organizationSlug, userId);
return orgId ? { organizationId: orgId } : {};
},
// No top-level authorization — different intents have different
@@ -42,6 +42,8 @@ function isUnsafeIPv4(host: string): boolean {
if (a === 169 && b === 254) return true;
// 100.64/10 carrier-grade NAT
if (a === 100 && b >= 64 && b <= 127) return true;
// 198.18/15 benchmarking
if (a === 198 && b >= 18 && b <= 19) return true;
// 224/4 multicast
if (a >= 224 && a <= 239) return true;
// 240/4 reserved
@@ -2,6 +2,8 @@
// Each test seeds a User + session cookie via seedTestUser / seedTestSession
// (helpers/seedTestSession.ts) and hits the shared webapp container.
import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, it } from "vitest";
import { getTestServer } from "./helpers/sharedTestServer";
import { seedTestSession, seedTestUser } from "./helpers/seedTestSession";
@@ -115,4 +117,66 @@ describe("Dashboard", () => {
expect(new URL(location, "http://localhost").pathname).toBe("/");
});
});
// Cross-tenant tenant floor on org settings routes. settings/roles is the case
// the route-level membership scoping (SSO/Team) did NOT cover, so it exercises
// the RBAC fallback's org-membership floor specifically: the fallback ability
// is permissive (can: () => true), so that floor is the only thing stopping a
// non-member from reading the org's role and permission catalogue.
//
// The request hits the route's own loader directly via Remix's `?_data`, which
// is the exact exploit shape: a plain document GET 404s at the org layout
// (membership) and never reaches this leaf, so it wouldn't test the leaf floor.
// Both users have confirmedBasicDetails set so the `_app` onboarding redirect
// can't stand in for the deny.
describe("Org settings — cross-tenant tenant floor (settings/roles)", () => {
const ROLES_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings.roles";
const rolesData = (slug: string) =>
`/orgs/${slug}/settings/roles?_data=${encodeURIComponent(ROLES_ROUTE_ID)}`;
async function seedConfirmedUser(prisma: PrismaClient) {
const user = await seedTestUser(prisma);
await prisma.user.update({ where: { id: user.id }, data: { confirmedBasicDetails: true } });
return user;
}
async function seedOrgWithOwner() {
const server = getTestServer();
const owner = await seedConfirmedUser(server.prisma);
const org = await server.prisma.organization.create({
data: {
title: "E2E tenant-floor org",
slug: `e2e-tenant-${randomBytes(6).toString("hex")}`,
members: { create: { userId: owner.id, role: "ADMIN" } },
},
});
return { server, owner, org };
}
it("denies a non-member: no roles catalogue leaked", async () => {
const { server, org } = await seedOrgWithOwner();
const outsider = await seedConfirmedUser(server.prisma);
const cookie = await seedTestSession({ userId: outsider.id });
const res = await server.webapp.fetch(rolesData(org.slug), {
redirect: "manual",
headers: { Cookie: cookie },
});
const body = await res.text();
// With the tenant floor a non-member is denied (a redirect), so they never
// get the loader's 200 payload. Before the fix the permissive ability let
// the loader return the org's role/permission catalogue.
expect(res.status).not.toBe(200);
expect(body).not.toContain("manage:members");
});
it("allows a member: the loader returns the catalogue", async () => {
const { server, owner, org } = await seedOrgWithOwner();
const cookie = await seedTestSession({ userId: owner.id });
const res = await server.webapp.fetch(rolesData(org.slug), {
redirect: "manual",
headers: { Cookie: cookie },
});
expect(res.status).toBe(200);
});
});
});
@@ -0,0 +1,110 @@
import { postgresTest } from "@internal/testcontainers";
import plugin from "@trigger.dev/rbac";
import { type PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import {
createTestOrgProjectWithMember,
createTestUser,
} from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
// The RBAC fallback ability is permissive (`can: () => true` for a non-admin), so
// `ability.can` is not a tenant floor. `authenticateSession` is the gate every
// org-scoped dashboard route relies on; a non-member in an org context must be
// denied here, or a permissive ability lets them act on any org whose slug they
// know. The route-level e2e (auth-dashboard.e2e.full) covers the HTTP path; this
// pins the fallback gate directly since that path can't run without a container.
function fallback(prisma: PrismaClient) {
// forceFallback skips the closed-source plugin and uses the in-repo fallback.
return plugin.create({ primary: prisma, replica: prisma }, { forceFallback: true });
}
const request = new Request("https://app.trigger.dev/orgs/x/settings/roles");
describe("RBAC fallback authenticateSession — org membership floor", () => {
postgresTest("denies a non-member in an org context", async ({ prisma }) => {
const { organization } = await createTestOrgProjectWithMember(prisma);
const outsider = await createTestUser(prisma);
const result = await fallback(prisma).authenticateSession(request, {
userId: outsider.id,
organizationId: organization.id,
});
expect(result).toMatchObject({ ok: false, reason: "unauthorized" });
});
postgresTest("allows a member in an org context", async ({ prisma }) => {
const { user, organization } = await createTestOrgProjectWithMember(prisma);
const result = await fallback(prisma).authenticateSession(request, {
userId: user.id,
organizationId: organization.id,
});
expect(result.ok).toBe(true);
});
postgresTest(
"stays permissive with no org context, even for a non-member",
async ({ prisma }) => {
// Identity-only checks (no organizationId) predate any scope, so the floor
// does not apply and the permissive baseline is preserved.
const outsider = await createTestUser(prisma);
const result = await fallback(prisma).authenticateSession(request, { userId: outsider.id });
expect(result.ok).toBe(true);
}
);
// A project-only scope is still a tenant claim, so the floor resolves the
// project's organization rather than letting the context through unchecked.
postgresTest("denies a non-member scoped only to a project", async ({ prisma }) => {
const { project } = await createTestOrgProjectWithMember(prisma);
const outsider = await createTestUser(prisma);
const result = await fallback(prisma).authenticateSession(request, {
userId: outsider.id,
projectId: project.id,
});
expect(result).toMatchObject({ ok: false, reason: "unauthorized" });
});
postgresTest("allows a member scoped only to a project", async ({ prisma }) => {
const { user, project } = await createTestOrgProjectWithMember(prisma);
const result = await fallback(prisma).authenticateSession(request, {
userId: user.id,
projectId: project.id,
});
expect(result.ok).toBe(true);
});
// The membership probe reads the replica first and the primary on a miss, so a
// member whose row has not replicated yet is not bounced. Modelled by giving
// the controller a replica that cannot see the row and a primary that can.
postgresTest("allows a member the replica has not caught up on", async ({ prisma }) => {
const { user, organization } = await createTestOrgProjectWithMember(prisma);
const blindReplica = {
...prisma,
orgMember: { findFirst: async () => null },
user: prisma.user,
project: prisma.project,
} as unknown as PrismaClient;
const controller = plugin.create(
{ primary: prisma, replica: blindReplica },
{ forceFallback: true }
);
const result = await controller.authenticateSession(request, {
userId: user.id,
organizationId: organization.id,
});
expect(result.ok).toBe(true);
});
});
@@ -0,0 +1,37 @@
import { postgresTest } from "@internal/testcontainers";
import { describe, expect, vi } from "vitest";
vi.setConfig({ testTimeout: 60_000 });
import { resolveOrgIdFromSlug, resolveOrgIdFromSlugForUser } from "~/models/organization.server";
import {
createTestOrgProjectWithMember,
createTestUser,
} from "./fixtures/environmentVariablesFixtures";
// The org settings routes resolve their org through this helper, so a non-member resolving to null
// is what makes the dashboard route builder fail closed. ability.can is not a tenant floor (the RBAC
// plugin and the OSS fallback both return a permissive ability for a non-member), so without the
// membership filter a non-member reached those routes for any org whose slug they knew: a live
// cross-tenant read of SSO/directory-sync config and an open set-role gate, confirmed on test-cloud.
describe("resolveOrgIdFromSlugForUser", () => {
postgresTest("resolves an org the user is a member of", async ({ prisma }) => {
const { user, organization } = await createTestOrgProjectWithMember(prisma);
const resolved = await resolveOrgIdFromSlugForUser(organization.slug, user.id, prisma, prisma);
expect(resolved).toBe(organization.id);
});
postgresTest("returns null for a non-member, the tenant floor", async ({ prisma }) => {
const { organization: target } = await createTestOrgProjectWithMember(prisma);
const outsider = await createTestUser(prisma);
const resolved = await resolveOrgIdFromSlugForUser(target.slug, outsider.id, prisma, prisma);
// The unscoped resolver still hands the same non-member the org id: this is the exact gap the
// membership filter closes, and why scoping by slug alone was the hole.
const unscoped = await resolveOrgIdFromSlug(target.slug, prisma, prisma);
expect(unscoped).toBe(target.id);
expect(resolved).toBeNull();
});
});
+13 -2
View File
@@ -48,8 +48,15 @@ describe("assertSafeWebhookUrl", () => {
).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
});
it("rejects CGNAT, multicast and reserved ranges", async () => {
for (const host of ["100.64.0.1", "224.0.0.1", "239.1.1.1", "240.0.0.1"]) {
it("rejects CGNAT, benchmarking, multicast and reserved ranges", async () => {
for (const host of [
"100.64.0.1",
"198.18.0.0",
"198.19.255.255",
"224.0.0.1",
"239.1.1.1",
"240.0.0.1",
]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
@@ -92,6 +99,8 @@ describe("assertSafeWebhookUrl", () => {
describe("assertAddressAllowed", () => {
it("allows public IPv4 / IPv6 addresses", () => {
expect(() => assertAddressAllowed("93.184.216.34", 4)).not.toThrow();
expect(() => assertAddressAllowed("198.17.255.255", 4)).not.toThrow();
expect(() => assertAddressAllowed("198.20.0.0", 4)).not.toThrow();
expect(() => assertAddressAllowed("2606:2800:220:1:248:1893:25c8:1946", 6)).not.toThrow();
});
@@ -104,6 +113,8 @@ describe("assertAddressAllowed", () => {
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
"198.18.0.0",
"198.19.255.255",
]) {
expect(() => assertAddressAllowed(addr, 4)).toThrow(UnsafeWebhookUrlError);
}
+55
View File
@@ -98,6 +98,21 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
const user = await this.replica.user.findFirst({ where: { id: context.userId } });
if (!user) return { ok: false, reason: "unauthenticated" };
// A non-member in a scoped context is denied, not handed a permissive
// ability. buildFallbackAbility is permissive for a non-admin
// (can: () => true), so ability.can is not a tenant floor; returning it here
// let a non-member act on any org whose slug they knew. An unscoped context
// stays permissive (identity-only checks predate any scope), and a platform
// admin keeps their ability.
if (!user.admin) {
const denied = await this.deniedByMembership(
context.organizationId,
context.projectId,
user.id
);
if (denied) return { ok: false, reason: "unauthorized" };
}
const subject: RbacSubject = {
type: "user",
userId: user.id,
@@ -113,6 +128,46 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
};
}
/**
* Whether a non-admin user is outside the tenant a scoped context names. A project-only scope
* resolves through the project's organization, so the floor holds whichever scope a route
* resolves; an unscoped context is not a tenant claim and is never denied here.
*
* Both lookups read the replica first and fall back to the primary before denying: org creation,
* invite acceptance and SSO provisioning all write to the primary, so a member who just joined
* must not be bounced while the row replicates.
*/
private async deniedByMembership(
organizationId: string | undefined,
projectId: string | undefined,
userId: string
): Promise<boolean> {
let orgId = organizationId;
if (!orgId && projectId) {
const project =
(await this.replica.project.findFirst({
where: { id: projectId },
select: { organizationId: true },
})) ??
(await this.prisma.project.findFirst({
where: { id: projectId },
select: { organizationId: true },
}));
// An unresolvable project names no tenant, so there is nothing to deny against.
if (!project) return false;
orgId = project.organizationId;
}
if (!orgId) return false;
const where = { organizationId: orgId, userId };
const member =
(await this.replica.orgMember.findFirst({ where, select: { id: true } })) ??
(await this.prisma.orgMember.findFirst({ where, select: { id: true } }));
return !member;
}
async authenticateAuthorizeBearer(
request: Request,
check: { action: string; resource: RbacResource | RbacResource[] },