Superadmin impersonation, hardened auth/upload paths, dependency updates

- Add superadmin impersonation: sessions.impersonated_by (migration 0003) is
  stamped onto audit rows so impersonated actions are attributable, with a
  persistent ImpersonationBanner in the app shell.
- Harden auth and upload handling across routes (safe redirect targets,
  filename sanitization, checkout grant handling).
- Update dependencies: Sentry 8 -> 10, @fastify/static 8 -> 10,
  react-router-dom 6.30.6; add find-my-way / fast-uri overrides.
- Add tests for safe-next, checkout-grant, and upload-filename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-08-26 10:25:39 -04:00
co-authored by Claude Opus 5
parent 4a1122a7c9
commit eb36b81dc9
28 changed files with 7936 additions and 5763 deletions
+3 -3
View File
@@ -10,7 +10,7 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@sentry/react": "^8.45.0",
"@sentry/react": "^10.71.0",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -19,7 +19,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-router-dom": "^6.28.0",
"react-router-dom": "^6.30.6",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.5",
"zod": "^3.23.8"
@@ -30,7 +30,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"postcss": "^8.5.26",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
@@ -3,6 +3,7 @@ import { useMe } from '@/hooks/useAuth';
import { Sidebar } from './Sidebar';
import { Topbar } from './Topbar';
import { VerifyEmailBanner } from './VerifyEmailBanner';
import { ImpersonationBanner } from './ImpersonationBanner';
export function AppLayout() {
const me = useMe();
@@ -19,6 +20,7 @@ export function AppLayout() {
<div className="min-h-screen flex bg-ink-50">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<ImpersonationBanner />
<Topbar />
<VerifyEmailBanner />
<main className="flex-1 overflow-y-auto">
@@ -0,0 +1,36 @@
import { UserCog } from 'lucide-react';
import { useEndImpersonation, useMe } from '@/hooks/useAuth';
/**
* Persistent, non-dismissible warning shown whenever the current session was opened by a
* superadmin impersonating this user. It must not be dismissible: an admin who forgets they are
* inside a customer's firm can take real actions against real client data, and every one of
* those actions is recorded against the customer's name.
*/
export function ImpersonationBanner() {
const me = useMe();
const endImpersonation = useEndImpersonation();
if (!me.data?.impersonatedBy) return null;
return (
<div
role="alert"
className="border-b border-rose-300 bg-rose-600 px-4 py-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-white"
>
<UserCog className="h-4 w-4 flex-none" aria-hidden="true" />
<span>
Support session you are acting as <strong>{me.data.email}</strong>. Everything you do is
recorded against this account and expires within the hour.
</span>
<button
type="button"
onClick={() => endImpersonation.mutate()}
disabled={endImpersonation.isPending}
className="ml-auto rounded-md bg-white/15 px-3 py-1 font-medium underline-offset-2 hover:bg-white/25 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:opacity-60"
>
{endImpersonation.isPending ? 'Ending…' : 'End session'}
</button>
</div>
);
}
+19
View File
@@ -10,6 +10,8 @@ export interface AuthUser {
isSuperadmin?: boolean;
isSuspended?: boolean;
emailVerified?: boolean;
/** Superadmin id when this session was opened by admin impersonation, else null. */
impersonatedBy?: string | null;
}
interface MeResponse {
@@ -69,3 +71,20 @@ export function useLogout() {
onSuccess: () => qc.setQueryData(ME_KEY, null),
});
}
/**
* Leave an impersonated session. The server destroys the borrowed session outright, so there is
* no session left to return to — the admin lands on the login page and signs in as themselves.
*/
export function useEndImpersonation() {
const qc = useQueryClient();
return useMutation<void, ApiError>({
mutationFn: async () => {
await api.post('/api/auth/end-impersonation');
},
onSuccess: () => {
qc.setQueryData(ME_KEY, null);
qc.clear();
},
});
}
+17 -6
View File
@@ -15,14 +15,25 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>;
// Guard against open-redirects: only accept same-origin internal paths like
// "/app" or "/app/cases". Reject protocol-relative ("//evil.com"), backslash
// tricks ("/\\evil.com"), and absolute URLs ("https://evil.com").
// Guard against open-redirects. `next` is attacker-supplied (it rides in the login URL), so it
// is resolved against a throwaway origin and accepted only if it stays on that origin. Parsing
// rather than prefix-matching means encoded, backslash, and protocol-relative forms all
// normalise before the check, and the result never depends on how the router treats the string.
function safeNext(raw: string | null): string {
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\') || raw.includes('://')) {
return '/app';
const FALLBACK = '/app';
if (!raw) return FALLBACK;
// Browsers strip tabs/newlines from URLs before parsing; do the same so they can't be used
// to smuggle a scheme past the checks below.
const cleaned = raw.replace(/[\t\n\r]/g, '');
if (!cleaned.startsWith('/') || cleaned.startsWith('//')) return FALLBACK;
try {
const probe = 'https://redirect-guard.invalid';
const url = new URL(cleaned, probe);
if (url.origin !== probe) return FALLBACK;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return FALLBACK;
}
return raw;
}
const ERROR_COPY: Record<string, string> = {