From 56c3bcf6666834125f9d4d08f6ef54005b362e57 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Jul 2026 13:55:10 -0400 Subject: [PATCH] fix homepage storefront proxy and CSP --- apps/homepage/next.config.ts | 10 +++ apps/homepage/src/lib/security/csp.ts | 3 +- apps/homepage/src/proxy.ts | 12 +++- apps/homepage/tests/browser/theme-csp.spec.ts | 3 +- apps/homepage/tests/unit/csp.test.ts | 4 +- apps/homepage/tests/unit/proxy.test.ts | 71 +++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 apps/homepage/tests/unit/proxy.test.ts diff --git a/apps/homepage/next.config.ts b/apps/homepage/next.config.ts index df98054..ce5e12b 100644 --- a/apps/homepage/next.config.ts +++ b/apps/homepage/next.config.ts @@ -54,8 +54,18 @@ const nextConfig: NextConfig = { process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001'; const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002'; + const storefrontOrigin = + process.env.STOREFRONT_INTERNAL_URL ?? 'http://storefront:3004'; return [ + { + source: '/storefront', + destination: `${storefrontOrigin}`, + }, + { + source: '/storefront/:path*', + destination: `${storefrontOrigin}/:path*`, + }, { source: '/dashboard', destination: `${dashboardOrigin}/dashboard`, diff --git a/apps/homepage/src/lib/security/csp.ts b/apps/homepage/src/lib/security/csp.ts index 6b70a00..9970e16 100644 --- a/apps/homepage/src/lib/security/csp.ts +++ b/apps/homepage/src/lib/security/csp.ts @@ -7,7 +7,8 @@ export function buildContentSecurityPolicy( development: boolean, apiOrigin?: string, ): string { - const scriptSources = ["'self'", `'nonce-${nonce}'`, "'strict-dynamic'", "'unsafe-eval'"]; + void nonce; + const scriptSources = ["'self'", "'unsafe-inline'", "'unsafe-eval'"]; const styleSources = ["'self'", "'unsafe-inline'"]; diff --git a/apps/homepage/src/proxy.ts b/apps/homepage/src/proxy.ts index d3ed9b8..a7586b6 100644 --- a/apps/homepage/src/proxy.ts +++ b/apps/homepage/src/proxy.ts @@ -13,6 +13,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; const dashboardLanguageCookie = 'rentaldrivego-language'; +const reservedProxySegments = new Set(['dashboard', 'admin', 'storefront']); function applySecurityHeaders(response: NextResponse, contentSecurityPolicy: string): NextResponse { response.headers.set('Content-Security-Policy', contentSecurityPolicy); @@ -65,10 +66,17 @@ export function proxy(request: NextRequest): NextResponse { return applySecurityHeaders(response, csp); } - // Redirect locale-less paths (e.g. /sign-in → /en/sign-in), but not /dashboard, /admin, or files + const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean); + if (isLocale(pathSegments[0]) && pathSegments[1] === 'storefront') { + const destination = request.nextUrl.clone(); + destination.pathname = `/${pathSegments.slice(1).join('/')}`; + return applySecurityHeaders(NextResponse.redirect(destination), csp); + } + + // Redirect locale-less paths (e.g. /sign-in → /en/sign-in), but not proxied app paths or files. const firstSegment = request.nextUrl.pathname.split('/')[1]; const isFile = /\.\w+$/.test(request.nextUrl.pathname); - if (firstSegment && !isFile && !isLocale(firstSegment) && firstSegment !== 'dashboard' && firstSegment !== 'admin') { + if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) { const cookieLocale = request.cookies.get(localeCookie)?.value; const locale = isLocale(cookieLocale) ? cookieLocale diff --git a/apps/homepage/tests/browser/theme-csp.spec.ts b/apps/homepage/tests/browser/theme-csp.spec.ts index fd020f2..3beb037 100644 --- a/apps/homepage/tests/browser/theme-csp.spec.ts +++ b/apps/homepage/tests/browser/theme-csp.spec.ts @@ -20,7 +20,8 @@ test('explicit dark theme is present on the server-rendered root', async ({ brow const csp = response?.headers()['content-security-policy'] ?? ''; const nonce = await page.locator('#theme-bootstrap').getAttribute('nonce'); expect(nonce).toBeTruthy(); - expect(csp).toContain(`'nonce-${nonce}'`); + expect(csp).toContain("'unsafe-inline'"); + expect(csp).not.toContain("'strict-dynamic'"); await context.close(); }); diff --git a/apps/homepage/tests/unit/csp.test.ts b/apps/homepage/tests/unit/csp.test.ts index b55c023..2267cd3 100644 --- a/apps/homepage/tests/unit/csp.test.ts +++ b/apps/homepage/tests/unit/csp.test.ts @@ -12,7 +12,9 @@ describe('CSP foundation', () => { it('allows required runtime evaluation without enabling development-only sources', () => { expect(buildContentSecurityPolicy('abc', true)).toContain("'unsafe-eval'"); expect(buildContentSecurityPolicy('abc', false)).toContain("'unsafe-eval'"); - expect(buildContentSecurityPolicy('abc', false)).toContain("'nonce-abc'"); + expect(buildContentSecurityPolicy('abc', false)).toContain("'unsafe-inline'"); + expect(buildContentSecurityPolicy('abc', false)).not.toContain("'strict-dynamic'"); + expect(buildContentSecurityPolicy('abc', false)).not.toContain("'nonce-abc'"); expect(buildContentSecurityPolicy('abc', false)).toContain( "style-src 'self' 'unsafe-inline'", ); diff --git a/apps/homepage/tests/unit/proxy.test.ts b/apps/homepage/tests/unit/proxy.test.ts new file mode 100644 index 0000000..04d2ebb --- /dev/null +++ b/apps/homepage/tests/unit/proxy.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const nextServer = vi.hoisted(() => { + const next = vi.fn((init?: { request?: { headers?: Headers } }) => ({ + kind: 'next', + init, + cookies: { + set: vi.fn(), + }, + headers: new Headers(), + })); + const redirect = vi.fn((url: URL) => ({ + kind: 'redirect', + url: url.toString(), + cookies: { + set: vi.fn(), + }, + headers: new Headers(), + })); + return { next, redirect }; +}); + +vi.mock('next/server', () => ({ + NextResponse: { + next: nextServer.next, + redirect: nextServer.redirect, + }, +})); + +function cloneableUrl(input: string): URL { + const url = new URL(input); + (url as URL & { clone: () => URL }).clone = () => cloneableUrl(url.toString()); + return url; +} + +function request(input: string) { + return { + nextUrl: cloneableUrl(input), + cookies: { + get: vi.fn(), + }, + headers: new Headers(), + }; +} + +beforeEach(() => { + nextServer.next.mockClear(); + nextServer.redirect.mockClear(); +}); + +describe('homepage proxy app boundaries', () => { + it('does not localize storefront proxy paths', async () => { + const { proxy } = await import('@/proxy'); + + const response = proxy(request('https://rentaldrivego.ma/storefront/en') as never) as any; + + expect(response.kind).toBe('next'); + expect(nextServer.redirect).not.toHaveBeenCalled(); + }); + + it('redirects locale-prefixed storefront paths back to the storefront proxy mount', async () => { + const { proxy } = await import('@/proxy'); + + const response = proxy(request('https://rentaldrivego.ma/en/storefront/en') as never) as any; + + expect(response).toMatchObject({ + kind: 'redirect', + url: 'https://rentaldrivego.ma/storefront/en', + }); + }); +});