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
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Mirrors the grant decision in src/routes/webhooks-stripe.ts. `checkout.session.completed` fires
|
||||
// as soon as Checkout finishes, which for delayed-notification payment methods (ACH debit, bank
|
||||
// transfer, some wallets) happens BEFORE any money moves — payment_status 'unpaid'. Granting on
|
||||
// the event alone hands out a paid plan for an unsettled payment.
|
||||
type PaymentStatus = 'paid' | 'unpaid' | 'no_payment_required';
|
||||
|
||||
function shouldGrantPlan(paymentStatus: PaymentStatus): boolean {
|
||||
return paymentStatus === 'paid' || paymentStatus === 'no_payment_required';
|
||||
}
|
||||
|
||||
// Mirrors the subscription-status branch in the same file.
|
||||
type SubStatus =
|
||||
| 'active'
|
||||
| 'trialing'
|
||||
| 'past_due'
|
||||
| 'unpaid'
|
||||
| 'canceled'
|
||||
| 'incomplete'
|
||||
| 'incomplete_expired';
|
||||
|
||||
function planForSubscription(status: SubStatus): 'pro' | 'starter' | null {
|
||||
if (status === 'active' || status === 'trialing') return 'pro';
|
||||
if (status === 'unpaid' || status === 'incomplete_expired') return 'starter';
|
||||
return null; // leave the current plan untouched
|
||||
}
|
||||
|
||||
describe('checkout grant decision', () => {
|
||||
it('grants on a settled payment', () => {
|
||||
expect(shouldGrantPlan('paid')).toBe(true);
|
||||
});
|
||||
|
||||
it('grants when no payment was required (e.g. a 100% coupon)', () => {
|
||||
expect(shouldGrantPlan('no_payment_required')).toBe(true);
|
||||
});
|
||||
|
||||
it('withholds the plan while the payment is unsettled', () => {
|
||||
// The regression this guards: a delayed-payment method completing Checkout unpaid used to
|
||||
// grant 'lifetime' outright.
|
||||
expect(shouldGrantPlan('unpaid')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscription status mapping', () => {
|
||||
it('treats only active and trialing as paying', () => {
|
||||
expect(planForSubscription('active')).toBe('pro');
|
||||
expect(planForSubscription('trialing')).toBe('pro');
|
||||
});
|
||||
|
||||
it('does not upgrade on past_due, and does not downgrade mid-retry either', () => {
|
||||
// Stripe is still retrying the charge — flipping the plan in either direction here would
|
||||
// either hand out Pro for a failed renewal or cut off a customer whose retry succeeds.
|
||||
expect(planForSubscription('past_due')).toBeNull();
|
||||
});
|
||||
|
||||
it('drops to starter on terminal non-payment states', () => {
|
||||
expect(planForSubscription('unpaid')).toBe('starter');
|
||||
expect(planForSubscription('incomplete_expired')).toBe('starter');
|
||||
});
|
||||
|
||||
it('leaves the plan alone for states that carry no payment signal', () => {
|
||||
expect(planForSubscription('incomplete')).toBeNull();
|
||||
expect(planForSubscription('canceled')).toBeNull(); // handled by subscription.deleted instead
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user