From 5342fa85123d5314a196e67230d89dc8cfbeddf5 Mon Sep 17 00:00:00 2001 From: mindsers Date: Fri, 10 Apr 2026 08:56:26 +0200 Subject: [PATCH 1/2] fix: validate subdomain on unauthenticated routes (login, password reset) resolveCongregationFromRequest() was only called inside authenticated code paths. Unauthenticated pages (login, password forgot, password reset) on unknown subdomains rendered normally instead of redirecting to /congregation-not-found. --- app/features/authentication/routes/login.tsx | 3 +++ app/features/authentication/routes/password-forgot.tsx | 4 +++- app/features/authentication/routes/password-reset.tsx | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/features/authentication/routes/login.tsx b/app/features/authentication/routes/login.tsx index ac9f149..eca4cd8 100644 --- a/app/features/authentication/routes/login.tsx +++ b/app/features/authentication/routes/login.tsx @@ -23,6 +23,9 @@ export const meta: Route.MetaFunction = () => { } export async function loader({ request }: Route.LoaderArgs) { + // Vérifier que le sous-domaine correspond à une assemblée existante + await resolveCongregationFromRequest(request) + const shouldStartSetup = await needSetupProcess() if (shouldStartSetup) { return redirect(process.env.MULTI_TENANT === 'true' ? '/register' : '/setup') diff --git a/app/features/authentication/routes/password-forgot.tsx b/app/features/authentication/routes/password-forgot.tsx index 83f639b..10ac675 100644 --- a/app/features/authentication/routes/password-forgot.tsx +++ b/app/features/authentication/routes/password-forgot.tsx @@ -3,7 +3,7 @@ import { data, Form, Link, redirect } from 'react-router' import { createPasswordResetToken } from '~/features/authentication/server/invalidate-user-password.server' import { sendResetUserPasswordEmail } from '~/features/authentication/server/send-reset-user-password-email.server' import { commitSession, getSession } from '~/features/authentication/server/session.server' -import { getBrandingName, resolveCongregation } from '~/shared/libs/congregation.server' +import { getBrandingName, resolveCongregation, resolveCongregationFromRequest } from '~/shared/libs/congregation.server' import { unscopedDb as db } from '~/shared/libs/db.server' import { Alert, AlertDescription } from '~/shared/ui/alert' import { Button } from '~/shared/ui/button' @@ -18,6 +18,8 @@ export const meta: Route.MetaFunction = () => { } export async function loader({ request }: Route.LoaderArgs) { + await resolveCongregationFromRequest(request) + const session = await getSession(request.headers.get('Cookie')) const brandingName = await getBrandingName(request) diff --git a/app/features/authentication/routes/password-reset.tsx b/app/features/authentication/routes/password-reset.tsx index 3d92047..cea7cd9 100644 --- a/app/features/authentication/routes/password-reset.tsx +++ b/app/features/authentication/routes/password-reset.tsx @@ -1,6 +1,6 @@ import { Form, redirect } from 'react-router' -import { getBrandingName } from '~/shared/libs/congregation.server' +import { getBrandingName, resolveCongregationFromRequest } from '~/shared/libs/congregation.server' import { consumePasswordResetToken, verifyPasswordResetToken, @@ -19,6 +19,8 @@ export const meta: Route.MetaFunction = () => { } export async function loader({ request, params }: Route.LoaderArgs) { + await resolveCongregationFromRequest(request) + const user = await verifyPasswordResetToken(params.userHash ?? '') if (user == null) { From d1f35ff2f634bfea53b206d9e47713aa1b3393bc Mon Sep 17 00:00:00 2001 From: mindsers Date: Fri, 10 Apr 2026 09:20:39 +0200 Subject: [PATCH 2/2] fix: prevent silent data leak when AsyncLocalStorage context is lost The Prisma 7 pg adapter breaks AsyncLocalStorage context propagation after awaited queries. The scoped db extension silently fell through to unscoped queries when context was missing, returning data from all congregations instead of just the current tenant. - Make db extension throw on missing congregation context (fail-safe) - Re-enter ALS context after each scoped query in the db extension - Re-enter ALS context in verifyRole() after its unscopedDb queries --- .../server/verify-role.server.test.ts | 1 + .../authorization/server/verify-role.server.ts | 4 ++++ app/shared/libs/db.server.ts | 15 ++++++++++++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/features/authorization/server/verify-role.server.test.ts b/app/features/authorization/server/verify-role.server.test.ts index 965bfe6..d5524e9 100644 --- a/app/features/authorization/server/verify-role.server.test.ts +++ b/app/features/authorization/server/verify-role.server.test.ts @@ -11,6 +11,7 @@ vi.mock('~/shared/libs/db.server', () => ({ }, congregationContext: { getStore: vi.fn(), + enterWith: vi.fn(), }, })) diff --git a/app/features/authorization/server/verify-role.server.ts b/app/features/authorization/server/verify-role.server.ts index 6567702..dfecd95 100644 --- a/app/features/authorization/server/verify-role.server.ts +++ b/app/features/authorization/server/verify-role.server.ts @@ -31,6 +31,8 @@ export async function verifyRole(request: Request, roleKey: Role) { }) if (adminRole != null) { + // Restaurer le contexte ALS — les requêtes unscopedDb via l'adaptateur pg le cassent + congregationContext.enterWith({ congregationId }) return true } @@ -42,5 +44,7 @@ export async function verifyRole(request: Request, roleKey: Role) { }, }) + // Restaurer le contexte ALS — les requêtes unscopedDb via l'adaptateur pg le cassent + congregationContext.enterWith({ congregationId }) return role != null } diff --git a/app/shared/libs/db.server.ts b/app/shared/libs/db.server.ts index 5dd4279..ea10bb7 100644 --- a/app/shared/libs/db.server.ts +++ b/app/shared/libs/db.server.ts @@ -46,12 +46,15 @@ const UPDATE_OPERATIONS = new Set(['update', 'updateMany', 'upsert', 'delete', ' // Tenant-scoped client — auto-injects congregationId on all scoped models const db = unscopedDb.$extends({ query: { - $allOperations({ model, operation, args, query }) { + async $allOperations({ model, operation, args, query }) { if (!model || !SCOPED_MODELS.has(model)) return query(args) const ctx = congregationContext.getStore() if (!ctx) { - return query(args) + throw new Error( + `Congregation context is required for ${model}.${operation} but was not set. ` + + 'Use unscopedDb for global operations or ensure verifySession() was called.', + ) } const { congregationId } = ctx @@ -75,7 +78,13 @@ const db = unscopedDb.$extends({ } } - return query(args) + const result = await query(args) + + // Restaurer le contexte ALS après la requête — l'adaptateur pg de Prisma 7 + // peut casser la propagation d'AsyncLocalStorage lors des opérations async. + congregationContext.enterWith(ctx) + + return result }, }, })