fix: security release 2026-07-06 (#4199)

This commit is contained in:
Chris Arderne
2026-07-09 12:58:33 +01:00
committed by GitHub
parent bb450e608d
commit 34b1a181c2
88 changed files with 7803 additions and 5431 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
fix(cli): honor the MCP server's `--dev-only` flag
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Harden account and access-control handling across auth, RBAC, org membership, and impersonation
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Harden URL handling for alert webhooks and platform notifications
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Harden the query and prompt-override APIs and rate-limit the query ai-title endpoint
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Improve redaction of secrets from debug logs
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Tighten environment scoping when replaying a run.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Scope run, batch, and trigger lookups to the caller's tenant
@@ -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";
@@ -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"
/>
))}
-37
View File
@@ -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,
@@ -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";
@@ -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");
@@ -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);
@@ -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;
});
}
@@ -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,
};
}
@@ -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,
};
}
+2 -2
View File
@@ -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 = {
@@ -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: {
@@ -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);
@@ -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,
@@ -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({
@@ -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];
@@ -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({
@@ -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,
@@ -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";
@@ -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"]);
@@ -46,11 +46,11 @@ import * as Property from "~/components/primitives/PropertyTable";
import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { $replica } from "~/db.server";
import { $replica, prisma } from "~/db.server";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useOrganization } from "~/hooks/useOrganizations";
import { useUser } from "~/hooks/useUser";
import { removeTeamMember } from "~/models/member.server";
import { removeTeamMember } from "~/models/removeTeamMember.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
@@ -262,12 +262,9 @@ export const action = dashboardAction(
return json(submission.reply());
}
// Default intent: remove a member or leave the org. Scope the target to
// the actor's organization: an orgMember id is a globally unique key, so an
// unscoped lookup (plus an unscoped delete in the model) would let a
// manager in one org remove members of another by submitting a foreign id.
// Self-leave is always allowed; removing someone else requires
// manage:members.
// Default intent: remove a member or leave the org, with the target scoped
// to the actor's organization. Self-leave is always allowed; removing
// someone else requires manage:members.
const orgId = context.organizationId;
if (!orgId) {
return json({ ok: false, error: "Organization not found" } as const, { status: 404 });
@@ -295,11 +292,14 @@ export const action = dashboardAction(
}
try {
const deletedMember = await removeTeamMember({
userId,
memberId: submission.value.memberId,
slug: organizationSlug,
});
const deletedMember = await removeTeamMember(
{
userId,
memberId: submission.value.memberId,
slug: organizationSlug,
},
prisma
);
// Sticky removal: record a tombstone so passive SSO-JIT won't re-add
// them on next login (best-effort; no-op without the SSO plugin).
@@ -67,6 +67,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "webhook url is required" }, { status: 422 });
}
// The webhook URL is validated in CreateAlertChannelService.call();
// an unsafe URL surfaces as a ServiceValidationError -> 422 below.
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
name: body.data.name,
alertTypes: body.data.alertTypes.map((type) =>
@@ -13,7 +13,11 @@ const CreateBody = z.object({
textContent: z.string(),
model: z.string().optional(),
commitMessage: z.string().optional(),
source: z.string().optional(),
// `code` is reserved for deploy-created versions.
source: z
.string()
.refine((source) => source !== "code")
.optional(),
});
const UpdateBody = z.object({
+13 -12
View File
@@ -5,6 +5,7 @@ import { createActionApiRoute, everyResource } from "~/services/routeBuilders/ap
import { executeQuery, type QueryScope } from "~/services/queryService.server";
import { logger } from "~/services/logger.server";
import { rowsToCSV } from "~/utils/dataExport";
import { detectQueryTables } from "~/v3/detectQueryTables";
import { querySchemas } from "~/v3/querySchemas";
const BodySchema = z.object({
@@ -16,14 +17,12 @@ const BodySchema = z.object({
format: z.enum(["json", "csv"]).default("json"),
});
/** Extract table names from a TRQL query for authorization */
function detectTables(query: string): string[] {
return querySchemas
.filter((s) => {
const escaped = s.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`\\bFROM\\s+${escaped}\\b`, "i").test(query);
})
.map((s) => s.name);
const allowedQueryTables = new Set(querySchemas.map((s) => s.name));
/** Every table the query reads, for per-table JWT-scope authorization.
* `null` means unparseable — callers deny by default. */
function detectTables(query: string): string[] | null {
return detectQueryTables(query, allowedQueryTables);
}
const { action, loader } = createActionApiRoute(
@@ -34,12 +33,14 @@ const { action, loader } = createActionApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
// A multi-table query reads from every detected table. Wrap with
// everyResource so a JWT scoped to one table can't pass auth for
// a query that also reads tables it isn't scoped to (would be the
// same OR-loophole the batch trigger route had pre-fix).
// A multi-table query reads from every detected table, so wrap with
// everyResource: a JWT scoped to one table must not pass auth for a
// query that also reads tables it isn't scoped to.
resource: (_, __, ___, body) => {
const tables = detectTables(body.query);
// Unparseable query → deny. It must not fall through to the
// permissive {type:"query",id:"all"} branch.
if (tables === null) return { type: "query", id: "__unparseable__" };
return tables.length > 0
? everyResource(tables.map((id) => ({ type: "query", id })))
: { type: "query", id: "all" };
+1 -1
View File
@@ -25,7 +25,7 @@ import {
redirectWithErrorMessage,
} from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server";
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiterGlobal.server";
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
@@ -3,8 +3,13 @@ import type { ActionFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { assertExhaustive } from "@trigger.dev/core/utils";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { sanitizeRedirectPath } from "~/utils";
import { runStore } from "~/v3/runStore.server";
import { findBatchRunIdForUser } from "~/v3/services/batchRunAccess.server";
import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server";
export const checkCompletionSchema = z.object({
@@ -16,6 +21,8 @@ const ParamSchema = z.object({
});
export const action: ActionFunction = async ({ request, params }) => {
// Require a logged-in user; org membership is checked below before resuming.
const userId = await requireUserId(request);
const { batchId } = ParamSchema.parse(params);
const formData = await request.formData();
@@ -25,9 +32,22 @@ export const action: ActionFunction = async ({ request, params }) => {
return json(submission.reply());
}
// Keep the post-action redirect same-origin.
const safeRedirectUrl = sanitizeRedirectPath(submission.value.redirectUrl);
// Only act on a batch in an org the caller belongs to. Accepts either the
// friendlyId or the internal id; both forms stay org-scoped.
const ownedBatchRunId = await findBatchRunIdForUser(prisma, runStore, batchId, userId);
if (!ownedBatchRunId) {
return redirectWithErrorMessage(safeRedirectUrl, request, "Batch not found");
}
try {
const resumeBatchRunService = new ResumeBatchRunService();
const resumeResult = await resumeBatchRunService.call(batchId);
// Resume by the resolved internal id: the service looks up strictly by
// `{ id }`, so passing a friendlyId param would resolve to nothing.
const resumeResult = await resumeBatchRunService.call(ownedBatchRunId);
let message: string | undefined;
@@ -52,7 +72,7 @@ export const action: ActionFunction = async ({ request, params }) => {
}
}
return redirectWithSuccessMessage(submission.value.redirectUrl, request, message);
return redirectWithSuccessMessage(safeRedirectUrl, request, message);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to check batch completion", {
@@ -62,10 +82,10 @@ export const action: ActionFunction = async ({ request, params }) => {
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.redirectUrl, request, error.message);
return redirectWithErrorMessage(safeRedirectUrl, request, error.message);
} else {
logger.error("Failed to check batch completion", { error });
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Unknown error");
return redirectWithErrorMessage(safeRedirectUrl, request, "Unknown error");
}
}
};
@@ -8,10 +8,15 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { aiTitleRateLimiter } from "~/v3/services/aiTitleRateLimiter.server";
import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server";
// `/resources/*` isn't covered by the global apiRateLimiter (`/api/*` only),
// so this endpoint needs its own per-route limiter and length cap.
const MAX_QUERY_LENGTH = 10_000;
const RequestSchema = z.object({
query: z.string().min(1, "Query is required"),
query: z.string().min(1, "Query is required").max(MAX_QUERY_LENGTH),
queryId: z.string().optional(),
});
@@ -19,6 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const limit = await aiTitleRateLimiter.limit(`user:${userId}`);
if (!limit.success) {
return json(
{
success: false as const,
error: "Too many requests — please wait a moment and try again.",
title: null,
},
{ status: 429 }
);
}
// Parse the request body
const [error, data] = await tryCatch(request.json());
if (error) {
@@ -248,6 +248,22 @@ export class IdempotencyKeyConcern {
//We're using `andWait` so we need to block the parent run with a waitpoint
if (resumeParentOnCompletion && parentRunId) {
// `parentRunId` comes from the request body and isn't re-validated
// here, so confirm the parent run is in the caller's environment
// before wiring a waitpoint against it.
const parentRunInternalId = RunId.fromFriendlyId(parentRunId);
const parentRunInCallerEnv = await runStore.findRun(
{
id: parentRunInternalId,
runtimeEnvironmentId: request.environment.id,
},
{ select: { id: true } },
this.prisma
);
if (!parentRunInCallerEnv) {
throw new ServiceValidationError("Parent run not found in the calling environment", 404);
}
// Get or create waitpoint lazily (existing run may not have one if it was standalone)
let associatedWaitpoint = existingRun.associatedWaitpoint;
if (!associatedWaitpoint) {
@@ -276,7 +292,7 @@ export class IdempotencyKeyConcern {
: event.spanId;
await this.engine.blockRunWithWaitpoint({
runId: RunId.fromFriendlyId(parentRunId),
runId: parentRunInternalId,
waitpoints: associatedWaitpoint!.id,
spanIdToComplete: spanId,
batch: request.options?.batchId
+7 -4
View File
@@ -14,6 +14,8 @@ import {
} from "~/models/runtimeEnvironment.server";
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { logger } from "./logger.server";
import { safeEnvironmentLogFields } from "./safeEnvironmentLog";
import { missingJwtLogContext } from "./safeRequestLogContext";
import {
type PersonalAccessTokenAuthenticationResult,
authenticateApiRequestWithPersonalAccessToken,
@@ -673,9 +675,9 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
const jwt = request.headers.get("x-trigger-jwt");
if (!jwt) {
logger.debug("Missing JWT token in request", {
headers: Object.fromEntries(request.headers),
});
// Log a safe breadcrumb, not the raw headers (which carry the
// caller's Authorization credential).
logger.debug("Missing JWT token in request", missingJwtLogContext(request));
return;
}
@@ -735,8 +737,9 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
...payload.data,
});
// The environment carries secret material; log only non-secret fields.
logger.debug("Renewed JWT token from Authorization header API Key", {
environment: authenticatedEnv.environment,
environment: safeEnvironmentLogFields(authenticatedEnv.environment),
payload: payload.data,
});
@@ -3,6 +3,7 @@ import { GoogleStrategy } from "remix-auth-google";
import { env } from "~/env.server";
import { findOrCreateUser } from "~/models/user.server";
import type { AuthUser } from "./authUser";
import { isGoogleEmailVerified } from "./googleEmailVerification";
import { logger } from "./logger.server";
import { postAuthentication } from "./postAuth.server";
import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server";
@@ -27,6 +28,14 @@ export function addGoogleStrategy(
const email = emails[0].value;
// Only trust the email if Google asserts it's verified, since account
// linking keys off it. See isGoogleEmailVerified.
if (!isGoogleEmailVerified(profile)) {
throw new Error(
"Google login refused: the Google account's email is not verified. Sign in with an account whose email Google has verified, or use magic-link / GitHub."
);
}
// SSO auto-discovery gate — BEFORE findOrCreateUser, so an
// SSO-enforced domain never gets this Google identity linked onto
// an existing account.
@@ -0,0 +1,15 @@
import type { GoogleProfile } from "remix-auth-google";
/**
* Whether Google has asserted that the profile's email is verified. A
* successful OAuth flow proves control of the Google account, not ownership of
* the email it carries, and account linking keys off the email.
*
* Strict by design: only a real boolean `true` counts. A missing claim, missing
* `_json`, the string `"true"`, or a truthy `1` are all treated as unverified.
*/
export function isGoogleEmailVerified(profile: GoogleProfile): boolean {
const emailVerified = (profile as { _json?: { email_verified?: unknown } })?._json
?.email_verified;
return emailVerified === true;
}
@@ -1,27 +1,51 @@
import { Ratelimit } from "@upstash/ratelimit";
import { env } from "~/env.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
import { singleton } from "~/utils/singleton";
import { type RedisWithClusterOptions } from "~/redis.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiterCore.server";
export const mfaRateLimiter = singleton("mfaRateLimiter", initializeMfaRateLimiter);
// MFA rate limiting: two sliding windows in series, both of which must pass.
// A per-minute window covers interactive retries; a cumulative daily window
// caps total attempts per pending-MFA session.
//
// Free of `env.server` so it can be tested directly against a container Redis;
// the env-derived production singletons live in `mfaRateLimiterGlobal.server.ts`.
function initializeMfaRateLimiter() {
const redisClient = createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
});
// Production policy. Exported so tests assert against the real numbers.
export const MFA_PER_MINUTE_ATTEMPTS = 5;
export const MFA_DAILY_ATTEMPTS = 30;
return new RateLimiter({
redisClient,
keyPrefix: "mfa:validation",
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 attempts per minute
logSuccess: false, // Don't log successful attempts for privacy
logFailure: true, // Log rate limit violations for security monitoring
});
export type MfaRateLimiters = {
perMinute: Pick<RateLimiter, "limit">;
daily: Pick<RateLimiter, "limit">;
};
/**
* Build the pair of MFA rate limiters. Production passes the env-derived
* Redis connection and the default policy; tests inject a container
* Redis (and may override the attempt caps to isolate one window).
*/
export function createMfaRateLimiters(options: {
redisOptions: RedisWithClusterOptions;
perMinuteAttempts?: number;
dailyAttempts?: number;
}): { perMinute: RateLimiter; daily: RateLimiter } {
const redisClient = createRedisRateLimitClient(options.redisOptions);
return {
perMinute: new RateLimiter({
redisClient,
keyPrefix: "mfa:validation",
limiter: Ratelimit.slidingWindow(options.perMinuteAttempts ?? MFA_PER_MINUTE_ATTEMPTS, "1 m"),
logSuccess: false,
logFailure: true,
}),
daily: new RateLimiter({
redisClient,
keyPrefix: "mfa:validation:daily",
limiter: Ratelimit.slidingWindow(options.dailyAttempts ?? MFA_DAILY_ATTEMPTS, "24 h"),
logSuccess: false,
logFailure: true,
}),
};
}
export class MfaRateLimitError extends Error {
@@ -34,13 +58,20 @@ export class MfaRateLimitError extends Error {
}
/**
* Check if the user can attempt MFA validation
* Check whether the user can attempt MFA validation, enforcing both the
* per-minute and the daily cap. The daily cap is checked first.
* @param userId - The user ID to rate limit
* @throws {MfaRateLimitError} If rate limit is exceeded
* @param limiters - The limiter pair (production singletons or test-injected)
* @throws {MfaRateLimitError} If either rate limit is exceeded
*/
export async function checkMfaRateLimit(userId: string): Promise<void> {
const result = await mfaRateLimiter.limit(userId);
export async function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters): Promise<void> {
const dailyResult = await limiters.daily.limit(userId);
if (!dailyResult.success) {
const retryAfter = new Date(dailyResult.reset).getTime() - Date.now();
throw new MfaRateLimitError(retryAfter);
}
const result = await limiters.perMinute.limit(userId);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MfaRateLimitError(retryAfter);
@@ -0,0 +1,37 @@
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import {
checkMfaRateLimit as checkMfaRateLimitWith,
createMfaRateLimiters,
type MfaRateLimiters,
} from "./mfaRateLimiter.server";
// Production singletons, wired to the env-derived rate-limit Redis.
// Kept out of `mfaRateLimiter.server.ts` so that module stays free of
// `env.server` and remains testable in isolation (see that file).
const mfaRateLimiters = singleton("mfaRateLimiters", () =>
createMfaRateLimiters({
redisOptions: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
})
);
export const mfaRateLimiter = mfaRateLimiters.perMinute;
export const mfaDailyRateLimiter = mfaRateLimiters.daily;
/**
* Production entrypoint: rate-limit an MFA validation attempt for `userId`
* against the env-configured limiter pair. Throws `MfaRateLimitError` when
* either the per-minute or the cumulative daily cap is exceeded.
*/
export function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters = mfaRateLimiters) {
return checkMfaRateLimitWith(userId, limiters);
}
export { MfaRateLimitError } from "./mfaRateLimiter.server";
@@ -30,13 +30,30 @@ const DiscoverySchema = z.object({
matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
});
// Constrain URL fields to http/https; `.url()` alone accepts other schemes
// that would be unsafe to render into an `<a href>`.
const httpUrl = z
.string()
.url()
.refine(
(v) => {
try {
const proto = new URL(v).protocol;
return proto === "http:" || proto === "https:";
} catch {
return false;
}
},
{ message: "URL must use http or https" }
);
const CardDataV1Schema = z.object({
type: z.enum(["card", "info", "warn", "error", "success", "changelog"]),
title: z.string(),
description: z.string(),
image: z.string().url().optional(),
image: httpUrl.optional(),
actionLabel: z.string().optional(),
actionUrl: z.string().url().optional(),
actionUrl: httpUrl.optional(),
dismissOnAction: z.boolean().optional(),
discovery: DiscoverySchema.optional(),
});
+26 -92
View File
@@ -1,12 +1,21 @@
import { Ratelimit } from "@upstash/ratelimit";
import type { RedisOptions } from "ioredis";
import { env } from "~/env.server";
import type { RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { logger } from "./logger.server";
import {
RateLimiter as CoreRateLimiter,
type Limiter,
type RateLimiterRedisClient,
} from "./rateLimiterCore.server";
export {
createRedisRateLimitClient,
type Duration,
type Limiter,
type RateLimitResponse,
type RateLimiterRedisClient,
} from "./rateLimiterCore.server";
type Options = {
redis?: RedisOptions;
redis?: RedisWithClusterOptions;
redisClient?: RateLimiterRedisClient;
keyPrefix: string;
limiter: Limiter;
@@ -14,93 +23,18 @@ type Options = {
logFailure?: boolean;
};
export type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];
export type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
export type RateLimitResponse = Awaited<ReturnType<Ratelimit["limit"]>>;
export type RateLimiterRedisClient = ConstructorParameters<typeof Ratelimit>[0]["redis"];
export class RateLimiter {
#ratelimit: Ratelimit;
constructor(private readonly options: Options) {
const { redis, redisClient, keyPrefix, limiter } = options;
const prefix = `ratelimit:${keyPrefix}`;
this.#ratelimit = new Ratelimit({
redis:
redisClient ??
createRedisRateLimitClient(
redis ?? {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
}
),
limiter,
ephemeralCache: new Map(),
analytics: false,
prefix,
export class RateLimiter extends CoreRateLimiter {
constructor(options: Options) {
super({
...options,
redis: options.redis ?? {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
}
async limit(identifier: string, rate = 1): Promise<RateLimitResponse> {
const result = this.#ratelimit.limit(identifier, { rate });
const { success, limit, reset, remaining } = await result;
if (success && this.options.logSuccess) {
logger.info(`RateLimiter (${this.options.keyPrefix}): under rate limit`, {
limit,
reset,
remaining,
identifier,
});
}
//log these by default
if (!success && this.options.logFailure !== false) {
logger.info(`RateLimiter (${this.options.keyPrefix}): rate limit exceeded`, {
limit,
reset,
remaining,
identifier,
});
}
return result;
}
}
export function createRedisRateLimitClient(
redisOptions: RedisWithClusterOptions
): RateLimiterRedisClient {
const redis = createRedisClient("trigger:rateLimiter", redisOptions);
return {
sadd: async <TData>(key: string, ...members: TData[]): Promise<number> => {
return redis.sadd(key, members as (string | number | Buffer)[]);
},
hset: <TValue>(
key: string,
obj: {
[key: string]: TValue;
}
): Promise<number> => {
return redis.hset(key, obj);
},
eval: <TArgs extends unknown[], TData = unknown>(
...args: [script: string, keys: string[], args: TArgs]
): Promise<TData> => {
const script = args[0];
const keys = args[1];
const argsArray = args[2];
return redis.eval(
script,
keys.length,
...keys,
...(argsArray as (string | Buffer | number)[])
) as Promise<TData>;
},
};
}
@@ -0,0 +1,100 @@
import { Ratelimit } from "@upstash/ratelimit";
import type { RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { logger } from "./logger.server";
type Options = {
redis?: RedisWithClusterOptions;
redisClient?: RateLimiterRedisClient;
keyPrefix: string;
limiter: Limiter;
logSuccess?: boolean;
logFailure?: boolean;
};
export type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];
export type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
export type RateLimitResponse = Awaited<ReturnType<Ratelimit["limit"]>>;
export type RateLimiterRedisClient = ConstructorParameters<typeof Ratelimit>[0]["redis"];
export class RateLimiter {
#ratelimit: Ratelimit;
constructor(private readonly options: Options) {
const { redis, redisClient, keyPrefix, limiter } = options;
const prefix = `ratelimit:${keyPrefix}`;
const resolvedRedisClient =
redisClient ?? (redis ? createRedisRateLimitClient(redis) : undefined);
if (!resolvedRedisClient) {
throw new Error("RateLimiter requires either redis or redisClient options");
}
this.#ratelimit = new Ratelimit({
redis: resolvedRedisClient,
limiter,
ephemeralCache: new Map(),
analytics: false,
prefix,
});
}
async limit(identifier: string, rate = 1): Promise<RateLimitResponse> {
const result = this.#ratelimit.limit(identifier, { rate });
const { success, limit, reset, remaining } = await result;
if (success && this.options.logSuccess) {
logger.info(`RateLimiter (${this.options.keyPrefix}): under rate limit`, {
limit,
reset,
remaining,
identifier,
});
}
//log these by default
if (!success && this.options.logFailure !== false) {
logger.info(`RateLimiter (${this.options.keyPrefix}): rate limit exceeded`, {
limit,
reset,
remaining,
identifier,
});
}
return result;
}
}
export function createRedisRateLimitClient(
redisOptions: RedisWithClusterOptions
): RateLimiterRedisClient {
const redis = createRedisClient("trigger:rateLimiter", redisOptions);
return {
sadd: async <TData>(key: string, ...members: TData[]): Promise<number> => {
return redis.sadd(key, members as (string | number | Buffer)[]);
},
hset: <TValue>(
key: string,
obj: {
[key: string]: TValue;
}
): Promise<number> => {
return redis.hset(key, obj);
},
eval: <TArgs extends unknown[], TData = unknown>(
...args: [script: string, keys: string[], args: TArgs]
): Promise<TData> => {
const script = args[0];
const keys = args[1];
const argsArray = args[2];
return redis.eval(
script,
keys.length,
...keys,
...(argsArray as (string | Buffer | number)[])
) as Promise<TData>;
},
};
}
@@ -0,0 +1,19 @@
// Non-secret subset of an AuthenticatedEnvironment for logging (the full shape
// carries the env's apiKey). Dependency-free so it's unit-tested directly.
export type EnvironmentForLog = {
id: string;
slug: string;
type: string;
projectId: string;
organizationId: string;
};
export function safeEnvironmentLogFields(environment: EnvironmentForLog) {
return {
id: environment.id,
slug: environment.slug,
type: environment.type,
projectId: environment.projectId,
organizationId: environment.organizationId,
};
}
@@ -0,0 +1,15 @@
// A safe breadcrumb for logging an inbound API request. Must never include
// header *values*, only presence — the Authorization header carries the
// caller's credential. Dependency-free so it's unit-tested directly.
export function missingJwtLogContext(request: Request): {
method: string;
path: string;
hasAuthorization: boolean;
} {
const url = new URL(request.url);
return {
method: request.method,
path: url.pathname,
hasAuthorization: request.headers.has("authorization"),
};
}
+2 -3
View File
@@ -1,13 +1,12 @@
import { env } from "~/env.server";
import { emailMatchesPattern } from "./emailPattern";
export function assertEmailAllowed(email: string) {
if (!env.WHITELISTED_EMAILS) {
return;
}
const regexp = new RegExp(env.WHITELISTED_EMAILS);
if (!regexp.test(email)) {
if (!emailMatchesPattern(env.WHITELISTED_EMAILS, email)) {
throw new Error("This email is unauthorized");
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Match an email against an operator-supplied pattern (ADMIN_EMAILS /
* WHITELISTED_EMAILS), anchored to the whole address with `^(?:...)$` so the
* pattern matches the entire email rather than a substring.
*
* The non-capturing group keeps top-level alternation working
* (`a@x.com|b@x.com` stays two whole-string alternatives). Patterns that
* already carry their own `^`/`$` anchors remain equivalent. A top-level
* alternative that is just `@domain.tld` is expanded to "any mailbox at exactly
* that domain".
*
* Dependency-free so it can be tested directly; callers pass the pattern from `env`.
*/
export function emailMatchesPattern(pattern: string, email: string): boolean {
return new RegExp(`^(?:${expandDomainShorthand(pattern)})$`).test(email);
}
function expandDomainShorthand(pattern: string): string {
return splitTopLevelAlternatives(pattern)
.map((alternative) => {
const domain = alternative.match(/^@([A-Za-z0-9.-]+)$/)?.[1];
return domain ? `[^@]+@${escapeRegExp(domain)}` : alternative;
})
.join("|");
}
function splitTopLevelAlternatives(pattern: string): string[] {
const alternatives: string[] = [];
let current = "";
let escaped = false;
let depth = 0;
let inCharacterClass = false;
for (const char of pattern) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
current += char;
escaped = true;
continue;
}
if (char === "[" && !inCharacterClass) {
inCharacterClass = true;
} else if (char === "]" && inCharacterClass) {
inCharacterClass = false;
} else if (!inCharacterClass && char === "(") {
depth++;
} else if (!inCharacterClass && char === ")" && depth > 0) {
depth--;
}
if (char === "|" && depth === 0 && !inCharacterClass) {
alternatives.push(current);
current = "";
continue;
}
current += char;
}
alternatives.push(current);
return alternatives;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+33
View File
@@ -0,0 +1,33 @@
// An inviter can only assign a role at or below their own. The systemRoles
// array is in canonical order (highest authority first), so array index drives
// the ladder. Custom roles aren't in the table and are refused. Dependency-free
// so the rule can be unit-tested directly.
export type LadderRole = { id: string };
export function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
const level: Record<string, number> = {};
roles.forEach((r, i) => {
// Top of the array = highest level; larger number means more authority.
level[r.id] = roles.length - i;
});
return level;
}
/**
* Whether an inviter holding `inviterRoleId` may assign `invitedRoleId`.
* A roleless inviter (`inviterRoleId == null`) and custom/unknown roles absent
* from the ladder are all refused.
*/
export function isAtOrBelow(
roles: ReadonlyArray<LadderRole>,
inviterRoleId: string | null,
invitedRoleId: string
): boolean {
if (!inviterRoleId) return false;
const level = buildRoleLevel(roles);
const inviter = level[inviterRoleId];
const invited = level[invitedRoleId];
if (inviter === undefined || invited === undefined) return false;
return invited <= inviter;
}
@@ -0,0 +1,21 @@
/**
* Whether `request` is an unambiguously same-origin navigation, used to
* CSRF-gate state-changing GET routes. `allowedOrigin` is the dashboard origin
* (caller passes `env.LOGIN_ORIGIN`, kept out so the rule stays testable).
*
* Deny-by-default: prefer `Sec-Fetch-Site: same-origin` when present, otherwise
* require a `Referer` whose origin matches `allowedOrigin`. Anything
* missing/cross-site/unparseable returns `false`.
*/
export function isSameOriginNavigation(request: Request, allowedOrigin: string): boolean {
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite) return fetchSite === "same-origin";
const referer = request.headers.get("referer");
if (!referer) return false;
try {
return new URL(referer).origin === new URL(allowedOrigin).origin;
} catch {
return false;
}
}
+14
View File
@@ -0,0 +1,14 @@
// Return the URL only if it uses an http(s) scheme, else `undefined` so callers
// can fall back to a default. Use for any URL rendered into an `<a href>`.
const SAFE_HTTP_PROTOCOLS = new Set(["http:", "https:"]);
export function sanitizeHttpUrl(url: string | undefined | null): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url);
return SAFE_HTTP_PROTOCOLS.has(parsed.protocol) ? parsed.href : undefined;
} catch {
return undefined;
}
}
+113
View File
@@ -0,0 +1,113 @@
import {
parseTSQLSelect,
SyntaxError as TSQLSyntaxError,
type Field,
type JoinExpr,
type SelectQuery,
type SelectSetQuery,
} from "@internal/tsql";
/**
* Extract every known table a TRQL query reads — the FROM table, every JOIN in
* the chain, and any subqueries — for per-table JWT-scope authorization.
*
* `allowedTableNames` is the set of recognised table names (matched
* case-insensitively); anything not in it is ignored. Injected so this stays
* dependency-free (the caller derives it from the query schemas).
*
* Returns `null` when the query can't be parsed; callers MUST treat `null` as
* deny-by-default.
*/
export function detectQueryTables(query: string, allowedTableNames: Set<string>): string[] | null {
let ast: SelectQuery | SelectSetQuery;
try {
ast = parseTSQLSelect(query);
} catch (err) {
if (err instanceof TSQLSyntaxError) return null;
throw err;
}
const allowed = new Map(Array.from(allowedTableNames, (n) => [n.toLowerCase(), n]));
const seen = new Set<string>();
const scanned = new WeakSet<object>();
function visitSelect(q: SelectQuery): void {
// CTE bodies: `WITH r AS (SELECT ... FROM <table>) ...` — the table is
// read by the CTE even when the outer query only references the CTE alias.
if (q.ctes) {
for (const cte of Object.values(q.ctes)) {
scanForSubqueries(cte.expr);
}
}
// FROM / JOIN chain (tables + FROM-position subqueries).
if (q.select_from) visitJoin(q.select_from);
// Subqueries anywhere else (WHERE, SELECT list, GROUP BY, ORDER BY, etc.)
// can each embed a SELECT that reads a real table, e.g.
// `WHERE id IN (SELECT … FROM runs)`.
scanForSubqueries(q.select);
scanForSubqueries(q.where);
scanForSubqueries(q.prewhere);
scanForSubqueries(q.having);
scanForSubqueries(q.group_by);
scanForSubqueries(q.array_join_list);
scanForSubqueries(q.order_by);
scanForSubqueries(q.limit);
scanForSubqueries(q.offset);
scanForSubqueries(q.limit_by);
scanForSubqueries(q.window_exprs);
}
// Shape-agnostic walk of an expression subtree: descends every nested
// object/array and hands any embedded SELECT to the query visitors, so a new
// node shape can't silently reintroduce a detection gap. The WeakSet guards
// against back-reference cycles the AST might carry.
function scanForSubqueries(node: unknown): void {
if (node === null || typeof node !== "object") return;
if (scanned.has(node)) return;
scanned.add(node);
if (Array.isArray(node)) {
for (const item of node) scanForSubqueries(item);
return;
}
const expressionType = (node as { expression_type?: string }).expression_type;
if (expressionType === "select_query") {
visitSelect(node as SelectQuery);
return;
}
if (expressionType === "select_set_query") {
visitSelectSet(node as SelectSetQuery);
return;
}
for (const value of Object.values(node)) scanForSubqueries(value);
}
function visitSelectSet(q: SelectSetQuery): void {
visitAny(q.initial_select_query);
for (const node of q.subsequent_select_queries ?? []) {
visitAny(node.select_query);
}
}
function visitAny(q: SelectQuery | SelectSetQuery): void {
if (q.expression_type === "select_query") visitSelect(q);
else visitSelectSet(q);
}
function visitJoin(node: JoinExpr): void {
const tableExpr = node.table;
if (tableExpr) {
if ((tableExpr as Field).expression_type === "field") {
const name = (tableExpr as Field).chain[0];
const canonicalName =
typeof name === "string" ? allowed.get(name.toLowerCase()) : undefined;
if (canonicalName) seen.add(canonicalName);
} else if ((tableExpr as SelectQuery).expression_type === "select_query") {
visitSelect(tableExpr as SelectQuery);
} else if ((tableExpr as SelectSetQuery).expression_type === "select_set_query") {
visitSelectSet(tableExpr as SelectSetQuery);
}
}
if (node.next_join) visitJoin(node.next_join);
}
if (ast.expression_type === "select_set_query") visitSelectSet(ast);
else visitSelect(ast);
return Array.from(seen);
}
@@ -0,0 +1,25 @@
import { Ratelimit } from "@upstash/ratelimit";
import { type RedisWithClusterOptions } from "~/redis.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
import { singleton } from "~/utils/singleton";
// The query ai-title endpoint lives under `/resources/*`, which the global
// apiRateLimiter (only `/api/*`) does not cover, so it needs its own per-user
// cap. Exported so the policy is asserted in tests rather than re-encoded.
export const AI_TITLE_RATE_LIMIT_ATTEMPTS = 30;
export const AI_TITLE_RATE_LIMIT_WINDOW = "10 m" as const;
/**
* Build the ai-title per-user rate limiter. Production uses the env-derived
* rate-limit Redis; tests inject a container Redis.
*/
export function createAITitleRateLimiter(redisOptions?: RedisWithClusterOptions): RateLimiter {
return new RateLimiter({
...(redisOptions ? { redisClient: createRedisRateLimitClient(redisOptions) } : {}),
keyPrefix: "query.ai-title",
limiter: Ratelimit.slidingWindow(AI_TITLE_RATE_LIMIT_ATTEMPTS, AI_TITLE_RATE_LIMIT_WINDOW),
logFailure: true,
});
}
export const aiTitleRateLimiter = singleton("aiTitleRateLimiter", () => createAITitleRateLimiter());
@@ -10,6 +10,7 @@ import { encryptSecret } from "~/services/secrets/secretStore.server";
import { alertsWorker } from "~/v3/alertsWorker.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService, ServiceValidationError } from "../baseService.server";
import { assertSafeWebhookUrl, UnsafeWebhookUrlError } from "./safeWebhookUrl.server";
export type CreateAlertChannelOptions = {
name: string;
@@ -46,6 +47,19 @@ export class CreateAlertChannelService extends BaseService {
throw new ServiceValidationError("Project not found");
}
// Validate webhook URLs here (not per-route) so every caller is covered.
// Delivery re-validates at connect time via safeWebhookFetch.
if (options.channel.type === "WEBHOOK") {
try {
await assertSafeWebhookUrl(options.channel.url);
} catch (error) {
if (error instanceof UnsafeWebhookUrlError) {
throw new ServiceValidationError(error.message);
}
throw error;
}
}
const environmentTypes =
options.environmentTypes.length === 0
? (["STAGING", "PRODUCTION"] satisfies RuntimeEnvironmentType[])
@@ -49,6 +49,7 @@ import { alertsWorker } from "~/v3/alertsWorker.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { fromPromise } from "neverthrow";
import { BaseService } from "../baseService.server";
import { safeWebhookFetch } from "./safeWebhookFetch.server";
import { CURRENT_API_VERSION } from "~/api/versions";
import type { RunStore } from "@internal/run-store";
import type { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
@@ -1033,7 +1034,8 @@ export class DeliverAlertService extends BaseService {
const signature = await subtle.sign("HMAC", key, hashPayload);
const signatureHex = Buffer.from(signature).toString("hex");
const response = await fetch(webhook.url, {
// Deliver via the SSRF-safe wrapper (see safeWebhookFetch.server.ts).
const response = await safeWebhookFetch(webhook.url, {
method: "POST",
headers: {
"content-type": "application/json",
@@ -24,6 +24,7 @@ import { logger } from "~/services/logger.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { subtle } from "crypto";
import { generateErrorGroupWebhookPayload } from "./errorGroupWebhook.server";
import { safeWebhookFetch } from "./safeWebhookFetch.server";
type ErrorAlertClassification = "new_issue" | "regression" | "unignored";
@@ -255,7 +256,8 @@ export class DeliverErrorGroupAlertService {
const signature = await subtle.sign("HMAC", key, hashPayload);
const signatureHex = Buffer.from(signature).toString("hex");
const response = await fetch(webhookProperties.data.url, {
// Deliver via the SSRF-safe wrapper (see safeWebhookFetch.server.ts).
const response = await safeWebhookFetch(webhookProperties.data.url, {
method: "POST",
headers: {
"content-type": "application/json",
@@ -0,0 +1,161 @@
import http from "node:http";
import https from "node:https";
import { promises as dnsPromises } from "node:dns";
import type { LookupFunction } from "node:net";
import { logger } from "~/services/logger.server";
import {
assertAddressAllowed,
assertSafeWebhookUrl,
assertSafeWebhookUrlLexical,
UnsafeWebhookUrlError,
} from "./safeWebhookUrl.server";
/**
* `fetch`-like wrapper for delivering user-supplied webhook URLs. The lexical
* check is shared with the storage-time gate (`assertSafeWebhookUrlLexical`).
*
* Validation is bound to the actual connection: the request goes through
* `node:http`/`node:https` with a custom DNS `lookup` that validates every
* resolved address before the socket connects, so the connected address is the
* one that was checked. Redirects are followed manually and re-validated per
* hop, capped at `MAX_REDIRECTS`.
*/
// Re-exported so callers/tests don't reach into the underlying module.
export { assertSafeWebhookUrl, assertSafeWebhookUrlLexical, UnsafeWebhookUrlError };
const MAX_REDIRECTS = 5;
export type SafeWebhookFetchInit = {
method?: string;
headers?: Record<string, string>;
body?: string | Buffer;
signal?: AbortSignal;
redirectLimit?: number;
};
// DNS lookup that validates every resolved address before handing it to the
// connector; any unsafe address fails the whole lookup. On error we pass an
// empty address list, which net ignores when err is set.
const safeLookup: LookupFunction = (hostname, options, callback) => {
dnsPromises
.lookup(hostname, {
all: true,
family: options.family,
hints: options.hints,
verbatim: options.verbatim,
})
.then((addresses) => {
try {
for (const { address, family } of addresses) {
assertAddressAllowed(address, family);
}
} catch (err) {
callback(err as NodeJS.ErrnoException, []);
return;
}
if (options.all) {
callback(null, addresses);
} else {
callback(null, addresses[0].address, addresses[0].family);
}
})
.catch((err) => callback(err as NodeJS.ErrnoException, []));
};
// Single request with no redirect following, using the validating lookup. The
// response body is drained and discarded (callers only need status / headers),
// which also frees the socket.
function requestOnce(urlStr: string, init: SafeWebhookFetchInit): Promise<Response> {
const url = new URL(urlStr);
const mod = url.protocol === "https:" ? https : http;
// Set Content-Length explicitly (as fetch does for string/Buffer bodies)
// rather than falling back to chunked transfer-encoding, which some
// webhook receivers reject.
const headers: Record<string, string> = { ...(init.headers ?? {}) };
if (
init.body != null &&
headers["content-length"] === undefined &&
headers["Content-Length"] === undefined
) {
headers["content-length"] = String(Buffer.byteLength(init.body));
}
return new Promise((resolve, reject) => {
const req = mod.request(
url,
{
method: init.method ?? "GET",
headers,
lookup: safeLookup,
signal: init.signal,
},
(res) => {
res.on("data", () => {});
res.on("end", () => {
const responseHeaders = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
if (Array.isArray(value)) {
for (const v of value) responseHeaders.append(key, v);
} else if (value !== undefined) {
responseHeaders.set(key, value);
}
}
resolve(
new Response(null, {
status: res.statusCode ?? 502,
statusText: res.statusMessage ?? "",
headers: responseHeaders,
})
);
});
res.on("error", reject);
}
);
req.on("error", reject);
if (init.body != null) {
req.write(init.body);
}
req.end();
});
}
/**
* Tenant-supplied-URL fetch with connection-bound SSRF validation and manual,
* per-hop redirect validation.
*/
export async function safeWebhookFetch(
rawUrl: string,
init: SafeWebhookFetchInit = {}
): Promise<Response> {
let nextUrl = assertSafeWebhookUrlLexical(rawUrl).href;
const limit = init.redirectLimit ?? MAX_REDIRECTS;
for (let hop = 0; hop <= limit; hop++) {
const response = await requestOnce(nextUrl, init);
if (response.status < 300 || response.status >= 400) {
return response;
}
const location = response.headers.get("location");
if (!location) return response;
if (hop === limit) {
throw new UnsafeWebhookUrlError(
`Refusing to deliver webhook to ${nextUrl}: exceeded redirect limit (${limit}) following ${location}`
);
}
const target = new URL(location, nextUrl);
try {
nextUrl = assertSafeWebhookUrlLexical(target.href).href;
} catch (err) {
logger.warn("Refusing to follow webhook redirect", {
from: nextUrl,
to: target.href,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
// Unreachable — the loop always returns or throws.
throw new UnsafeWebhookUrlError(
`Refusing to deliver webhook to ${nextUrl}: exhausted redirect loop`
);
}
@@ -0,0 +1,182 @@
// Validator for user-supplied webhook URLs that the server fetches later
// (alert channels, error-group webhooks). Rejects non-http(s) schemes and
// private/loopback/link-local/reserved hosts.
//
// Two entry points:
// - `assertSafeWebhookUrlLexical` — sync, no network. Runs on every
// delivery hop; the connect-time bound lookup below is authoritative.
// - `assertSafeWebhookUrl` — storage-time gate: lexical check plus a
// best-effort DNS resolution for early, friendly rejection.
//
// The authoritative guard is at delivery time: `safeWebhookFetch` binds
// validation into the connection's own DNS lookup, so the address actually
// connected to is the one that was checked.
import { promises as dnsPromises } from "node:dns";
export class UnsafeWebhookUrlError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsafeWebhookUrlError";
}
}
function isUnsafeIPv4(host: string): boolean {
// Reject if the host parses as a 4-octet IPv4 in any of the unsafe ranges.
const parts = host.split(".");
if (parts.length !== 4) return false;
const nums = parts.map((p) => Number(p));
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
const [a, b] = nums;
// 0.0.0.0/8 (unspecified)
if (a === 0) return true;
// 127/8 loopback
if (a === 127) return true;
// 10/8
if (a === 10) return true;
// 172.16/12
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168/16
if (a === 192 && b === 168) return true;
// 169.254/16 link-local
if (a === 169 && b === 254) return true;
// 100.64/10 carrier-grade NAT
if (a === 100 && b >= 64 && b <= 127) return true;
// 224/4 multicast
if (a >= 224 && a <= 239) return true;
// 240/4 reserved
if (a >= 240) return true;
return false;
}
function isUnsafeIPv6(host: string): boolean {
// URL.hostname keeps the brackets for IPv6 literals ([::1]); DNS results
// and IP literals elsewhere are unbracketed. Strip brackets so both work.
const lower = (
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host
).toLowerCase();
// loopback
if (lower === "::1") return true;
// unspecified
if (lower === "::" || lower === "::0" || lower === "0:0:0:0:0:0:0:0") return true;
// link-local fe80::/10
if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true;
// ULA fc00::/7
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;
// multicast ff00::/8
if (lower.startsWith("ff")) return true;
// IPv4-mapped, dotted form: ::ffff:a.b.c.d
const mappedDotted = lower.match(/^::ffff:([0-9.]+)$/);
if (mappedDotted && isUnsafeIPv4(mappedDotted[1])) return true;
// IPv4-mapped, hex form: ::ffff:7f00:1 (how Node normalizes ::ffff:127.0.0.1).
// The two trailing hextets encode the 32-bit IPv4 address.
const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mappedHex) {
const hi = parseInt(mappedHex[1], 16);
const lo = parseInt(mappedHex[2], 16);
const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
if (isUnsafeIPv4(ipv4)) return true;
}
return false;
}
/**
* Throw if a resolved IP address falls in a disallowed range. Exposed so the
* delivery-time connector can validate the actual address it connects to.
*/
export function assertAddressAllowed(address: string, family: number): void {
if (family === 4 && isUnsafeIPv4(address)) {
throw new UnsafeWebhookUrlError(
`Webhook URL resolves to a private/loopback/link-local address: ${address}`
);
}
if (family === 6 && isUnsafeIPv6(address)) {
throw new UnsafeWebhookUrlError(
`Webhook URL resolves to a private/loopback/link-local IPv6 address: ${address}`
);
}
}
function isUnsafeHostname(host: string): boolean {
const lower = host.toLowerCase();
if (lower === "localhost" || lower.endsWith(".localhost")) return true;
if (lower === "internal" || lower.endsWith(".internal")) return true;
if (lower === "local" || lower.endsWith(".local")) return true;
return false;
}
function isIPLiteral(host: string): boolean {
// Strip brackets: URL.hostname keeps them for IPv6 literals ([::1]).
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
// IPv4: four dot-separated 0-255 octets.
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bare)) return true;
// IPv6: at least one `:` and only hex / `:` / `.` (the `.` allows
// IPv4-mapped notation like ::ffff:1.2.3.4).
if (bare.includes(":") && /^[0-9a-fA-F:.]+$/.test(bare)) return true;
return false;
}
/**
* Best-effort storage-time DNS check: resolve `hostname` and throw if a
* returned address is unsafe. Not a security boundary resolution failures
* don't block the save, since delivery re-validates at connect time.
*/
async function assertResolvedAddressesSafe(hostname: string): Promise<void> {
let addresses: Array<{ address: string; family: number }>;
try {
addresses = await dnsPromises.lookup(hostname, { all: true });
} catch {
// Unresolvable right now — don't block the save; connect-time is authoritative.
return;
}
for (const { address, family } of addresses) {
assertAddressAllowed(address, family);
}
}
/**
* Synchronous, no-network SSRF check: scheme allow-list plus IP-literal
* and hostname range checks. Used on every delivery hop, where the
* connect-time bound lookup is the authoritative range check.
*/
export function assertSafeWebhookUrlLexical(rawUrl: string): URL {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new UnsafeWebhookUrlError("Webhook URL is not a valid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new UnsafeWebhookUrlError(`Webhook URL must use http or https (got ${parsed.protocol})`);
}
const host = parsed.hostname;
if (!host) {
throw new UnsafeWebhookUrlError("Webhook URL must have a hostname");
}
if (isUnsafeHostname(host)) {
throw new UnsafeWebhookUrlError(`Webhook URL host is not allowed: ${host}`);
}
if (isUnsafeIPv4(host)) {
throw new UnsafeWebhookUrlError(
`Webhook URL points at a private/loopback/link-local address: ${host}`
);
}
if (isUnsafeIPv6(host)) {
throw new UnsafeWebhookUrlError(
`Webhook URL points at a private/loopback/link-local IPv6 address: ${host}`
);
}
return parsed;
}
/**
* Storage-time gate: lexical check plus a best-effort DNS resolution of
* registrable domains, for early rejection before storage.
*/
export async function assertSafeWebhookUrl(rawUrl: string): Promise<URL> {
const parsed = assertSafeWebhookUrlLexical(rawUrl);
if (!isIPLiteral(parsed.hostname)) {
await assertResolvedAddressesSafe(parsed.hostname);
}
return parsed;
}
@@ -0,0 +1,50 @@
import type { RunStore } from "@internal/run-store";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
/**
* Resolve the BatchTaskRun id for `batchId` (accepting either the friendlyId or
* the internal id) only if `userId` is a member of the batch's owning
* organization. Returns null otherwise. Batch lookup goes through runStore so
* batches resident in either run-store database are visible.
*/
export async function findBatchRunIdForUser(
prisma: PrismaClientOrTransaction,
store: RunStore,
batchId: string,
userId: string
): Promise<string | null> {
const batchRunId = toBatchRunId(batchId);
if (!batchRunId) return null;
const batchRun = await store.findBatchTaskRunById(batchRunId);
if (!batchRun) return null;
return (await userCanAccessEnvironment(prisma, batchRun.runtimeEnvironmentId, userId))
? batchRun.id
: null;
}
function toBatchRunId(batchId: string): string | null {
try {
return BatchId.toId(batchId);
} catch {
return null;
}
}
async function userCanAccessEnvironment(
prisma: PrismaClientOrTransaction,
runtimeEnvironmentId: string,
userId: string
): Promise<boolean> {
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
id: runtimeEnvironmentId,
organization: { members: { some: { userId } } },
},
select: { id: true },
});
return !!environment;
}
@@ -20,6 +20,7 @@ import { env } from "~/env.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { dependentAttemptWhere } from "./dependentAttemptScope";
import { logger } from "~/services/logger.server";
import { getEntitlement } from "~/services/platform.v3.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
@@ -193,7 +194,8 @@ export class BatchTriggerV3Service extends BaseService {
const dependentAttempt = body?.dependentAttempt
? await this._prisma.taskRunAttempt.findFirst({
where: { friendlyId: body.dependentAttempt },
// Scope to the caller's environment (see dependentAttemptWhere).
where: dependentAttemptWhere(body.dependentAttempt, environment.id),
include: {
taskRun: {
select: {
@@ -0,0 +1,17 @@
import type { Prisma } from "@trigger.dev/database";
/**
* Where-clause for resolving a dependent/parent TaskRunAttempt by friendlyId,
* scoped to the caller's environment via the related run. The env scope keeps a
* foreign friendlyId from resolving onto the new batch. Standalone builder so
* the scope can be asserted directly in tests.
*/
export function dependentAttemptWhere(
friendlyId: string,
environmentId: string
): Prisma.TaskRunAttemptWhereInput {
return {
friendlyId,
taskRun: { runtimeEnvironmentId: environmentId },
};
}
@@ -0,0 +1,7 @@
// `source: "code"` is reserved for the deploy path; no other caller may write
// it. Normalize anything that isn't a legitimate caller-supplied value to
// "dashboard". Dependency-free so the rule can be unit-tested directly; it
// backs the service-layer check (the route layer also constrains `source`).
export function normalizePromptOverrideSource(source: string | undefined | null): string {
return source && source !== "code" ? source : "dashboard";
}
@@ -1,6 +1,7 @@
import { createHash } from "crypto";
import { prisma } from "~/db.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { normalizePromptOverrideSource } from "./promptOverrideSource";
export class PromptService extends BaseService {
async promoteVersion(promptId: string, versionId: string, options?: { sourceGuard?: boolean }) {
@@ -62,13 +63,17 @@ export class PromptService extends BaseService {
WHERE "promptId" = ${promptId} AND 'override' = ANY("labels")
`;
// Defence in depth: reject the reserved `code` source regardless of
// caller, in case a future caller skips the route-layer check.
const safeSource = normalizePromptOverrideSource(data.source);
await tx.promptVersion.create({
data: {
promptId,
version: nextVersion,
textContent: data.textContent,
model: data.model || null,
source: data.source || "dashboard",
source: safeSource,
commitMessage: data.commitMessage || null,
contentHash,
labels: ["override"],
@@ -24,6 +24,42 @@ type OverrideOptions = {
export class ReplayTaskRunService extends BaseService {
public async call(existingTaskRun: TaskRun, overrideOptions: OverrideOptions = {}) {
// An override environment must belong to the same project as the source
// run. The source project is derived from the run's own environment rather
// than existingTaskRun.projectId, since the buffered-run fallback passes a
// synthetic TaskRun with no projectId. Only check when a distinct override
// is supplied; otherwise the run's own environment is used.
if (
overrideOptions.environmentId &&
overrideOptions.environmentId !== existingTaskRun.runtimeEnvironmentId
) {
const [overrideEnvironment, sourceEnvironment] = await Promise.all([
this._prisma.runtimeEnvironment.findFirst({
where: { id: overrideOptions.environmentId },
select: { projectId: true },
}),
this._prisma.runtimeEnvironment.findFirst({
where: { id: existingTaskRun.runtimeEnvironmentId },
select: { projectId: true },
}),
]);
if (
!overrideEnvironment ||
!sourceEnvironment ||
overrideEnvironment.projectId !== sourceEnvironment.projectId
) {
logger.warn("Refusing to replay a run into an environment outside its project", {
taskRunId: existingTaskRun.id,
taskRunFriendlyId: existingTaskRun.friendlyId,
sourceEnvironmentId: existingTaskRun.runtimeEnvironmentId,
sourceProjectId: sourceEnvironment?.projectId ?? null,
overrideEnvironmentId: overrideOptions.environmentId,
overrideProjectId: overrideEnvironment?.projectId ?? null,
});
throw new Error("Cannot replay a run into an environment outside its project");
}
}
const authenticatedEnvironment = await findEnvironmentById(
overrideOptions.environmentId ?? existingTaskRun.runtimeEnvironmentId
);
@@ -31,6 +31,7 @@ import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { startActiveSpan } from "../tracer.server";
import { clampMaxDuration } from "../utils/maxDuration";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { attemptInEnvironmentWhere, batchRunInEnvironmentWhere } from "./triggerV1Scoping";
import { EnqueueDelayedRunService } from "./enqueueDelayedRun.server";
import { enqueueRun } from "./enqueueRun.server";
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
@@ -165,7 +166,7 @@ export class TriggerTaskServiceV1 extends BaseService {
const dependentAttempt = body.options?.dependentAttempt
? await this._prisma.taskRunAttempt.findFirst({
where: { friendlyId: body.options.dependentAttempt },
where: attemptInEnvironmentWhere(body.options.dependentAttempt, environment.id),
include: {
taskRun: {
select: {
@@ -205,7 +206,7 @@ export class TriggerTaskServiceV1 extends BaseService {
const parentAttempt = body.options?.parentAttempt
? await this._prisma.taskRunAttempt.findFirst({
where: { friendlyId: body.options.parentAttempt },
where: attemptInEnvironmentWhere(body.options.parentAttempt, environment.id),
include: {
taskRun: {
select: {
@@ -223,7 +224,7 @@ export class TriggerTaskServiceV1 extends BaseService {
const dependentBatchRun = body.options?.dependentBatch
? await this._prisma.batchTaskRun.findFirst({
where: { friendlyId: body.options.dependentBatch },
where: batchRunInEnvironmentWhere(body.options.dependentBatch, environment.id),
include: {
dependentTaskAttempt: {
include: {
@@ -270,7 +271,7 @@ export class TriggerTaskServiceV1 extends BaseService {
const parentBatchRun = body.options?.parentBatch
? await this._prisma.batchTaskRun.findFirst({
where: { friendlyId: body.options.parentBatch },
where: batchRunInEnvironmentWhere(body.options.parentBatch, environment.id),
include: {
dependentTaskAttempt: {
include: {
@@ -0,0 +1,20 @@
import type { Prisma } from "@trigger.dev/database";
// Where-clauses for resolving caller-supplied parent/dependent attempt & batch
// friendlyIds in the V1 trigger path, scoped to the caller's environment so a
// foreign friendlyId can't be wired onto the new run/batch. Standalone builders
// so the scope can be asserted directly in tests.
export function attemptInEnvironmentWhere(
friendlyId: string,
environmentId: string
): Prisma.TaskRunAttemptWhereInput {
return { friendlyId, taskRun: { runtimeEnvironmentId: environmentId } };
}
export function batchRunInEnvironmentWhere(
friendlyId: string,
environmentId: string
): Prisma.BatchTaskRunWhereInput {
return { friendlyId, runtimeEnvironmentId: environmentId };
}
@@ -0,0 +1,27 @@
import { WORKER_HEADERS } from "@trigger.dev/core/v3/workers";
// Secret-bearing headers to drop before logging request headers.
// Dependency-free so the redaction is unit-tested directly.
export const SENSITIVE_WORKER_HEADERS = new Set([
"authorization",
"cookie",
WORKER_HEADERS.MANAGED_SECRET.toLowerCase(),
]);
/**
* Copy `headers` into a plain object, dropping any header whose (lower-cased)
* name is in `denylist`. Used before logging request headers.
*/
export function sanitizeWorkerHeaders(
headers: Headers,
denylist: ReadonlySet<string> = SENSITIVE_WORKER_HEADERS
): Partial<Record<string, string>> {
const skip = new Set(Array.from(denylist, (h) => h.toLowerCase()));
const sanitized: Partial<Record<string, string>> = {};
for (const [key, value] of headers.entries()) {
if (!skip.has(key.toLowerCase())) {
sanitized[key] = value;
}
}
return sanitized;
}
@@ -0,0 +1,19 @@
import { WorkerInstanceGroupType } from "@trigger.dev/database";
/**
* Whether a worker group may be used by the calling project.
*
* MANAGED groups are shared across projects. UNMANAGED groups are per-project
* (masterQueue is `${projectId}-${name}`), so a project may only use an
* UNMANAGED group whose `projectId` matches it. Dependency-free so it can be
* unit-tested directly.
*/
export function isWorkerGroupAllowedForProject(
workerGroup: { type: WorkerInstanceGroupType; projectId: string | null },
projectId: string
): boolean {
if (workerGroup.type === WorkerInstanceGroupType.UNMANAGED) {
return workerGroup.projectId === projectId;
}
return true;
}
@@ -1,6 +1,7 @@
import type { WorkerInstanceGroup, WorkloadType } from "@trigger.dev/database";
import { WorkerInstanceGroupType } from "@trigger.dev/database";
import { WithRunEngine } from "../baseService.server";
import { isWorkerGroupAllowedForProject } from "./workerGroupAccess";
import { WorkerGroupTokenService } from "./workerGroupTokenService.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
@@ -252,6 +253,13 @@ export class WorkerGroupService extends WithRunEngine {
throw new Error(`The region you specified doesn't exist ("${regionOverride}").`);
}
// The masterQueue-only lookup above can resolve another project's
// UNMANAGED group, so reject groups not usable by this project
// (see isWorkerGroupAllowedForProject).
if (!isWorkerGroupAllowedForProject(workerGroup, project.id)) {
throw new Error(`The region you specified isn't available to you ("${regionOverride}").`);
}
// If they're restricted, check they have access
if (project.allowedWorkerQueues.length > 0) {
if (project.allowedWorkerQueues.includes(workerGroup.masterQueue)) {
@@ -18,6 +18,7 @@ import { fromFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import { WORKER_HEADERS, type WorkerQueueClass } from "@trigger.dev/core/v3/workers";
import type { RuntimeEnvironment, WorkerInstanceGroup } from "@trigger.dev/database";
import { Prisma, WorkerInstanceGroupType } from "@trigger.dev/database";
import { SENSITIVE_WORKER_HEADERS, sanitizeWorkerHeaders } from "./sanitizeWorkerHeaders";
import { createHash, timingSafeEqual } from "crypto";
import { customAlphabet } from "nanoid";
import { z } from "zod";
@@ -187,7 +188,6 @@ export class WorkerGroupTokenService extends WithRunEngine {
if (a.byteLength !== b.byteLength) {
logger.error("[WorkerGroupTokenService] Managed secret length mismatch", {
managedWorkerSecret,
headers: this.sanitizeHeaders(request),
});
return;
@@ -195,7 +195,6 @@ export class WorkerGroupTokenService extends WithRunEngine {
if (!timingSafeEqual(a, b)) {
logger.error("[WorkerGroupTokenService] Managed secret mismatch", {
managedWorkerSecret,
headers: this.sanitizeHeaders(request),
});
return;
@@ -317,16 +316,10 @@ export class WorkerGroupTokenService extends WithRunEngine {
}
}
private sanitizeHeaders(request: Request, skipHeaders = ["authorization"]) {
const sanitizedHeaders: Partial<Record<string, string>> = {};
for (const [key, value] of request.headers.entries()) {
if (!skipHeaders.includes(key.toLowerCase())) {
sanitizedHeaders[key] = value;
}
}
return sanitizedHeaders;
// Strip sensitive headers before logging request headers — see
// `sanitizeWorkerHeaders`.
private sanitizeHeaders(request: Request, denylist = SENSITIVE_WORKER_HEADERS) {
return sanitizeWorkerHeaders(request.headers, denylist);
}
}
@@ -0,0 +1,54 @@
import { redisTest } from "@internal/testcontainers";
import { type RedisOptions } from "ioredis";
import { describe, expect, vi } from "vitest";
import { type RedisWithClusterOptions } from "../app/redis.server.js";
import {
AI_TITLE_RATE_LIMIT_ATTEMPTS,
createAITitleRateLimiter,
} from "../app/v3/services/aiTitleRateLimiter.server.js";
vi.setConfig({ testTimeout: 60_000 });
// Plaintext container: without tlsDisabled the client attempts TLS, the
// connection fails, and @upstash/ratelimit fails open (allowing everything).
const toRedisOptions = (o: RedisOptions): RedisWithClusterOptions => ({
host: o.host,
port: o.port,
username: o.username,
password: o.password,
tlsDisabled: true,
});
let seq = 0;
const userKey = (label: string) => `user:${label}-${seq++}`;
// The query ai-title endpoint isn't covered by the global apiRateLimiter, so
// this per-user limiter is the only thing bounding it.
describe("aiTitleRateLimiter", () => {
redisTest("allows up to the limit then blocks further attempts", async ({ redisOptions }) => {
const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions));
const key = userKey("loop");
for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) {
const r = await limiter.limit(key);
expect(r.success).toBe(true);
}
const blocked = await limiter.limit(key);
expect(blocked.success).toBe(false);
});
redisTest("scopes the limit per user", async ({ redisOptions }) => {
const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions));
const victim = userKey("victim");
const bystander = userKey("bystander");
for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) {
await limiter.limit(victim);
}
expect((await limiter.limit(victim)).success).toBe(false);
// A different user is unaffected.
expect((await limiter.limit(bystander)).success).toBe(true);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import { PostgresRunStore } from "@internal/run-store";
import { containerTest } from "@internal/testcontainers";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect, vi } from "vitest";
import { findBatchRunIdForUser } from "~/v3/services/batchRunAccess.server";
vi.setConfig({ testTimeout: 60_000 });
const rand = () => Math.random().toString(36).slice(2, 10);
// The batch-resume route was previously unauthenticated. This is the org-scoped
// ownership gate: a user may only resolve a batch in an org they belong to.
describe("findBatchRunIdForUser", () => {
containerTest(
"resolves a batch for an org member, by friendlyId and by internal id",
async ({ prisma }) => {
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const member = await prisma.user.create({
data: { email: `member_${rand()}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
await prisma.orgMember.create({
data: { organizationId: env.organizationId, userId: member.id },
});
const batchId = BatchId.generate();
const batch = await prisma.batchTaskRun.create({
data: { id: batchId.id, friendlyId: batchId.friendlyId, runtimeEnvironmentId: env.id },
});
expect(await findBatchRunIdForUser(prisma, store, batch.friendlyId, member.id)).toBe(
batch.id
);
expect(await findBatchRunIdForUser(prisma, store, batch.id, member.id)).toBe(batch.id);
}
);
containerTest(
"returns null for a user who is not a member of the batch's org",
async ({ prisma }) => {
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const batchId = BatchId.generate();
const batch = await prisma.batchTaskRun.create({
data: { id: batchId.id, friendlyId: batchId.friendlyId, runtimeEnvironmentId: env.id },
});
// A user who exists but isn't a member of the org.
const stranger = await prisma.user.create({
data: { email: `stranger_${rand()}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
expect(await findBatchRunIdForUser(prisma, store, batch.friendlyId, stranger.id)).toBeNull();
expect(await findBatchRunIdForUser(prisma, store, batch.id, stranger.id)).toBeNull();
}
);
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { dependentAttemptWhere } from "../app/v3/services/dependentAttemptScope.js";
// The dependent-attempt lookup must be scoped to the caller's environment via
// the related run — a where clause missing that constraint lets a foreign
// attempt friendlyId resolve (the cross-tenant bug). This pins the scope on the
// query itself.
describe("dependentAttemptWhere", () => {
it("scopes the attempt lookup to the environment via the related run", () => {
const where = dependentAttemptWhere("attempt_abc", "env_caller");
expect(where.friendlyId).toBe("attempt_abc");
expect(where.taskRun).toEqual({ runtimeEnvironmentId: "env_caller" });
});
it("threads the exact environment id through (no cross-env match)", () => {
const where = dependentAttemptWhere("attempt_abc", "env_A");
// The env constraint must reference the caller's env, not be absent/empty.
expect((where.taskRun as { runtimeEnvironmentId?: string })?.runtimeEnvironmentId).toBe(
"env_A"
);
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { detectQueryTables } from "../app/v3/detectQueryTables.js";
const allowed = new Set(["runs", "tasks"]);
const sorted = (xs: string[] | null) => (xs === null ? null : [...xs].sort());
// detectQueryTables backs per-table JWT-scope authorization for /api/v1/query.
// Key behaviours over the old FROM-only regex: it sees JOINed and subquery
// tables, and returns null for unparseable queries so the caller denies by
// default.
describe("detectQueryTables", () => {
it("detects the FROM table", () => {
expect(sorted(detectQueryTables("SELECT * FROM runs", allowed))).toEqual(["runs"]);
});
it("returns the canonical table name when query casing differs", () => {
expect(sorted(detectQueryTables("SELECT * FROM RUNS", allowed))).toEqual(["runs"]);
});
it("detects every JOINed table, not just FROM", () => {
expect(
sorted(detectQueryTables("SELECT * FROM runs JOIN tasks ON runs.id = tasks.run_id", allowed))
).toEqual(["runs", "tasks"]);
});
it("detects tables inside a FROM subquery", () => {
expect(sorted(detectQueryTables("SELECT * FROM (SELECT * FROM runs) AS r", allowed))).toEqual([
"runs",
]);
});
it("detects a table read only inside a CTE body", () => {
expect(
sorted(detectQueryTables("WITH r AS (SELECT * FROM runs) SELECT * FROM r", allowed))
).toEqual(["runs"]);
});
it("detects a table read only inside a WHERE subquery", () => {
expect(
sorted(
detectQueryTables("SELECT * FROM tasks WHERE id IN (SELECT run_id FROM runs)", allowed)
)
).toEqual(["runs", "tasks"]);
});
it("detects a table read only inside a SELECT-list subquery", () => {
expect(
sorted(detectQueryTables("SELECT (SELECT count() FROM runs) AS c FROM tasks", allowed))
).toEqual(["runs", "tasks"]);
});
it("ignores tables that aren't in the allowed schema set", () => {
expect(detectQueryTables("SELECT * FROM runs", new Set(["tasks"]))).toEqual([]);
});
it("returns null for an unparseable query (caller denies by default)", () => {
expect(detectQueryTables("definitely not a valid query !!!", allowed)).toBeNull();
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { emailMatchesPattern } from "../app/utils/emailPattern.js";
// emailMatchesPattern backs the ADMIN_EMAILS and WHITELISTED_EMAILS gates.
// Property under test: a pattern matches the whole address, never a substring.
describe("emailMatchesPattern", () => {
it("matches an address that equals the operator pattern exactly", () => {
expect(emailMatchesPattern("admin@company.com", "admin@company.com")).toBe(true);
});
it("matches a leading-@ domain shorthand against addresses at that domain", () => {
expect(emailMatchesPattern("@company.com", "alice@company.com")).toBe(true);
expect(emailMatchesPattern("@company.com", "bob@company.com")).toBe(true);
});
it("rejects a look-alike address that merely contains the pattern", () => {
// A look-alike address embeds the pattern as a substring; an unanchored
// match would wrongly accept it.
expect(emailMatchesPattern("admin@company.com", "evil@admin@company.com.attacker.com")).toBe(
false
);
expect(emailMatchesPattern("@company.com", "evil@company.com.attacker.com")).toBe(false);
expect(emailMatchesPattern("@company.com", "alice@sub.company.com")).toBe(false);
});
it("rejects a trailing-garbage address (pattern is only a prefix)", () => {
expect(emailMatchesPattern("admin@company.com", "admin@company.computer-evil.com")).toBe(false);
});
it("rejects a leading-garbage address (pattern is only a suffix)", () => {
expect(emailMatchesPattern("admin@company.com", "not-admin@company.com")).toBe(false);
});
it("preserves top-level alternation as whole-string alternatives", () => {
// Guards against anchoring without the non-capturing group, which would
// turn `^a|b$` into anchored-a OR anchored-b and break multi-address configs.
const pattern = "alice@x.com|bob@x.com";
expect(emailMatchesPattern(pattern, "alice@x.com")).toBe(true);
expect(emailMatchesPattern(pattern, "bob@x.com")).toBe(true);
expect(emailMatchesPattern(pattern, "eve@x.com")).toBe(false);
// ...and alternation must not become a substring match either.
expect(emailMatchesPattern(pattern, "eve+alice@x.com.evil.com")).toBe(false);
});
it("expands domain shorthand inside simple top-level alternation", () => {
const pattern = "alice@x.com|@company.com";
expect(emailMatchesPattern(pattern, "alice@x.com")).toBe(true);
expect(emailMatchesPattern(pattern, "carol@company.com")).toBe(true);
expect(emailMatchesPattern(pattern, "carol@company.com.evil.com")).toBe(false);
});
it("accepts patterns that already carry their own anchors", () => {
// Operators who already wrote a fully-anchored pattern keep working:
// ^(?:^...$)$ accepts exactly the same strings.
expect(emailMatchesPattern("^ops@company\\.com$", "ops@company.com")).toBe(true);
expect(emailMatchesPattern("^ops@company\\.com$", "ops@company.com.evil.com")).toBe(false);
});
});
@@ -0,0 +1,329 @@
import { describe, expect, vi } from "vitest";
// Mock the db prisma singleton so the real testcontainer prisma is used
// instead of the webapp's env-bound client (mirrors triggerTask.test.ts).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import {
setupAuthenticatedEnvironment,
setupBackgroundWorker,
type AuthenticatedEnvironment,
} from "@internal/run-engine/tests";
import { containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import type { IOPacket } from "@trigger.dev/core/v3";
import {
Decimal,
type PrismaClient,
type RuntimeEnvironmentType,
type TaskRun,
} from "@trigger.dev/database";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import type {
EntitlementValidationParams,
MaxAttemptsValidationParams,
ParentRunValidationParams,
PayloadProcessor,
TagValidationParams,
TraceEventConcern,
TracedEventSpan,
TriggerTaskRequest,
TriggerTaskValidator,
ValidationResult,
} from "~/runEngine/types";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import { setTimeout } from "node:timers/promises";
vi.setConfig({ testTimeout: 60_000 });
class MockPayloadProcessor implements PayloadProcessor {
async process(request: TriggerTaskRequest): Promise<IOPacket> {
return {
data: JSON.stringify(request.body.payload),
dataType: "application/json",
};
}
}
// Permissive validator: the main (non-cached) trigger path is not the
// subject here — we want the cached idempotency branch's own scoping
// guard to be the only thing standing between a caller and an
// arbitrary parent run.
class MockTriggerTaskValidator implements TriggerTaskValidator {
validateTags(_params: TagValidationParams): ValidationResult {
return { ok: true };
}
validateEntitlement(_params: EntitlementValidationParams): Promise<ValidationResult> {
return Promise.resolve({ ok: true });
}
validateMaxAttempts(_params: MaxAttemptsValidationParams): ValidationResult {
return { ok: true };
}
validateParentRun(_params: ParentRunValidationParams): ValidationResult {
return { ok: true };
}
}
const MOCK_TRACE_ID = "0123456789abcdef0123456789abcdef";
const MOCK_SPAN_ID = "fedcba9876543210";
const MOCK_TRACEPARENT = `00-${MOCK_TRACE_ID}-${MOCK_SPAN_ID}-01`;
class MockTraceEventConcern implements TraceEventConcern {
private span(): TracedEventSpan {
return {
traceId: MOCK_TRACE_ID,
spanId: MOCK_SPAN_ID,
traceContext: { traceparent: MOCK_TRACEPARENT },
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
};
}
async traceRun<T>(
_request: TriggerTaskRequest,
_parentStore: string | undefined,
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return callback(this.span(), "test");
}
async traceIdempotentRun<T>(
_request: TriggerTaskRequest,
_parentStore: string | undefined,
_options: {
existingRun: TaskRun;
idempotencyKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return callback(this.span(), "test");
}
async traceDebouncedRun<T>(
_request: TriggerTaskRequest,
_parentStore: string | undefined,
_options: { existingRun: TaskRun; debounceKey: string; incomplete: boolean; isError: boolean },
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return callback(this.span(), "test");
}
}
// setupAuthenticatedEnvironment hardcodes every unique field (slug,
// apiKey, shortcode, ...), so it can only be called once per database.
// This builds a second, fully-distinct tenant for the cross-environment
// assertion.
async function createDistinctTenant(
prisma: PrismaClient,
type: RuntimeEnvironmentType,
suffix: string
): Promise<AuthenticatedEnvironment> {
const org = await prisma.organization.create({
data: { title: `Test Org ${suffix}`, slug: `test-organization-${suffix}` },
});
const workerGroup = await prisma.workerInstanceGroup.create({
data: {
name: `default-${suffix}`,
masterQueue: `default-${suffix}`,
type: "MANAGED",
token: { create: { tokenHash: `token_hash_${suffix}` } },
},
});
const project = await prisma.project.create({
data: {
name: `Test Project ${suffix}`,
slug: `test-project-${suffix}`,
externalRef: `proj_${suffix}`,
organizationId: org.id,
defaultWorkerGroupId: workerGroup.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type,
slug: `slug-${suffix}`,
projectId: project.id,
organizationId: org.id,
apiKey: `api_key_${suffix}`,
pkApiKey: `pk_api_key_${suffix}`,
shortcode: `short_code_${suffix}`,
maximumConcurrencyLimit: 10,
concurrencyLimitBurstFactor: new Decimal(2.0),
},
});
return prisma.runtimeEnvironment.findUniqueOrThrow({
where: { id: environment.id },
include: { project: true, organization: true, orgMember: true },
});
}
describe("IdempotencyKeyConcern cached-branch parent-run scoping", () => {
containerTest(
"rejects a parentRunId from another environment, permits one from the caller's environment",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const parentTask = "parent-task";
const childTask = "child-task";
// Two independent tenants.
const callerEnv = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const victimEnv = await createDistinctTenant(prisma, "PRODUCTION", "victim");
await setupBackgroundWorker(engine, callerEnv, [parentTask, childTask]);
await setupBackgroundWorker(engine, victimEnv, [parentTask, childTask]);
// Helper: trigger a parent run and start its attempt so it is a
// valid waitpoint target for resumeParentOnCompletion.
const triggerAndStart = async (
env: typeof callerEnv,
friendlyId: string,
idSuffix: string
) => {
const run = await engine.trigger(
{
number: 1,
friendlyId,
environment: env,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: `t${idSuffix}`,
spanId: `s${idSuffix}`,
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
await setTimeout(500);
const dequeued = await engine.dequeueFromWorkerQueue({
consumerId: `consumer${idSuffix}`,
workerQueue: "main",
});
await engine.startRunAttempt({ runId: run.id, snapshotId: dequeued[0].snapshot.id });
return run;
};
// Victim parent lives in the OTHER environment.
const victimParent = await triggerAndStart(victimEnv, "run_victimp", "11111");
// A legitimate parent in the caller's own environment.
const callerParent = await triggerAndStart(callerEnv, "run_callerp", "22222");
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const service = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Seed two cached idempotent child runs in the caller's env. The
// first call for each key takes the non-cached path and creates the
// run; the second call hits the cached branch under test.
const crossEnvKey = "cross-env-key";
const sameEnvKey = "same-env-key";
const seedCross = await service.call({
taskId: childTask,
environment: callerEnv,
body: { payload: { n: 1 }, options: { idempotencyKey: crossEnvKey } },
});
expect(seedCross?.isCached).toBe(false);
const seedSame = await service.call({
taskId: childTask,
environment: callerEnv,
body: { payload: { n: 2 }, options: { idempotencyKey: sameEnvKey } },
});
expect(seedSame?.isCached).toBe(false);
// ATTACK: cached call in the caller's env naming the victim's parent
// run (which belongs to victimEnv). Must be refused.
await expect(
service.call({
taskId: childTask,
environment: callerEnv,
body: {
payload: { n: 1 },
options: {
idempotencyKey: crossEnvKey,
parentRunId: victimParent.friendlyId,
resumeParentOnCompletion: true,
},
},
})
).rejects.toThrow(/Parent run not found in the calling environment/);
// CONTROL: same cached path, but the parent is in the caller's own
// env — the guard must let this through (isCached hit).
const sameEnvResult = await service.call({
taskId: childTask,
environment: callerEnv,
body: {
payload: { n: 2 },
options: {
idempotencyKey: sameEnvKey,
parentRunId: callerParent.friendlyId,
resumeParentOnCompletion: true,
},
},
});
expect(sameEnvResult?.isCached).toBe(true);
expect(sameEnvResult?.run.friendlyId).toBe(seedSame?.run.friendlyId);
// And the cross-tenant victim run must NOT have been blocked by the
// attacker's waitpoint — its execution snapshot stays in its own env.
const victimAfter = await prisma.taskRun.findFirst({
where: { id: victimParent.id },
select: { runtimeEnvironmentId: true },
});
expect(victimAfter?.runtimeEnvironmentId).toBe(victimEnv.id);
await engine.quit();
}
);
});
@@ -0,0 +1,33 @@
import type { GoogleProfile } from "remix-auth-google";
import { describe, expect, it } from "vitest";
import { isGoogleEmailVerified } from "../app/services/googleEmailVerification.js";
// Build a minimal Google profile carrying just the email_verified claim the
// guard inspects. The real profile is much larger; only _json.email_verified
// matters here.
const profileWith = (emailVerified: unknown): GoogleProfile =>
({ _json: { email_verified: emailVerified } }) as unknown as GoogleProfile;
describe("isGoogleEmailVerified", () => {
it("accepts a profile Google marked email_verified === true", () => {
expect(isGoogleEmailVerified(profileWith(true))).toBe(true);
});
it("rejects a profile with email_verified === false (the account-linking takeover vector)", () => {
expect(isGoogleEmailVerified(profileWith(false))).toBe(false);
});
it("rejects when the email_verified claim is absent", () => {
expect(isGoogleEmailVerified(profileWith(undefined))).toBe(false);
// _json missing entirely
expect(isGoogleEmailVerified({} as unknown as GoogleProfile)).toBe(false);
});
it("is strict: truthy non-true values do not count as verified", () => {
// Google asserts a real boolean; a string "true"/"false" or a truthy number
// means the claim wasn't a genuine verification and must not be trusted.
expect(isGoogleEmailVerified(profileWith("true"))).toBe(false);
expect(isGoogleEmailVerified(profileWith("false"))).toBe(false);
expect(isGoogleEmailVerified(profileWith(1))).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { isAtOrBelow } from "../app/utils/inviteRoleLadder.js";
// systemRoles in canonical order: highest authority first.
const roles = [{ id: "owner" }, { id: "admin" }, { id: "member" }];
// Property under test: an inviter can only assign a role at or below their own,
// and a roleless inviter can assign nothing.
describe("isAtOrBelow", () => {
it("lets an inviter assign a role below their own", () => {
expect(isAtOrBelow(roles, "owner", "admin")).toBe(true);
expect(isAtOrBelow(roles, "admin", "member")).toBe(true);
});
it("lets an inviter assign their own level", () => {
expect(isAtOrBelow(roles, "admin", "admin")).toBe(true);
});
it("refuses assigning a role above the inviter's", () => {
expect(isAtOrBelow(roles, "admin", "owner")).toBe(false);
expect(isAtOrBelow(roles, "member", "admin")).toBe(false);
});
it("refuses a roleless inviter outright — the privilege-escalation vector", () => {
expect(isAtOrBelow(roles, null, "owner")).toBe(false);
expect(isAtOrBelow(roles, null, "member")).toBe(false);
});
it("refuses unknown / custom roles not on the ladder", () => {
expect(isAtOrBelow(roles, "owner", "custom-role-id")).toBe(false);
expect(isAtOrBelow(roles, "custom-role-id", "member")).toBe(false);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { redisTest } from "@internal/testcontainers";
import { type RedisOptions } from "ioredis";
import { describe, expect, it, vi } from "vitest";
import { type RedisWithClusterOptions } from "../app/redis.server.js";
import {
checkMfaRateLimit,
createMfaRateLimiters,
MFA_DAILY_ATTEMPTS,
MFA_PER_MINUTE_ATTEMPTS,
MfaRateLimitError,
} from "../app/services/mfa/mfaRateLimiter.server.js";
// redisTest spins up a container per test; give startup + the cumulative
// 30-attempt loop room.
vi.setConfig({ testTimeout: 60_000 });
// The container speaks plaintext; without tlsDisabled the client tries TLS,
// the connection fails, and @upstash/ratelimit fails open (allowing every
// attempt). Map the fixture options onto the shape createMfaRateLimiters
// expects, with TLS off.
const toRedisOptions = (redisOptions: RedisOptions): RedisWithClusterOptions => ({
host: redisOptions.host,
port: redisOptions.port,
username: redisOptions.username,
password: redisOptions.password,
tlsDisabled: true,
});
// A unique user id per test so sliding-window state never bleeds between
// cases (Redis is shared within a container).
let seq = 0;
const userId = (label: string) => `mfa-${label}-${seq++}`;
describe("checkMfaRateLimit", () => {
redisTest(
"allows up to the per-minute cap then blocks the next attempt",
async ({ redisOptions }) => {
const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) });
const id = userId("per-min");
// The first MFA_PER_MINUTE_ATTEMPTS (5) succeed.
for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) {
await expect(checkMfaRateLimit(id, limiters)).resolves.toBeUndefined();
}
// The 6th within the same minute is rejected.
await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
}
);
redisTest(
"caps cumulative attempts at the daily limit even when the per-minute window would allow them",
async ({ redisOptions }) => {
// Raise the per-minute cap out of the way so this test isolates the
// 24h cumulative window.
const limiters = createMfaRateLimiters({
redisOptions: toRedisOptions(redisOptions),
perMinuteAttempts: 100_000,
});
const id = userId("daily");
for (let i = 0; i < MFA_DAILY_ATTEMPTS; i++) {
await expect(checkMfaRateLimit(id, limiters)).resolves.toBeUndefined();
}
await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
}
);
redisTest("rate limits are scoped per user id", async ({ redisOptions }) => {
const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) });
const victim = userId("victim");
const bystander = userId("bystander");
// Exhaust the per-minute window for the victim.
for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) {
await checkMfaRateLimit(victim, limiters);
}
await expect(checkMfaRateLimit(victim, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
// A different user is unaffected.
await expect(checkMfaRateLimit(bystander, limiters)).resolves.toBeUndefined();
});
redisTest("the thrown error carries a positive retry-after", async ({ redisOptions }) => {
const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) });
const id = userId("retry-after");
for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) {
await checkMfaRateLimit(id, limiters);
}
const error = await checkMfaRateLimit(id, limiters).catch((e) => e);
expect(error).toBeInstanceOf(MfaRateLimitError);
expect((error as MfaRateLimitError).retryAfter).toBeGreaterThan(0);
});
redisTest(
"the daily cap is checked before the per-minute cap, so an exhausted day blocks the very first attempt of a fresh minute",
async ({ redisOptions }) => {
// perMinute high enough that only the daily window can trip.
const limiters = createMfaRateLimiters({
redisOptions: toRedisOptions(redisOptions),
perMinuteAttempts: 100_000,
dailyAttempts: 3,
});
const id = userId("daily-first");
await checkMfaRateLimit(id, limiters);
await checkMfaRateLimit(id, limiters);
await checkMfaRateLimit(id, limiters);
// Daily budget (3) is spent; the next attempt is rejected even though
// the per-minute window is nowhere near full.
await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
}
);
it("pins the production policy to the documented values", () => {
// Guards against a silent loosening of the caps in future edits.
expect(MFA_PER_MINUTE_ATTEMPTS).toBe(5);
expect(MFA_DAILY_ATTEMPTS).toBe(30);
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { normalizePromptOverrideSource } from "../app/v3/services/promptOverrideSource.js";
// Invariant: an override-creation path must never write the reserved
// `source: "code"`. Anything that isn't a legitimate caller value
// collapses to "dashboard".
describe("normalizePromptOverrideSource", () => {
it("never lets the privileged 'code' value through", () => {
expect(normalizePromptOverrideSource("code")).toBe("dashboard");
});
it("passes through caller-supplied non-code sources", () => {
expect(normalizePromptOverrideSource("api")).toBe("api");
expect(normalizePromptOverrideSource("dashboard")).toBe("dashboard");
expect(normalizePromptOverrideSource("sdk")).toBe("sdk");
});
it("defaults missing/empty source to 'dashboard'", () => {
expect(normalizePromptOverrideSource(undefined)).toBe("dashboard");
expect(normalizePromptOverrideSource(null)).toBe("dashboard");
expect(normalizePromptOverrideSource("")).toBe("dashboard");
});
});
+158
View File
@@ -0,0 +1,158 @@
import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { removeTeamMember } from "~/models/removeTeamMember.server";
vi.setConfig({ testTimeout: 60_000 });
async function seedOrgWithMembers(prisma: PrismaClient, slugBase: string) {
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
const admin = await prisma.user.create({
data: { email: `admin_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
const member = await prisma.user.create({
data: { email: `member_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
const organization = await prisma.organization.create({
data: {
title: slug,
slug,
members: {
createMany: {
data: [
{ userId: admin.id, role: "ADMIN" },
{ userId: member.id, role: "MEMBER" },
],
},
},
},
include: { members: true },
});
const adminMember = organization.members.find((m) => m.userId === admin.id)!;
const regularMember = organization.members.find((m) => m.userId === member.id)!;
return { organization, admin, member, adminMember, regularMember };
}
describe("removeTeamMember", () => {
containerTest(
"refuses to delete an OrgMember that belongs to a different org",
async ({ prisma }) => {
const a = await seedOrgWithMembers(prisma, "orga");
const b = await seedOrgWithMembers(prisma, "orgb");
await expect(
removeTeamMember(
{
userId: a.admin.id,
slug: a.organization.slug,
memberId: b.regularMember.id,
},
prisma
)
).rejects.toThrow();
const stillThere = await prisma.orgMember.findUnique({
where: { id: b.regularMember.id },
});
expect(stillThere).not.toBeNull();
}
);
containerTest("removes a member that belongs to the actor's org", async ({ prisma }) => {
const a = await seedOrgWithMembers(prisma, "orga");
const result = await removeTeamMember(
{
userId: a.admin.id,
slug: a.organization.slug,
memberId: a.regularMember.id,
},
prisma
);
expect(result.id).toBe(a.regularMember.id);
const gone = await prisma.orgMember.findUnique({
where: { id: a.regularMember.id },
});
expect(gone).toBeNull();
});
containerTest("allows the actor to leave their own org (self-leave)", async ({ prisma }) => {
const a = await seedOrgWithMembers(prisma, "orga");
const result = await removeTeamMember(
{
userId: a.member.id,
slug: a.organization.slug,
memberId: a.regularMember.id,
},
prisma
);
expect(result.userId).toBe(a.member.id);
const gone = await prisma.orgMember.findUnique({
where: { id: a.regularMember.id },
});
expect(gone).toBeNull();
});
containerTest(
"throws the in-org not-found error for an unknown memberId (locks the error message the route renders)",
async ({ prisma }) => {
const a = await seedOrgWithMembers(prisma, "orga");
await expect(
removeTeamMember(
{
userId: a.admin.id,
slug: a.organization.slug,
memberId: "doesnotexist",
},
prisma
)
).rejects.toThrow("Member not found in this organization");
}
);
containerTest("throws when the actor is not a member of the slug org", async ({ prisma }) => {
const a = await seedOrgWithMembers(prisma, "orga");
const b = await seedOrgWithMembers(prisma, "orgb");
await expect(
removeTeamMember(
{
userId: a.admin.id,
slug: b.organization.slug,
memberId: b.regularMember.id,
},
prisma
)
).rejects.toThrow("User does not have access to this organization");
const stillThere = await prisma.orgMember.findUnique({
where: { id: b.regularMember.id },
});
expect(stillThere).not.toBeNull();
});
containerTest(
"uses an exact-message error for cross-tenant attempts (locks contract)",
async ({ prisma }) => {
const a = await seedOrgWithMembers(prisma, "orga");
const b = await seedOrgWithMembers(prisma, "orgb");
await expect(
removeTeamMember(
{
userId: a.admin.id,
slug: a.organization.slug,
memberId: b.regularMember.id,
},
prisma
)
).rejects.toThrow("Member not found in this organization");
}
);
});
@@ -0,0 +1,109 @@
import { describe, expect, vi } from "vitest";
import { randomBytes } from "crypto";
import type { TaskRun } from "@trigger.dev/database";
import { postgresTest } from "@internal/testcontainers";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
import { seedTestRun } from "./helpers/seedTestRun";
// The service runs against the testcontainer prisma passed to its constructor.
// These empty stubs just satisfy the module-level db.server imports so the
// module tree loads; the guard under test uses the injected `this._prisma`.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
runOpsNewReplica: {},
runOpsLegacyReplica: {},
}));
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
vi.setConfig({ testTimeout: 60_000 });
describe("ReplayTaskRunService environment scoping", () => {
postgresTest(
"refuses to replay a run into an environment in another tenant's project",
async ({ prisma }) => {
// Tenant A owns the run; the override targets tenant B's environment.
// Distinct orgs => distinct projects.
const tenantA = await seedTestEnvironment(prisma);
const tenantB = await seedTestEnvironment(prisma);
const { run } = await seedTestRun(prisma, {
environmentId: tenantA.environment.id,
projectId: tenantA.project.id,
});
const service = new ReplayTaskRunService(prisma);
await expect(service.call(run, { environmentId: tenantB.environment.id })).rejects.toThrow(
"Cannot replay a run into an environment outside its project"
);
// No run was created in tenant B's project.
const runsInVictimProject = await prisma.taskRun.count({
where: { projectId: tenantB.project.id },
});
expect(runsInVictimProject).toBe(0);
}
);
postgresTest(
"refuses to replay when the override environment id does not exist",
async ({ prisma }) => {
const tenantA = await seedTestEnvironment(prisma);
const { run } = await seedTestRun(prisma, {
environmentId: tenantA.environment.id,
projectId: tenantA.project.id,
});
const service = new ReplayTaskRunService(prisma);
await expect(service.call(run, { environmentId: "env_does_not_exist" })).rejects.toThrow(
"Cannot replay a run into an environment outside its project"
);
}
);
postgresTest(
"allows a same-project override even when the source run carries no projectId",
async ({ prisma }) => {
// The buffered-run fallback passes a synthetic TaskRun with a
// runtimeEnvironmentId but no projectId. A same-project override must
// still be allowed, since the source project comes from the run's
// environment, not from the (absent) projectId.
const tenant = await seedTestEnvironment(prisma);
const suffix = randomBytes(4).toString("hex");
const stagingEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: "staging",
type: "STAGING",
apiKey: `tr_stg_${suffix}`,
pkApiKey: `pk_stg_${suffix}`,
shortcode: `stg${suffix.slice(0, 1)}`,
projectId: tenant.project.id,
organizationId: tenant.organization.id,
},
});
// Mirror the synthetic dashboard replay run: source env present, no projectId.
const syntheticRun = {
id: "run_buffered_synthetic",
friendlyId: "run_buffered_synthetic",
runtimeEnvironmentId: tenant.environment.id,
} as unknown as TaskRun;
const service = new ReplayTaskRunService(prisma);
// The guard must pass; execution then proceeds past it (into stubbed
// db.server), so any resulting error must NOT be the rejection.
const error = await service
.call(syntheticRun, { environmentId: stagingEnvironment.id })
.catch((e) => e as Error);
expect(error?.message).not.toBe(
"Cannot replay a run into an environment outside its project"
);
}
);
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { safeEnvironmentLogFields } from "../app/services/safeEnvironmentLog.js";
// AuthenticatedEnvironment carries `apiKey` (and pkApiKey) — these must never
// reach the logger when an environment is logged.
const environment = {
id: "env_1",
slug: "prod",
type: "PRODUCTION",
projectId: "proj_1",
organizationId: "org_1",
apiKey: "tr_prod_SUPERSECRET",
pkApiKey: "pk_prod_SECRET",
} as any;
describe("safeEnvironmentLogFields", () => {
it("emits only non-secret identity fields, never the api key", () => {
const fields = safeEnvironmentLogFields(environment);
const serialized = JSON.stringify(fields);
expect(serialized).not.toContain("tr_prod_SUPERSECRET");
expect(serialized).not.toContain("pk_prod_SECRET");
expect(serialized).not.toContain("apiKey");
expect(fields).toEqual({
id: "env_1",
slug: "prod",
type: "PRODUCTION",
projectId: "proj_1",
organizationId: "org_1",
});
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { slackSecretLogFields } from "../app/models/safeIntegrationLog.js";
const secret = {
botAccessToken: "xoxb-BOT-SECRET",
userAccessToken: "xoxp-USER-SECRET",
refreshToken: "xoxe-REFRESH-SECRET",
botScopes: ["chat:write", "channels:read"],
userScopes: ["identity.basic"],
};
// Built right before secretStore encrypts the same object — the log fields must
// never carry the actual token strings.
describe("slackSecretLogFields", () => {
it("emits presence booleans + scopes, never the token values", () => {
const fields = slackSecretLogFields("int_123", secret);
const serialized = JSON.stringify(fields);
for (const token of ["xoxb-BOT-SECRET", "xoxp-USER-SECRET", "xoxe-REFRESH-SECRET"]) {
expect(serialized).not.toContain(token);
}
expect(fields).toEqual({
friendlyId: "int_123",
hasUserToken: true,
hasRefreshToken: true,
botScopes: ["chat:write", "channels:read"],
userScopes: ["identity.basic"],
});
});
it("reports false when optional tokens are absent", () => {
const fields = slackSecretLogFields("int_456", { botAccessToken: "xoxb-x", botScopes: [] });
expect(fields.hasUserToken).toBe(false);
expect(fields.hasRefreshToken).toBe(false);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { missingJwtLogContext } from "../app/services/safeRequestLogContext.js";
const reqWith = (headers: Record<string, string>) =>
new Request("https://api.trigger.dev/api/v1/runs?x=1", { method: "POST", headers });
// The breadcrumb must never carry header values, only presence.
describe("missingJwtLogContext", () => {
it("returns only method, path, and a hasAuthorization boolean", () => {
const ctx = missingJwtLogContext(reqWith({ authorization: "Bearer tr_secret_key" }));
expect(ctx).toEqual({ method: "POST", path: "/api/v1/runs", hasAuthorization: true });
});
it("never includes the Authorization value or a raw headers map (the leak)", () => {
const ctx = missingJwtLogContext(
reqWith({ authorization: "Bearer tr_secret_key", cookie: "__session=abc" })
);
const serialized = JSON.stringify(ctx);
expect(serialized).not.toContain("tr_secret_key");
expect(serialized).not.toContain("Bearer");
expect(serialized).not.toContain("__session");
// hasAuthorization signals presence without leaking the value.
expect(ctx.hasAuthorization).toBe(true);
});
it("reports hasAuthorization=false when the header is absent", () => {
expect(missingJwtLogContext(reqWith({})).hasAuthorization).toBe(false);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
assertSafeWebhookUrl,
UnsafeWebhookUrlError,
} from "../app/v3/services/alerts/safeWebhookFetch.server.js";
// assertSafeWebhookUrl is the per-hop check behind safeWebhookFetch. These
// cases are decided lexically / by IP literal, so no network is needed; the
// DNS-resolution branch and redirect-following are not exercised here.
describe("assertSafeWebhookUrl", () => {
it("accepts a public http(s) URL given as an IP literal (no DNS needed)", async () => {
await expect(assertSafeWebhookUrl("https://93.184.216.34/hook")).resolves.toBeInstanceOf(URL);
});
it("rejects non-http(s) schemes", async () => {
for (const url of ["file:///etc/passwd", "gopher://x/_", "javascript:alert(1)", "ftp://h/x"]) {
await expect(assertSafeWebhookUrl(url)).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
}
});
it("rejects loopback / unspecified / RFC1918 / link-local IPv4 literals", async () => {
for (const host of [
"127.0.0.1",
"0.0.0.0",
"10.1.2.3",
"172.16.0.1",
"172.31.255.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1", // CGNAT 100.64/10
"100.127.255.255",
]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects multicast and reserved IPv4 ranges", async () => {
for (const host of ["224.0.0.1", "239.1.1.1", "240.0.0.1"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects loopback/internal hostnames before any DNS lookup", async () => {
for (const host of ["localhost", "svc.internal", "db.local", "0.0.0.0"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects unsafe IPv6 literals", async () => {
for (const host of ["[::1]", "[fe80::1]", "[fc00::1]", "[::ffff:127.0.0.1]"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects malformed URLs", async () => {
await expect(assertSafeWebhookUrl("not a url")).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
assertAddressAllowed,
assertSafeWebhookUrl,
UnsafeWebhookUrlError,
} from "../app/v3/services/alerts/safeWebhookUrl.server.js";
// These cases are decided by the lexical / IP-literal checks, so no network is
// needed. The DNS-resolution branch is not exercised here.
describe("assertSafeWebhookUrl", () => {
it("accepts a public http(s) URL given as an IP literal (no DNS needed)", async () => {
await expect(assertSafeWebhookUrl("https://93.184.216.34/hook?x=1")).resolves.toBeInstanceOf(
URL
);
});
it("rejects non-http(s) schemes", async () => {
for (const url of [
"file:///etc/passwd",
"gopher://evil/_",
"ftp://host/x",
"data:text/plain,hi",
"javascript:alert(1)",
]) {
await expect(assertSafeWebhookUrl(url)).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
}
});
it("rejects loopback and unspecified IPv4", async () => {
for (const host of ["127.0.0.1", "127.9.9.9", "0.0.0.0"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects RFC1918 private ranges", async () => {
for (const host of ["10.0.0.1", "172.16.0.1", "172.31.255.1", "192.168.1.1"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects link-local incl. the cloud metadata address", async () => {
await expect(
assertSafeWebhookUrl("http://169.254.169.254/latest/meta-data/")
).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"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects loopback/internal hostnames before any DNS lookup", async () => {
for (const host of ["localhost", "foo.localhost", "svc.internal", "db.local"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects unsafe IPv6 literals", async () => {
for (const host of ["[::1]", "[fe80::1]", "[fc00::1]", "[::ffff:127.0.0.1]"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
// Regression: bracketed IPv6 literals and Node's hex-normalized IPv4-mapped
// addresses (::ffff:127.0.0.1 -> ::ffff:7f00:1) must be caught lexically.
it("rejects bracketed and hex-mapped IPv6 loopback lexically", async () => {
for (const host of ["[::1]", "[::ffff:7f00:1]"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects malformed URLs", async () => {
await expect(assertSafeWebhookUrl("not a url")).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
});
});
// assertAddressAllowed is the connect-time check that safeWebhookFetch runs
// inside the socket's DNS lookup. These cases cover its address-range logic.
describe("assertAddressAllowed", () => {
it("allows public IPv4 / IPv6 addresses", () => {
expect(() => assertAddressAllowed("93.184.216.34", 4)).not.toThrow();
expect(() => assertAddressAllowed("2606:2800:220:1:248:1893:25c8:1946", 6)).not.toThrow();
});
it("rejects loopback / private / CGNAT / link-local IPv4 (incl. metadata)", () => {
for (const addr of [
"127.0.0.1",
"0.0.0.0",
"10.1.2.3",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
]) {
expect(() => assertAddressAllowed(addr, 4)).toThrow(UnsafeWebhookUrlError);
}
});
it("rejects loopback / ULA / link-local / mapped IPv6", () => {
for (const addr of ["::1", "fe80::1", "fc00::1", "::ffff:127.0.0.1"]) {
expect(() => assertAddressAllowed(addr, 6)).toThrow(UnsafeWebhookUrlError);
}
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { isSameOriginNavigation } from "../app/utils/sameOriginNavigation.js";
const ORIGIN = "https://app.trigger.dev";
const req = (headers: Record<string, string>) =>
new Request("https://app.trigger.dev/@/orgs/victim/anything", { headers });
// Property under test: only an unambiguously same-origin navigation is
// accepted; anything cross-site is refused.
describe("isSameOriginNavigation", () => {
it("accepts Sec-Fetch-Site: same-origin", () => {
expect(isSameOriginNavigation(req({ "sec-fetch-site": "same-origin" }), ORIGIN)).toBe(true);
});
it("rejects cross-site / same-site / none Sec-Fetch-Site (the phishing vector)", () => {
for (const v of ["cross-site", "same-site", "none"]) {
expect(isSameOriginNavigation(req({ "sec-fetch-site": v }), ORIGIN)).toBe(false);
}
});
it("falls back to a Referer matching the dashboard origin", () => {
expect(isSameOriginNavigation(req({ referer: "https://app.trigger.dev/runs" }), ORIGIN)).toBe(
true
);
});
it("rejects a Referer from a different origin", () => {
expect(isSameOriginNavigation(req({ referer: "https://evil.example.com/x" }), ORIGIN)).toBe(
false
);
});
it("denies by default when neither Sec-Fetch-Site nor Referer is present", () => {
expect(isSameOriginNavigation(req({}), ORIGIN)).toBe(false);
});
it("rejects an unparseable Referer", () => {
expect(isSameOriginNavigation(req({ referer: "not a url" }), ORIGIN)).toBe(false);
});
it("prefers Sec-Fetch-Site over Referer when both are present", () => {
// A same-origin Referer must not rescue a cross-site Sec-Fetch-Site.
expect(
isSameOriginNavigation(
req({ "sec-fetch-site": "cross-site", referer: "https://app.trigger.dev/x" }),
ORIGIN
)
).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { sanitizeHttpUrl } from "../app/utils/sanitizeUrl.js";
// sanitizeHttpUrl returns undefined for anything that isn't http(s), so callers
// fall back to a safe default rather than rendering it into an href.
describe("sanitizeHttpUrl", () => {
it("passes through http and https URLs", () => {
expect(sanitizeHttpUrl("https://trigger.dev/changelog")).toBe("https://trigger.dev/changelog");
expect(sanitizeHttpUrl("http://example.com/x?y=1")).toBe("http://example.com/x?y=1");
});
it("rejects script-bearing and non-http(s) schemes", () => {
for (const url of [
"javascript:alert(1)",
"javascript:alert(document.cookie)//",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"file:///etc/passwd",
]) {
expect(sanitizeHttpUrl(url)).toBeUndefined();
}
});
it("returns undefined for empty / nullish input", () => {
expect(sanitizeHttpUrl(undefined)).toBeUndefined();
expect(sanitizeHttpUrl(null)).toBeUndefined();
expect(sanitizeHttpUrl("")).toBeUndefined();
});
it("returns undefined for unparseable input", () => {
expect(sanitizeHttpUrl("not a url")).toBeUndefined();
});
});
@@ -0,0 +1,44 @@
import { WORKER_HEADERS } from "@trigger.dev/core/v3/workers";
import { describe, expect, it } from "vitest";
import { sanitizeWorkerHeaders } from "../app/v3/services/worker/sanitizeWorkerHeaders.js";
// Runs before request headers are logged. The security property: secret-bearing
// managed-worker headers must never make it into the sanitized (loggable)
// object.
describe("sanitizeWorkerHeaders", () => {
const build = () =>
new Headers({
authorization: "Bearer tr_secret",
cookie: "__session=abc",
[WORKER_HEADERS.MANAGED_SECRET]: "cluster-shared-secret",
[WORKER_HEADERS.INSTANCE_NAME]: "supervisor-1",
"content-type": "application/json",
});
it("strips the managed worker secret (the leak this fixes)", () => {
const out = sanitizeWorkerHeaders(build());
expect(out[WORKER_HEADERS.MANAGED_SECRET]).toBeUndefined();
});
it("strips authorization and cookie", () => {
const out = sanitizeWorkerHeaders(build());
expect(out["authorization"]).toBeUndefined();
expect(out["cookie"]).toBeUndefined();
});
it("preserves non-sensitive headers", () => {
const out = sanitizeWorkerHeaders(build());
expect(out["content-type"]).toBe("application/json");
expect(out[WORKER_HEADERS.INSTANCE_NAME]).toBe("supervisor-1");
});
it("matches header names case-insensitively", () => {
const h = new Headers({
Authorization: "Bearer x",
"X-Trigger-Worker-Managed-Secret": "s",
});
const out = sanitizeWorkerHeaders(h);
// Headers lower-cases keys; both must be gone regardless of input casing.
expect(Object.keys(out)).toHaveLength(0);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { slackAccessResultLogFields } from "../app/models/slackOAuthResultLog.js";
const result = {
ok: true,
access_token: "xoxb-BOT-SECRET",
refresh_token: "xoxe-REFRESH-SECRET",
scope: "chat:write,channels:read",
team: { id: "T123" },
authed_user: { id: "U1", access_token: "xoxp-USER-SECRET" },
};
// Logged right after the Slack OAuth exchange — must never carry the tokens.
describe("slackAccessResultLogFields", () => {
it("emits only non-secret diagnostics, never the tokens", () => {
const fields = slackAccessResultLogFields(result);
const serialized = JSON.stringify(fields);
for (const token of ["xoxb-BOT-SECRET", "xoxp-USER-SECRET", "xoxe-REFRESH-SECRET"]) {
expect(serialized).not.toContain(token);
}
expect(fields).toEqual({
teamId: "T123",
scope: "chat:write,channels:read",
hasUserToken: true,
hasRefreshToken: true,
});
});
it("reports false when user/refresh tokens are absent", () => {
const fields = slackAccessResultLogFields({ team: { id: "T9" }, scope: "chat:write" });
expect(fields.hasUserToken).toBe(false);
expect(fields.hasRefreshToken).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
attemptInEnvironmentWhere,
batchRunInEnvironmentWhere,
} from "../app/v3/services/triggerV1Scoping.js";
// Caller-supplied parent/dependent attempt & batch friendlyIds must be resolved
// scoped to the caller's environment — a where clause missing that constraint
// lets a foreign id resolve (the cross-tenant bug). Pins the scope on each query.
describe("triggerV1 scoping where-clauses", () => {
it("scopes attempt lookups to the env via the related run", () => {
const where = attemptInEnvironmentWhere("attempt_x", "env_caller");
expect(where.friendlyId).toBe("attempt_x");
expect(where.taskRun).toEqual({ runtimeEnvironmentId: "env_caller" });
});
it("scopes batch-run lookups to the env directly", () => {
const where = batchRunInEnvironmentWhere("batch_x", "env_caller");
expect(where.friendlyId).toBe("batch_x");
expect(where.runtimeEnvironmentId).toBe("env_caller");
});
});
@@ -0,0 +1,22 @@
import { WorkerInstanceGroupType } from "@trigger.dev/database";
import { describe, expect, it } from "vitest";
import { isWorkerGroupAllowedForProject } from "../app/v3/services/worker/workerGroupAccess.js";
// UNMANAGED worker groups are per-project; MANAGED are shared. A project must
// not be able to route onto another project's UNMANAGED group.
describe("isWorkerGroupAllowedForProject", () => {
it("allows an UNMANAGED group owned by the calling project", () => {
const group = { type: WorkerInstanceGroupType.UNMANAGED, projectId: "proj_me" };
expect(isWorkerGroupAllowedForProject(group, "proj_me")).toBe(true);
});
it("rejects an UNMANAGED group owned by a different project (the cross-tenant vector)", () => {
const group = { type: WorkerInstanceGroupType.UNMANAGED, projectId: "proj_other" };
expect(isWorkerGroupAllowedForProject(group, "proj_me")).toBe(false);
});
it("allows MANAGED (shared) groups regardless of project", () => {
const group = { type: WorkerInstanceGroupType.MANAGED, projectId: null };
expect(isWorkerGroupAllowedForProject(group, "proj_me")).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -6,9 +6,9 @@ import { tryCatch } from "@trigger.dev/core/utils";
import type { Command } from "commander";
import { z } from "zod";
import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js";
import { CLOUD_API_URL } from "../consts.js";
import { serverMetadata } from "../mcp/config.js";
import { McpContext } from "../mcp/context.js";
import { toMcpContextOptions } from "../mcp/contextOptions.js";
import { FileLogger } from "../mcp/logger.js";
import { registerTools } from "../mcp/tools.js";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
@@ -95,13 +95,7 @@ export async function mcpCommand(options: McpCommandOptions) {
? new FileLogger(options.logFile, server)
: undefined;
const context = new McpContext(server, {
projectRef: options.projectRef,
fileLogger,
apiUrl: options.apiUrl ?? CLOUD_API_URL,
profile: options.profile,
readonly: options.readonly,
});
const context = new McpContext(server, toMcpContextOptions(options, fileLogger));
registerTools(context);
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { toMcpContextOptions } from "./contextOptions.js";
import type { McpCommandOptions } from "../commands/mcp.js";
// The dev-only guards only work if `--dev-only` is threaded from the parsed
// CLI options into the McpContext. These tests pin that wiring.
const baseOptions = {
projectRef: "proj_123",
apiUrl: "https://api.example.com",
profile: "default",
readonly: false,
devOnly: false,
} as unknown as McpCommandOptions;
describe("toMcpContextOptions", () => {
it("threads devOnly=true through to the context options", () => {
expect(toMcpContextOptions({ ...baseOptions, devOnly: true }, undefined).devOnly).toBe(true);
});
it("threads devOnly=false through to the context options", () => {
expect(toMcpContextOptions({ ...baseOptions, devOnly: false }, undefined).devOnly).toBe(false);
});
it("carries the other relevant options across", () => {
const result = toMcpContextOptions(baseOptions, undefined);
expect(result.projectRef).toBe("proj_123");
expect(result.apiUrl).toBe("https://api.example.com");
expect(result.profile).toBe("default");
expect(result.readonly).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { CLOUD_API_URL } from "../consts.js";
import type { McpCommandOptions } from "../commands/mcp.js";
import type { McpContextOptions } from "./context.js";
import type { FileLogger } from "./logger.js";
/**
* Map parsed CLI options onto the `McpContext` options. `devOnly` must be
* forwarded here or the context sees `undefined` and its dev-only guards
* never fire.
*
* Kept in its own module (type-only imports) so the wiring can be
* unit-tested without loading the full command/tool/build chain.
*/
export function toMcpContextOptions(
options: McpCommandOptions,
fileLogger: FileLogger | undefined
): McpContextOptions {
return {
projectRef: options.projectRef,
fileLogger,
apiUrl: options.apiUrl ?? CLOUD_API_URL,
profile: options.profile,
readonly: options.readonly,
devOnly: options.devOnly,
};
}