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
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
// Mirrors safeNext() in apps/web/src/pages/LoginPage.tsx — the post-login redirect guard. `next`
// rides in the login URL and is therefore attacker-supplied, so an open redirect here lands a
// freshly authenticated user on a phishing page. Lives in the API suite because the web workspace
// has no test runner configured; keep the two copies in step.
function safeNext(raw: string | null): string {
const FALLBACK = '/app';
if (!raw) return FALLBACK;
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;
}
}
const PROBE = 'https://redirect-guard.invalid';
const staysInternal = (value: string) => new URL(value, PROBE).origin === PROBE;
describe('safeNext — post-login open-redirect guard', () => {
it('passes through legitimate internal destinations', () => {
expect(safeNext('/app/cases')).toBe('/app/cases');
expect(safeNext('/app?tab=open#row')).toBe('/app?tab=open#row');
expect(safeNext('/admin')).toBe('/admin');
});
it('falls back when nothing was requested', () => {
expect(safeNext(null)).toBe('/app');
expect(safeNext('')).toBe('/app');
});
it('rejects absolute URLs to another origin', () => {
expect(safeNext('https://evil.com')).toBe('/app');
expect(safeNext('http://evil.com/path')).toBe('/app');
});
it('rejects protocol-relative URLs', () => {
expect(safeNext('//evil.com')).toBe('/app');
expect(safeNext('////evil.com')).toBe('/app');
});
it('rejects backslash variants that browsers normalise into an authority', () => {
// URL parsing rewrites \ as / for special schemes, so each of these would otherwise become
// protocol-relative and point off-origin. The origin check is what catches them.
for (const attack of ['/\\\\evil.com', '/\\/evil.com', '\\\\evil.com', '/\\\\\\evil.com']) {
expect(safeNext(attack)).toBe('/app');
}
});
it('rejects schemes smuggled past the leading-slash check with whitespace', () => {
expect(safeNext('\tjavascript:alert(1)')).toBe('/app');
expect(safeNext('\njavascript:alert(1)')).toBe('/app');
expect(safeNext('javascript:alert(1)')).toBe('/app');
expect(safeNext('data:text/html,<script>alert(1)</script>')).toBe('/app');
});
it('never returns a value that resolves off-origin', () => {
const attacks = [
'//evil.com',
'/\\evil.com',
'/\\\\evil.com',
'https://evil.com',
'\tjavascript:alert(1)',
'////evil.com',
'/%5cevil.com',
'/%2f%2fevil.com',
'/\t/evil.com',
'data:text/html,<script>alert(1)</script>',
];
for (const attack of attacks) {
expect(staysInternal(safeNext(attack))).toBe(true);
}
});
});