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:
co-authored by
Claude Opus 5
parent
4a1122a7c9
commit
eb36b81dc9
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user