fix: security release 2026-07-06 (#4199)
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
+11
-2
@@ -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({
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+18
-1
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user