-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(webapp): let an admin switch impersonation target without stopping first #4576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import { redirect } from "@remix-run/server-runtime"; | ||
| import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server"; | ||
| import { $replica, $transaction, prisma, type PrismaClientOrTransaction } from "~/db.server"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import type { SearchParams } from "~/routes/admin._index"; | ||
| import { | ||
|
|
@@ -9,7 +9,7 @@ import { | |
| setImpersonationId, | ||
| } from "~/services/impersonation.server"; | ||
| import { authenticator } from "~/services/auth.server"; | ||
| import { requireUser } from "~/services/session.server"; | ||
| import { getRealUser } from "~/services/session.server"; | ||
| import { extractClientIp } from "~/utils/extractClientIp.server"; | ||
| import { impersonationDestinationPath } from "~/utils/pathBuilder"; | ||
|
|
||
|
|
@@ -210,35 +210,76 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se | |
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Starts (or switches) impersonation. | ||
| * | ||
| * The admin gate resolves the *real* authenticated user itself. `requireUser` returns the | ||
| * impersonation target while impersonating, so callers that gated on it refused an admin who was | ||
| * already impersonating someone — they had to stop first — and would have attributed the audit row | ||
| * to the target rather than the admin. | ||
| * | ||
| * `verifiedAdmin` exists only so tests can supply an admin without a session cookie. Production | ||
| * callers must not pass it: passing a `requireUser` result is exactly the bug described above. | ||
| */ | ||
| export async function redirectWithImpersonation( | ||
| request: Request, | ||
| userId: string, | ||
| path: string, | ||
| currentUser?: { id: string; admin: boolean }, | ||
| verifiedAdmin?: { id: string; admin: boolean }, | ||
| prismaClient: PrismaClientOrTransaction = prisma | ||
| ) { | ||
| const user = currentUser ?? (await requireUser(request)); | ||
| if (!user.admin) { | ||
| const admin = verifiedAdmin ?? (await getRealUser(request, prismaClient)); | ||
| if (!admin?.admin) { | ||
| throw new Error("Unauthorized"); | ||
| } | ||
|
|
||
| const xff = request.headers.get("x-forwarded-for"); | ||
| const ipAddress = extractClientIp(xff); | ||
| const previousTargetId = await getImpersonationId(request); | ||
|
|
||
| // Switching straight from one target to another never passes through `clearImpersonation`, so the | ||
| // previous session is closed here, or the trail shows two overlapping STARTs. | ||
| // | ||
| // Both rows are written in one transaction: as separate statements, a failure between them could | ||
| // start an impersonation whose only audit row is the STOP for the previous target — an admin | ||
| // acting as someone with no record of it. | ||
|
Comment on lines
+243
to
+245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win The catch block contradicts the stated audit guarantee. The comment at Lines 243-245 states the transaction prevents "an admin acting as someone with no record of it". The catch block at Lines 277-284 logs the failure and then execution continues. Lines 286-290 set the impersonation cookie and redirect. If the audit transaction fails, impersonation starts with no Choose one behavior and make the code and the comment agree:
🔒 Fail-closed variant } catch (error) {
logger.error("Failed to create impersonation audit log", {
error,
adminId: admin.id,
targetId: userId,
previousTargetId,
});
+ throw error;
}Also applies to: 277-284 |
||
| // | ||
| // `createdAt` is stamped explicitly rather than left to `@default(now())`, because Postgres `now()` | ||
| // is the *transaction* timestamp: inside one transaction both rows would take the same value, and | ||
| // an audit view ordered by that column couldn't tell which came first. | ||
| const startedAt = new Date(); | ||
| const closedAt = new Date(startedAt.getTime() - 1); | ||
|
|
||
| try { | ||
| await prismaClient.impersonationAuditLog.create({ | ||
| data: { | ||
| action: "START", | ||
| adminId: user.id, | ||
| targetId: userId, | ||
| ipAddress, | ||
| }, | ||
| await $transaction(prismaClient, "startImpersonationAudit", async (tx) => { | ||
| if (previousTargetId && previousTargetId !== userId) { | ||
| await tx.impersonationAuditLog.create({ | ||
| data: { | ||
| action: "STOP", | ||
| adminId: admin.id, | ||
| targetId: previousTargetId, | ||
| ipAddress, | ||
| createdAt: closedAt, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| await tx.impersonationAuditLog.create({ | ||
| data: { | ||
| action: "START", | ||
| adminId: admin.id, | ||
| targetId: userId, | ||
| ipAddress, | ||
| createdAt: startedAt, | ||
| }, | ||
| }); | ||
| }); | ||
|
Comment on lines
253
to
276
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Switching impersonation target can leave no record at all when one record fails to save Both audit entries are now written together in a single all-or-nothing database write ( Mechanism: atomic write plus swallowed error removes the previously independent START rowBefore this change, Now, when The in-code comment claims the transaction prevents "an admin acting as someone with no record of it", but because the error is swallowed rather than aborting the impersonation, the transaction actually widens that window instead of closing it. Either the Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } catch (error) { | ||
| logger.error("Failed to create impersonation audit log", { | ||
| error, | ||
| adminId: user.id, | ||
| adminId: admin.id, | ||
| targetId: userId, | ||
| previousTargetId, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -308,7 +349,8 @@ export async function startImpersonation( | |
| request: Request, | ||
| organizationSlug: string, | ||
| path: string, | ||
| currentUser: { id: string; admin: boolean }, | ||
| // Test-only, forwarded to `redirectWithImpersonation` — see its docstring. | ||
| verifiedAdmin?: { id: string; admin: boolean }, | ||
| clients: { read: PrismaClientOrTransaction; write: PrismaClientOrTransaction } = { | ||
| read: $replica, | ||
| write: prisma, | ||
|
|
@@ -325,7 +367,7 @@ export async function startImpersonation( | |
| request, | ||
| target.userId, | ||
| impersonationDestinationPath(organizationSlug, path, new URL(request.url).search), | ||
| currentUser, | ||
| verifiedAdmin, | ||
| clients.write | ||
| ); | ||
| } | ||
|
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { | ||
| redirect, | ||
| type ActionFunctionArgs, | ||
| type LoaderFunctionArgs, | ||
| } from "@remix-run/server-runtime"; | ||
| import { z } from "zod"; | ||
| import { redirectWithImpersonation } from "~/models/admin.server"; | ||
| import { authenticator } from "~/services/auth.server"; | ||
| import { rbac } from "~/services/rbac.server"; | ||
| import { getRealUser } from "~/services/session.server"; | ||
| import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import { sanitizeRedirectPath } from "~/utils"; | ||
|
Comment on lines
+1
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Release notes will not mention this webapp fix This change only touches server code under Repository rule: server-only changes require a `.server-changes/` fileAGENTS.md ("Changesets and Server Changes") and CONTRIBUTING.md ("Adding server changes") both state that a PR changing only server components ( Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| /** | ||
| * Served at `/admin/impersonate`, but the trailing `_` on `admin_` keeps it out of the `admin.tsx` | ||
| * layout on purpose. | ||
| * | ||
| * That layout's loader is `dashboardLoader({ authorization: { requireSuper: true } })`, which | ||
| * resolves the user through `getUserId` — the impersonated id while impersonating. So starting on a | ||
| * second target ran the parent gate against the target, which isn't a super admin, and it answered | ||
| * with its own `redirect("/")`. Nesting would leave this route's behaviour depending on the router | ||
| * preferring the deepest redirect; opting out removes the question. Nothing is lost — this route | ||
| * only ever redirects, so it never rendered inside the layout anyway. | ||
| */ | ||
|
|
||
| const FormSchema = z.object({ id: z.string() }); | ||
|
|
||
| /** | ||
| * The real authenticated user, or null when they're signed in but not an admin. | ||
| * | ||
| * Must not use `requireUser`: while impersonating it resolves to the impersonation target, whose | ||
| * `admin` is false, so an admin switching to a second target was bounced to `/` and left on the | ||
| * first one. | ||
| * | ||
| * Throws a login redirect when nobody is signed in, keeping this URL as `redirectTo` so the | ||
| * impersonation survives the round trip — the one-time token is validated after this gate, so it's | ||
| * still unconsumed when the browser comes back. Collapsing that into the non-admin `/` redirect | ||
| * would drop the link the agent clicked. | ||
| */ | ||
| async function requireRealAdmin(request: Request) { | ||
| if (!(await authenticator.isAuthenticated(request))) { | ||
| const url = new URL(request.url); | ||
| const redirectTo = sanitizeRedirectPath(`${url.pathname}${url.search}`); | ||
| throw redirect(`/login?${new URLSearchParams([["redirectTo", redirectTo]])}`); | ||
| } | ||
|
|
||
| const admin = await getRealUser(request); | ||
| if (!admin) return null; | ||
|
|
||
| // Same gate `dashboardLoader({ authorization: { requireSuper: true } })` applies, evaluated | ||
| // against the real admin. It can't be reached through the builder here, because the builder | ||
| // resolves its subject with `getUserId` — the impersonated id while impersonating, which is the | ||
| // bug this route exists to fix. So the ability is built explicitly for `admin.id` instead of | ||
| // trusting the raw `User.admin` column: `canSuper()` is only equal to that column in the OSS | ||
| // fallback, and a plugin is free to be stricter. requireSuper needs no org/project scope. | ||
| const auth = await rbac.authenticateSession(request, { userId: admin.id }); | ||
| if (!auth.ok || !auth.ability.canSuper()) return null; | ||
|
|
||
| return admin; | ||
| } | ||
|
|
||
| async function handleImpersonationRequest(request: Request, userId: string): Promise<Response> { | ||
| const admin = await requireRealAdmin(request); | ||
| if (!admin) { | ||
| return redirect("/"); | ||
| } | ||
| return redirectWithImpersonation(request, userId, "/"); | ||
| } | ||
|
Comment on lines
+63
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect the SameSite attribute of the auth and impersonation session cookies.
rg -nP --type=ts -C 6 'createCookieSessionStorage|sameSite' apps/webapp/app | head -120
# Confirm the same-origin helper contract used by the sibling route.
fd -t f 'sameOriginNavigation.ts' apps/webapp | while IFS= read -r f; do
echo "=== $f ==="
cat -n "$f"
doneRepository: triggerdotdev/trigger.dev Length of output: 11284 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '=== admin impersonation route ==='
fd -t f 'admin_.impersonate.tsx' apps/webapp | while IFS= read -r f; do
cat -n "$f"
done
printf '%s\n' '=== sibling action and token usage ==='
rg -n -C 12 'isSameOriginNavigation|impersonationToken|handleImpersonationRequest|redirectWithImpersonation' apps/webapp/app/routes apps/webapp/app/services apps/webapp/app/utils
printf '%s\n' '=== session cookie consumers ==='
rg -n -C 8 'sessionStorage|getSession\\(|__session|requireRealAdmin' apps/webapp/app/services apps/webapp/app/routes apps/webapp/app/utils | head -240Repository: triggerdotdev/trigger.dev Length of output: 36844 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '=== impersonation redirect implementation ==='
rg -n -C 16 'function redirectWithImpersonation|const redirectWithImpersonation|export .*redirectWithImpersonation' apps/webapp/app/models apps/webapp/app/services
printf '%s\n' '=== authentication session storage ==='
rg -n -C 12 'sessionStorage|authenticator|createCookieSessionStorage|sameSite' apps/webapp/app/services/auth.server.ts apps/webapp/app/services/session.server.ts apps/webapp/app/services/sessionStorage.server.ts
printf '%s\n' '=== all admin impersonation entry points ==='
rg -n -C 8 'admin/impersonate|redirectWithImpersonation\\(' apps/webapp/appRepository: triggerdotdev/trigger.dev Length of output: 16209 Add the same-origin check to the POST action. The |
||
|
|
||
| export const loader = async ({ request }: LoaderFunctionArgs) => { | ||
| const url = new URL(request.url); | ||
| const impersonateUserId = url.searchParams.get("impersonate"); | ||
| const impersonationToken = url.searchParams.get("impersonationToken"); | ||
|
|
||
| if (!impersonateUserId) { | ||
| return redirect("/admin"); | ||
| } | ||
|
|
||
| if (!impersonationToken) { | ||
| logger.warn("Impersonation request missing token"); | ||
| return redirect("/"); | ||
| } | ||
|
|
||
| // Check admin BEFORE consuming the one-time token, so a rejected request leaves the token usable. | ||
| const admin = await requireRealAdmin(request); | ||
| if (!admin) { | ||
| return redirect("/"); | ||
| } | ||
|
|
||
| const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken); | ||
|
|
||
| if (!validatedUserId || validatedUserId !== impersonateUserId) { | ||
| logger.warn("Invalid or expired impersonation token"); | ||
| return redirect("/"); | ||
| } | ||
|
|
||
| return redirectWithImpersonation(request, impersonateUserId, "/"); | ||
| }; | ||
|
|
||
| export async function action({ request }: ActionFunctionArgs) { | ||
| if (request.method.toLowerCase() !== "post") { | ||
| return new Response("Method not allowed", { status: 405 }); | ||
| } | ||
|
|
||
| const payload = Object.fromEntries(await request.formData()); | ||
| const { id } = FormSchema.parse(payload); | ||
|
Comment on lines
+106
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Use
The parse also runs before 🐛 Proposed fix const payload = Object.fromEntries(await request.formData());
- const { id } = FormSchema.parse(payload);
+ const parsed = FormSchema.safeParse(payload);
+ if (!parsed.success) {
+ return new Response("Bad request", { status: 400 });
+ }
- return handleImpersonationRequest(request, id);
+ return handleImpersonationRequest(request, parsed.data.id);
} |
||
|
|
||
| return handleImpersonationRequest(request, id); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Restrict the
verifiedAdminauthentication bypass.verifiedAdminskipsgetRealUsercompletely. The only protection is the docstring at Lines 221-222.startImpersonationalso forwards this parameter as a public optional argument (Lines 352-353, 370), so both exported functions accept a caller-supplied admin identity that is never verified against the session.A future caller can pass
{ id, admin: true }and start impersonation for any target without an authenticated admin session. The audit row then records that unverifiedidas the actor.Prefer a test seam that cannot become an auth bypass. Two options:
envfromapp/env.server.ts.🔒 Option 1: inject the resolver
Update
startImpersonationto forward the same seam.Also applies to: 352-353
Source: Coding guidelines