Skip to content

Commit 270c753

Browse files
committed
csrf protection
1 parent afead1e commit 270c753

File tree

3 files changed

+114
-2
lines changed

3 files changed

+114
-2
lines changed

apps/webapp/app/routes/admin._index.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import {
2222
import { useUser } from "~/hooks/useUser";
2323
import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server";
2424
import { requireUser, requireUserId } from "~/services/session.server";
25+
import {
26+
validateAndConsumeImpersonationToken,
27+
} from "~/services/impersonation.server";
2528
import { createSearchParams } from "~/utils/searchParams";
29+
import { logger } from "~/services/logger.server";
2630

2731
export const SearchParams = z.object({
2832
page: z.coerce.number().optional(),
@@ -48,8 +52,23 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4852
// Check if this is an impersonation request via query parameter (e.g., from Plain customer cards)
4953
const url = new URL(request.url);
5054
const impersonateUserId = url.searchParams.get("impersonate");
55+
const impersonationToken = url.searchParams.get("impersonationToken");
5156

5257
if (impersonateUserId) {
58+
// Require both userId and token for GET-based impersonation
59+
if (!impersonationToken) {
60+
logger.warn("Impersonation request missing token");
61+
return redirect("/");
62+
}
63+
64+
// Validate and consume the token (prevents replay attacks)
65+
const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken);
66+
67+
if (!validatedUserId || validatedUserId !== impersonateUserId) {
68+
logger.warn("Invalid or expired impersonation token");
69+
return redirect("/");
70+
}
71+
5372
return handleImpersonationRequest(request, impersonateUserId);
5473
}
5574

apps/webapp/app/routes/api.v1.plain.customer-cards.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from "zod";
66
import { prisma } from "~/db.server";
77
import { env } from "~/env.server";
88
import { logger } from "~/services/logger.server";
9+
import { generateImpersonationToken } from "~/services/impersonation.server";
910

1011
// Schema for the request body from Plain
1112
const PlainCustomerCardRequestSchema = z.object({
@@ -142,8 +143,10 @@ export async function action({ request }: ActionFunctionArgs) {
142143
for (const cardKey of cardKeys) {
143144
switch (cardKey) {
144145
case "account-details": {
145-
// Build the impersonate URL
146-
const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}`;
146+
// Generate a signed one-time token for impersonation
147+
const impersonationToken = await generateImpersonationToken(user.id);
148+
// Build the impersonate URL with token for CSRF protection
149+
const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`;
147150

148151
cards.push({
149152
key: "account-details",

apps/webapp/app/services/impersonation.server.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { createCookieSessionStorage, type Session } from "@remix-run/node";
2+
import { SignJWT, jwtVerify, errors } from "jose";
3+
import { singleton } from "~/utils/singleton";
4+
import { createRedisClient, type RedisClient } from "~/redis.server";
25
import { env } from "~/env.server";
6+
import { logger } from "~/services/logger.server";
37

48
export const impersonationSessionStorage = createCookieSessionStorage({
59
cookie: {
@@ -42,3 +46,89 @@ export async function clearImpersonationId(request: Request) {
4246

4347
return session;
4448
}
49+
50+
// Impersonation token utilities for CSRF protection
51+
const IMPERSONATION_TOKEN_EXPIRY_SECONDS = 5 * 60; // 5 minutes
52+
53+
function getImpersonationTokenSecret(): Uint8Array {
54+
return new TextEncoder().encode(env.SESSION_SECRET);
55+
}
56+
57+
function getImpersonationTokenRedisClient(): RedisClient {
58+
return singleton(
59+
"impersonationTokenRedis",
60+
() =>
61+
createRedisClient("impersonation:token", {
62+
host: env.CACHE_REDIS_HOST,
63+
port: env.CACHE_REDIS_PORT,
64+
username: env.CACHE_REDIS_USERNAME,
65+
password: env.CACHE_REDIS_PASSWORD,
66+
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
67+
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
68+
keyPrefix: "impersonation:token:",
69+
})
70+
);
71+
}
72+
73+
/**
74+
* Generate a signed one-time impersonation token for a user
75+
*/
76+
export async function generateImpersonationToken(userId: string): Promise<string> {
77+
const secret = getImpersonationTokenSecret();
78+
const now = Math.floor(Date.now() / 1000);
79+
80+
const token = await new SignJWT({ userId })
81+
.setProtectedHeader({ alg: "HS256" })
82+
.setIssuedAt(now)
83+
.setExpirationTime(now + IMPERSONATION_TOKEN_EXPIRY_SECONDS)
84+
.setIssuer("https://trigger.dev")
85+
.setAudience("https://trigger.dev/admin")
86+
.sign(secret);
87+
88+
return token;
89+
}
90+
91+
/**
92+
* Validate and consume an impersonation token (prevents replay attacks)
93+
*/
94+
export async function validateAndConsumeImpersonationToken(
95+
token: string
96+
): Promise<string | undefined> {
97+
try {
98+
const secret = getImpersonationTokenSecret();
99+
100+
// Verify the token signature and expiration
101+
const { payload } = await jwtVerify(token, secret, {
102+
issuer: "https://trigger.dev",
103+
audience: "https://trigger.dev/admin",
104+
});
105+
106+
const userId = payload.userId as string | undefined;
107+
if (!userId || typeof userId !== "string") {
108+
return undefined;
109+
}
110+
111+
// Check if token has already been used (prevent replay attacks)
112+
const redis = getImpersonationTokenRedisClient();
113+
const tokenKey = token;
114+
115+
// Try to set the key with NX (only if not exists) and expiration
116+
// This atomically marks the token as used
117+
const result = await redis.set(tokenKey, "1", "EX", IMPERSONATION_TOKEN_EXPIRY_SECONDS, "NX");
118+
119+
if (result !== "OK") {
120+
// Token was already used
121+
return undefined;
122+
}
123+
124+
return userId;
125+
} catch (error) {
126+
if (error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid) {
127+
return undefined;
128+
}
129+
logger.error("Error validating impersonation token", {
130+
error: error instanceof Error ? error.message : String(error),
131+
});
132+
return undefined;
133+
}
134+
}

0 commit comments

Comments
 (0)