Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-thread-dev-only-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

fix(cli): honor the MCP server's `--dev-only` flag
6 changes: 6 additions & 0 deletions .server-changes/account-access-control-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Harden account and access-control handling across auth, RBAC, org membership, and impersonation
6 changes: 6 additions & 0 deletions .server-changes/harden-alert-webhook-and-notification-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Harden URL handling for alert webhooks and platform notifications
6 changes: 6 additions & 0 deletions .server-changes/query-and-api-endpoint-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Harden the query and prompt-override APIs and rate-limit the query ai-title endpoint
6 changes: 6 additions & 0 deletions .server-changes/redact-secrets-from-logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Improve redaction of secrets from debug logs
6 changes: 6 additions & 0 deletions .server-changes/reject-cross-project-replay-environment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Tighten environment scoping when replaying a run.
6 changes: 6 additions & 0 deletions .server-changes/scope-run-batch-trigger-to-tenant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Scope run, batch, and trigger lookups to the caller's tenant
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon";
import { StarIcon } from "~/assets/icons/StarIcon";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { sanitizeHttpUrl } from "~/utils/sanitizeUrl";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { cn } from "~/utils/cn";
Expand Down Expand Up @@ -158,7 +159,7 @@ export function HelpAndFeedback({
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-text-dimmed"
activeIconColor="text-text-dimmed"
to={entry.actionUrl ?? "https://trigger.dev/changelog"}
to={sanitizeHttpUrl(entry.actionUrl) ?? "https://trigger.dev/changelog"}
target="_blank"
/>
))}
Expand Down
37 changes: 0 additions & 37 deletions apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,43 +74,6 @@ export async function getTeamMembersAndInvites({
return { members: org.members, invites: org.invites };
}

export async function removeTeamMember({
userId,
slug,
memberId,
}: {
userId: string;
slug: string;
memberId: string;
}) {
const org = await prisma.organization.findFirst({
where: { slug, members: { some: { userId } } },
});

if (!org) {
throw new Error("User does not have access to this organization");
}

// Scope the target to this org. A member id is a globally unique key, so
// deleting by id alone would remove members of other orgs; bind it to the
// resolved org and reject a foreign id.
const member = await prisma.orgMember.findFirst({
where: { id: memberId, organizationId: org.id },
include: {
organization: true,
user: true,
},
});

if (!member) {
throw new Error("Member not found in this organization");
}

await prisma.orgMember.delete({ where: { id: member.id } });

return member;
}

export async function inviteMembers({
slug,
emails,
Expand Down
16 changes: 10 additions & 6 deletions apps/webapp/app/models/orgIntegration.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { z } from "zod";
import { $transaction, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { slackSecretLogFields } from "./safeIntegrationLog";
import { slackAccessResultLogFields } from "./slackOAuthResultLog";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
Expand Down Expand Up @@ -205,9 +207,8 @@ export class OrgIntegrationRepository {
});

if (result.ok) {
logger.debug("Received slack access token", {
result,
});
// `result` carries Slack tokens; log only non-secret diagnostics.
logger.debug("Received slack access token", slackAccessResultLogFields(result));

if (!result.access_token) {
throw new Error("Failed to get access token");
Expand All @@ -230,9 +231,12 @@ export class OrgIntegrationRepository {
raw: result,
};

logger.debug("Setting secret", {
secretValue,
});
// `secretValue` carries the tokens encrypted below; log only
// non-secret fields.
logger.debug(
"Setting secret",
slackSecretLogFields(integrationFriendlyId, secretValue)
);

await secretStore.setSecret(integrationFriendlyId, secretValue);

Expand Down
41 changes: 41 additions & 0 deletions apps/webapp/app/models/removeTeamMember.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { PrismaClient } from "@trigger.dev/database";

// Leaf module with a type-only Prisma import (caller passes the client) so it
// can be unit-tested without importing `~/db.server`, which eagerly connects
// the global prisma singleton.
export async function removeTeamMember(
{
userId,
slug,
memberId,
}: {
userId: string;
slug: string;
memberId: string;
},
prismaClient: PrismaClient
) {
const org = await prismaClient.organization.findFirst({
where: { slug, members: { some: { userId } } },
});

if (!org) {
throw new Error("User does not have access to this organization");
}

// Scope both the lookup and the delete to org.id, in a transaction, so the
// member id is only ever resolved within the actor's organization.
return prismaClient.$transaction(async (tx) => {
const target = await tx.orgMember.findFirst({
where: { id: memberId, organizationId: org.id },
include: { organization: true, user: true },
});

if (!target) {
throw new Error("Member not found in this organization");
}

await tx.orgMember.delete({ where: { id: target.id } });
return target;
});
}
20 changes: 20 additions & 0 deletions apps/webapp/app/models/safeIntegrationLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Non-secret fields for logging a Slack integration secret: presence booleans
// and scope arrays only, never the token values. Dependency-free so it's
// unit-tested directly.
export type SlackSecretLike = {
botAccessToken?: string;
userAccessToken?: string;
refreshToken?: string;
botScopes?: string[];
userScopes?: string[];
};

export function slackSecretLogFields(friendlyId: string, secret: SlackSecretLike) {
return {
friendlyId,
hasUserToken: !!secret.userAccessToken,
hasRefreshToken: !!secret.refreshToken,
botScopes: secret.botScopes,
userScopes: secret.userScopes,
};
}
18 changes: 18 additions & 0 deletions apps/webapp/app/models/slackOAuthResultLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Non-secret fields for logging a Slack `oauth.v2.access` response, which
// otherwise carries bot/user/refresh tokens. Dependency-free so it's
// unit-tested directly.
export type SlackAccessResultLike = {
team?: { id?: string } | null;
scope?: string;
authed_user?: { access_token?: string } | null;
refresh_token?: string;
};

export function slackAccessResultLogFields(result: SlackAccessResultLike) {
return {
teamId: result.team?.id,
scope: result.scope,
hasUserToken: !!result.authed_user?.access_token,
hasRefreshToken: !!result.refresh_token,
};
}
4 changes: 2 additions & 2 deletions apps/webapp/app/models/user.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { DashboardPreferences } from "~/services/dashboardPreferences.serve
import { getDashboardPreferences } from "~/services/dashboardPreferences.server";
export type { User } from "@trigger.dev/database";
import { assertEmailAllowed } from "~/utils/email";
import { emailMatchesPattern } from "~/utils/emailPattern";
import { logger } from "~/services/logger.server";

type FindOrCreateMagicLink = {
Expand Down Expand Up @@ -74,8 +75,7 @@ export async function findOrCreateMagicLinkUser({
},
});

const adminEmailRegex = env.ADMIN_EMAILS ? new RegExp(env.ADMIN_EMAILS) : undefined;
const makeAdmin = adminEmailRegex ? adminEmailRegex.test(email) : false;
const makeAdmin = env.ADMIN_EMAILS ? emailMatchesPattern(env.ADMIN_EMAILS, email) : false;

const user = await prisma.user.upsert({
where: {
Expand Down
14 changes: 14 additions & 0 deletions apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { $replica } from "~/db.server";
import { clearImpersonation, redirectWithImpersonation } from "~/models/admin.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";

export async function loader({ request, params }: LoaderFunctionArgs) {
const user = await requireUser(request);
Expand All @@ -29,6 +31,18 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
return clearImpersonation(request, "/admin");
}

// CSRF gate for the SET-impersonation path. Clearing impersonation
// above is benign and stays reachable without the check.
if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
logger.warn("Refusing cross-site impersonation entry", {
userId: user.id,
organizationSlug,
referer: request.headers.get("referer"),
secFetchSite: request.headers.get("sec-fetch-site"),
});
return redirect("/admin");
}

const org = await $replica.organization.findFirst({
where: {
slug: organizationSlug,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
import { isAtOrBelow } from "~/utils/inviteRoleLadder";
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";

const Params = z.object({
Expand Down Expand Up @@ -109,43 +110,6 @@ export const loader = dashboardLoader(
// dropdown is hidden) or as a defensive default.
const NO_RBAC_ROLE = "__no_rbac_role__";

// An inviter can only assign a role at or below their own. The
// plugin's systemRoles array is in canonical order (highest authority
// first), so array index drives the ladder — earlier index = higher
// rank. Plan-tier filtering happens separately via assignableRoleIds;
// the ladder is the absolute hierarchy. Custom roles aren't in the
// ladder yet, so they're refused for now.
type LadderRole = { id: string };

function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
const level: Record<string, number> = {};
roles.forEach((r, i) => {
// Top of the array = highest level. Subtract from length so larger
// numbers always mean "more authority" — no off-by-one when a role
// is added or removed.
level[r.id] = roles.length - i;
});
return level;
}

function isAtOrBelow(
roles: ReadonlyArray<LadderRole>,
inviterRoleId: string | null,
invitedRoleId: string
): boolean {
// No resolvable role for the inviter → fail closed: we can't confirm a
// target role is at or below an unknown level, so refuse it. The invite
// itself still proceeds (it's gated by manage:members); only assigning an
// explicit role is refused, and the picker offers nothing in this case.
if (!inviterRoleId) return false;
const level = buildRoleLevel(roles);
const inviter = level[inviterRoleId];
const invited = level[invitedRoleId];
// Custom roles aren't in the level table — refuse.
if (inviter === undefined || invited === undefined) return false;
return invited <= inviter;
}

const schema = z.object({
emails: z.preprocess((i) => {
if (typeof i === "string") return [i];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import {
type CreateAlertChannelOptions,
CreateAlertChannelService,
} from "~/v3/services/alerts/createAlertChannel.server";
import {
assertSafeWebhookUrl,
UnsafeWebhookUrlError,
} from "~/v3/services/alerts/safeWebhookUrl.server";

const FormSchema = z
.object({
Expand Down Expand Up @@ -189,6 +193,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return json(submission.reply({ formErrors: ["Project not found"] }));
}

// Validate the webhook URL before storing it, for an inline field error.
if (submission.value.type === "WEBHOOK") {
try {
await assertSafeWebhookUrl(submission.value.channelValue);
} catch (error) {
if (error instanceof UnsafeWebhookUrlError) {
return json(submission.reply({ fieldErrors: { channelValue: [error.message] } }));
}
throw error;
}
}

const service = new CreateAlertChannelService();
const alertChannel = await service.call(
project.externalRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type CreateAlertChannelOptions,
CreateAlertChannelService,
} from "~/v3/services/alerts/createAlertChannel.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";

Expand Down Expand Up @@ -144,8 +145,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
deduplicationKey: `error-webhook:${url}:${environment.type}`,
channel: { type: "WEBHOOK", url },
};
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
try {
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
} catch (error) {
// CreateAlertChannelService rejects unsafe webhook URLs.
if (error instanceof ServiceValidationError) {
return json(submission.reply({ fieldErrors: { webhooks: [error.message] } }));
}
throw error;
}
}

const editableTypes = new Set<string>(["WEBHOOK"]);
Expand Down
Loading
Loading