From b556c636eb89efa5e036f7830e0d7e76e2e31eba Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 20 Apr 2026 21:55:15 +0200 Subject: [PATCH] fix: tighten 401 redirect allowlist and add reset-password paths Replaced loose includes()/startsWith() path checks with exact equality for static routes and strict prefix matching for dynamic-token routes. Added /forgot-password and /reset-password to the allowlist so the password-reset flow is usable without auth. Extracted isAuthPublicPath as a pure testable function with 14 unit tests covering regressions. --- client/src/api/client.ts | 11 ++- .../tests/unit/api/client.interceptor.test.ts | 71 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 client/tests/unit/api/client.interceptor.test.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 5b41e50a..1322b8bb 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -62,13 +62,20 @@ apiClient.interceptors.request.use( (error) => Promise.reject(error) ) +export function isAuthPublicPath(pathname: string): boolean { + const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'] + const publicPrefixes = ['/shared/', '/public/'] + return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p)) +} + // Response interceptor - handle 401, 403 MFA, 429 rate limit apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { - if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) { - const currentPath = window.location.pathname + window.location.search + const { pathname } = window.location + if (!isAuthPublicPath(pathname)) { + const currentPath = pathname + window.location.search window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) } } diff --git a/client/tests/unit/api/client.interceptor.test.ts b/client/tests/unit/api/client.interceptor.test.ts new file mode 100644 index 00000000..a28e3d21 --- /dev/null +++ b/client/tests/unit/api/client.interceptor.test.ts @@ -0,0 +1,71 @@ +// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012 +import { describe, it, expect } from 'vitest' +import { isAuthPublicPath } from '../../../src/api/client' + +describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => { + describe('exact-match public paths — no redirect', () => { + it('FE-CLIENT-INTERCEPTOR-001: /login', () => { + expect(isAuthPublicPath('/login')).toBe(true) + }) + + it('FE-CLIENT-INTERCEPTOR-002: /register', () => { + expect(isAuthPublicPath('/register')).toBe(true) + }) + + it('FE-CLIENT-INTERCEPTOR-003: /forgot-password', () => { + expect(isAuthPublicPath('/forgot-password')).toBe(true) + }) + + it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => { + expect(isAuthPublicPath('/reset-password')).toBe(true) + }) + }) + + describe('prefix-match public paths — no redirect', () => { + it('FE-CLIENT-INTERCEPTOR-005: /shared/:token', () => { + expect(isAuthPublicPath('/shared/abc123token')).toBe(true) + }) + + it('FE-CLIENT-INTERCEPTOR-006: /public/journey/:token', () => { + expect(isAuthPublicPath('/public/journey/xyz789')).toBe(true) + }) + }) + + describe('paths that matched via includes() before fix — must redirect', () => { + it('FE-CLIENT-INTERCEPTOR-007: /admin/login', () => { + expect(isAuthPublicPath('/admin/login')).toBe(false) + }) + + it('FE-CLIENT-INTERCEPTOR-008: /admin/register', () => { + expect(isAuthPublicPath('/admin/register')).toBe(false) + }) + + it('FE-CLIENT-INTERCEPTOR-009: /some-login-page', () => { + expect(isAuthPublicPath('/some-login-page')).toBe(false) + }) + }) + + describe('paths that matched via loose startsWith before fix — must redirect', () => { + it('FE-CLIENT-INTERCEPTOR-010: /reset-password-extra', () => { + expect(isAuthPublicPath('/reset-password-extra')).toBe(false) + }) + + it('FE-CLIENT-INTERCEPTOR-011: /forgot-password-extra', () => { + expect(isAuthPublicPath('/forgot-password-extra')).toBe(false) + }) + }) + + describe('private app paths — must redirect', () => { + it('FE-CLIENT-INTERCEPTOR-012: /dashboard', () => { + expect(isAuthPublicPath('/dashboard')).toBe(false) + }) + + it('FE-CLIENT-INTERCEPTOR-013: /trips/123', () => { + expect(isAuthPublicPath('/trips/123')).toBe(false) + }) + + it('FE-CLIENT-INTERCEPTOR-014: / (root)', () => { + expect(isAuthPublicPath('/')).toBe(false) + }) + }) +})