4099 lines
169 KiB
Markdown
4099 lines
169 KiB
Markdown
# Security Review: xpeditis2.0 copy
|
||
|
||
## Scope
|
||
|
||
Audit statique transversal sur check_secu, révision 8446f879b676b303fdb2891388f88ff7e43f5fea.
|
||
|
||
- Scan mode: repository
|
||
- Target kind: git_revision
|
||
- Target ID: target_sha256_ddfe0183466d4153b86e8f190318b958432df21c7d3bcaec8c57c2564c3f1208
|
||
- Revision: 8446f879b676b303fdb2891388f88ff7e43f5fea
|
||
- Inventory strategy: repository
|
||
- Included paths: .
|
||
- Excluded paths: none
|
||
- Runtime or test status: Aucun test de pénétration ni exécution du produit ; vérification des bibliothèques installées par lecture.
|
||
- Artifacts reviewed: apps/backend/src/app.module.ts, apps/backend/src/application/api-keys/api-keys.service.ts, apps/backend/src/application/auth/auth.service.ts, apps/backend/src/application/auth/jwt.strategy.ts, apps/backend/src/application/controllers/audit.controller.ts, apps/backend/src/application/controllers/auth.controller.ts, apps/backend/src/application/controllers/bookings.controller.ts, apps/backend/src/application/controllers/csv-booking-actions.controller.ts, apps/backend/src/application/controllers/gdpr.controller.ts, apps/backend/src/application/controllers/invitations.controller.ts, apps/backend/src/application/controllers/notifications.controller.ts, apps/backend/src/application/controllers/organizations.controller.ts, apps/backend/src/application/controllers/subscriptions.controller.ts, apps/backend/src/application/controllers/users.controller.ts, apps/backend/src/application/controllers/webhooks.controller.ts, apps/backend/src/application/csv-bookings/csv-bookings.module.ts, apps/backend/src/application/dashboard/dashboard.controller.ts, apps/backend/src/application/dto/organization.dto.ts, apps/backend/src/application/dto/subscription.dto.ts, apps/backend/src/application/dto/user.dto.ts, apps/backend/src/application/gateways/notifications.gateway.ts, apps/backend/src/application/guards/api-key-or-jwt.guard.ts, apps/backend/src/application/guards/feature-flag.guard.ts, apps/backend/src/application/guards/jwt-auth.guard.ts, apps/backend/src/application/guards/roles.guard.ts, apps/backend/src/application/guards/throttle.guard.ts, apps/backend/src/application/logs/logs.controller.ts, apps/backend/src/application/mcp/capabilities/account.capabilities.ts, apps/backend/src/application/mcp/capabilities/admin.capabilities.ts, apps/backend/src/application/mcp/capabilities/bookings.capabilities.ts, apps/backend/src/application/mcp/capabilities/knowledge.capabilities.ts, apps/backend/src/application/mcp/capabilities/rates.capabilities.ts, apps/backend/src/application/mcp/capability.registry.ts, apps/backend/src/application/mcp/capability.ts, apps/backend/src/application/mcp/mcp.controller.ts, apps/backend/src/application/notifications/notifications.module.ts, apps/backend/src/application/services/analytics.service.ts, apps/backend/src/application/services/fuzzy-search.service.ts, apps/backend/src/application/services/gdpr.service.ts, apps/backend/src/application/services/invitation.service.ts, apps/backend/src/application/services/notification.service.ts, apps/backend/src/application/services/subscription.service.ts, apps/backend/src/application/services/webhook.service.ts, apps/backend/src/application/trade-assistant/trade-assistant.controller.ts, apps/backend/src/application/trade-assistant/trade-assistant.service.ts, apps/backend/src/domain/entities/subscription.entity.ts, apps/backend/src/domain/entities/user.entity.ts, apps/backend/src/domain/services/booking.service.ts, apps/backend/src/domain/services/capability-access.ts, apps/backend/src/domain/value-objects/subscription-plan.vo.ts, apps/backend/src/domain/value-objects/subscription-status.vo.ts, apps/backend/src/infrastructure/ai/openai-trade.adapter.ts, apps/backend/src/infrastructure/pdf/pdf.adapter.ts, apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts, apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts, apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-conversation.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-quota.repository.ts, apps/backend/src/infrastructure/security/security.config.ts, apps/backend/src/infrastructure/storage/s3-storage.adapter.ts, apps/backend/src/infrastructure/stripe/stripe.adapter.ts, apps/backend/src/main.ts, apps/frontend/Dockerfile, apps/frontend/app/\[locale\]/layout.tsx, apps/frontend/app/\[locale\]/login/page.tsx, apps/frontend/app/api/health/route.ts, apps/frontend/i18n/navigation.ts, apps/frontend/i18n/request.ts, apps/frontend/i18n/routing.ts, apps/frontend/lib/api/client.ts, apps/frontend/middleware.ts, apps/frontend/next.config.js, apps/frontend/package.json, apps/frontend/src/components/ExportButton.tsx, apps/frontend/src/components/assistant/answer-text.tsx, apps/frontend/src/components/assistant/message-list.tsx, apps/frontend/src/components/notifications/notification-row.tsx, apps/frontend/src/components/providers.tsx, apps/frontend/src/hooks/use-url-state.ts, apps/frontend/src/lib/api/client.ts, apps/frontend/src/lib/context/auth-context.tsx, apps/frontend/src/utils/export.ts, apps/frontend/tsconfig.json, apps/log-exporter/Dockerfile, apps/log-exporter/package.json, apps/log-exporter/src/index.js, docker/docker-compose.full.yml, infra/logging/loki/loki-config.yml, infra/prod/k8s/base/06-log-exporter.yaml, infra/prod/k8s/base/08-traefik-middlewares.yaml, infra/prod/k8s/base/09-ingress.yaml, infra/prod/k8s/base/10-network-policies.yaml
|
||
- Scan context: Modèle de menace généré depuis le code et revu indépendamment ; aucun modèle utilisateur.
|
||
|
||
Limitations and exclusions:
|
||
- Couverture source partielle ; aucune attestation d’absence de vulnérabilités.
|
||
- Fichiers .env/.env.\* interdits, non lus.
|
||
- Pas d’audit CVE en ligne, de déploiement réel, des secrets actifs, permissions cloud ou historique Git.
|
||
- Excluded \*\*/.env\*: Restriction explicite de lecture.
|
||
- Excluded \*\*/.env\*: User prohibits .env and .env.\* reads.
|
||
|
||
### Scan Summary
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Scan outcome | completed |
|
||
| Reportable findings | 14 |
|
||
| Severity mix | high: 2, medium: 9, low: 3 |
|
||
| Confidence mix | high: 12, medium: 2 |
|
||
| Coverage | partial |
|
||
| Validation mode | Static source trace |
|
||
|
||
Canonical artifacts: `scan-manifest.json`, `findings.json`, and `coverage.json`. This report is a deterministic projection of those files.
|
||
|
||
## Threat Model
|
||
|
||
Xpeditis freight platform uses Nest API with relational storage, CSV shipping rates, booking documents, subscription payments, MCP and AI assistant. Global ApiKeyOrJwtGuard and throttling protect normal API routes; public carrier links and Stripe webhook have separate authority checks (apps/backend/src/app.module.ts:210; apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/controllers/subscriptions.controller.ts:262). Production manifests describe Kubernetes plus private PostgreSQL and external object storage; actual deployment state is not supplied.
|
||
|
||
### Assets
|
||
|
||
- User sessions, API-key authority, organization booking data and subscription entitlements; API-key/JWT authentication paths are distinct (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34).
|
||
- Booking documents, freight rate integrity, published blog assets, AI conversation history and tool access.
|
||
|
||
### Trust Boundaries
|
||
|
||
- Browser to API: JWT extraction accepts httpOnly accessToken cookie; auth endpoints set cookies and security config defaults SameSite=lax with production Secure (apps/backend/src/application/auth/jwt.strategy.ts:40; apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/infrastructure/security/security.config.ts:195). Helmet/CORS/validation are applied at startup (apps/backend/src/main.ts:33; apps/backend/src/main.ts:42; apps/backend/src/main.ts:54).
|
||
- External API key caller to application: key validation supplies user context; absent key falls back to JWT (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34).
|
||
- MCP tools/list visibility is separate from invocation enforcement: registry checks role and plan again and parses schema before handler execution, recording audit (apps/backend/src/application/mcp/capability.registry.ts:85; apps/backend/src/application/mcp/capability.registry.ts:100).
|
||
- AI invocation is bound to authenticated actor and uses the same capability registry; capability scope is not inherently read-only. Quota reserved before model request (apps/backend/src/application/trade-assistant/trade-assistant.service.ts:146; apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216).
|
||
- Carrier email token permits public accept/reject actions; document delivery independently requires accepted booking and password when hash exists (apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/services/csv-booking.service.ts:729; apps/backend/src/application/services/csv-booking.service.ts:848).
|
||
- Stripe webhook is public and passes raw request body/signature to service, with adapter constructEvent verification using configured webhook secret (apps/backend/src/application/controllers/subscriptions.controller.ts:262; apps/backend/src/infrastructure/stripe/stripe.adapter.ts:251).
|
||
|
||
### Attacker Capabilities
|
||
|
||
- Unauthenticated caller can request public endpoints and supply arbitrary ordinary request input, but is not assumed to possess carrier token, password, Stripe signing secret, administrative API key or deployment control.
|
||
- Authenticated organization user controls their requests and AI questions; crossing into another tenant, administrative capability or higher-plan entitlement would be a new authority gain. MCP visibility alone is not permission evidence; registry invocation enforces policy (apps/backend/src/application/mcp/capability.registry.ts:85).
|
||
- Privileged CSV configuration/import and release operators are separate conditional workflows; ordinary remote callers are not assumed to control local files, deployment variables, or migration scripts.
|
||
|
||
### Security Objectives
|
||
|
||
- Preserve organization and document ownership across API, MCP and AI handlers; evaluate handler-level scoping separately from global authentication.
|
||
- Keep public token capabilities scoped to intended booking and action; enforce additional document password/state controls at every document consumer (apps/backend/src/application/services/csv-booking.service.ts:848).
|
||
- Bind financial state changes to verified Stripe events; protect credentials and sensitive object contents with actual consumed storage/database configuration.
|
||
- Retain effective resource distinctions: CSV configured bucket, document/PDF/blog hardcoded buckets, and distinct database startup versus migration TLS behavior.
|
||
|
||
### Assumptions
|
||
|
||
- User origin: requested complete security audit on new check_secu branch from current branch; this independent review performs architecture mapping only. No supplied threat model or knowledge base.
|
||
- No first-party SECURITY.md found by resolver inventory; only vendored node_modules policies exist. No .env files read.
|
||
- ConfigMap DATABASE_SSL=true and hostssl comments do not mean runtime clients consume TLS: app.module options and startup script omit ssl; CLI data-source consumes true but disables certificate validation (apps/backend/src/app.module.ts:165; apps/backend/scripts/setup/startup.js:13; apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26; infra/prod/k8s/base/02-configmap-backend.yaml:47).
|
||
- ConfigMap AWS_S3_BUCKET=xpeditis-prod-documents affects CSV object loading; separate booking documents/PDF/blog consumers hardcode other buckets. Object-store policies and provisioned bucket existence remain external prerequisites (infra/prod/k8s/base/02-configmap-backend.yaml:71; apps/backend/src/application/services/csv-booking.service.ts:1269; apps/backend/src/application/services/booking-automation.service.ts:100; apps/backend/src/application/controllers/blog.controller.ts:23).
|
||
- Current code uses httpOnly auth cookies; repository overview claiming localStorage token architecture is not sufficient evidence of current implementation (apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/application/auth/jwt.strategy.ts:40).
|
||
- Coverage is architectural, not a completed vulnerability audit. External IAM, deployed networking, CI secrets, tenant enforcement of every handler, refresh lifecycle and carrier-token entropy/expiry are not fully established by this pass.
|
||
- Nest TypeORM / production ConfigMap: DATABASE_HOST/PORT/NAME from ConfigService; DATABASE_SSL declared but absent from TypeORM options =\> 10.10.1.20:5432/xpeditis_prod; no explicit TLS option. Contrôle: synchronize:false; server pg_hba controls admission. Runtime factory does not consume DATABASE_SSL; deployment success and ambient driver options remain unknown Sources: apps/backend/src/app.module.ts:165, infra/prod/k8s/base/02-configmap-backend.yaml:44
|
||
- TypeORM migration CLI / production migration Job: Job calls compiled data-source; DATABASE_SSL=true from ConfigMap =\> 10.10.1.20:5432/xpeditis_prod with ssl.rejectUnauthorized=false. Contrôle: TLS encryption without certificate validation in data-source. Sources: infra/prod/k8s/base/07-migration-job.yaml:52, apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26
|
||
- Startup pg client and migration DataSource / image startup script: DATABASE_\* directly consumed; no ssl option =\> configured PostgreSQL target, including prod target when prod ConfigMap injected. Contrôle: database credential and server admission. Different TLS behavior from migration Job; Job explicitly documents this at 07-migration-job.yaml:52 Sources: apps/backend/scripts/setup/startup.js:13, apps/backend/scripts/setup/startup.js:40
|
||
- CSV object loader / production/object-storage configured: company config metadata.minioObjectKey; AWS_S3_BUCKET from ConfigMap; storage adapter AWS_S3_ENDPOINT =\> https://fsn1.your-objectstorage.com/xpeditis-prod-documents/{metadata.minioObjectKey}. Contrôle: S3 credential permissions; fallback to local file on error. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:149, apps/backend/src/infrastructure/storage/s3-storage.adapter.ts:244, infra/prod/k8s/base/02-configmap-backend.yaml:70
|
||
- CSV local loader / local and object-store fallback: absolute filePath unchanged; otherwise process.cwd()/src/infrastructure/storage/csv-storage/rates joined with filePath =\> {cwd}/src/infrastructure/storage/csv-storage/rates/{relative filePath}, or absolute filePath. Contrôle: host filesystem permissions and administrative configuration authority. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:125, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:164, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:287
|
||
- Booking document upload/download / all S3 deployments including prod: hardcoded bucket; document key constructed in service; endpoint from adapter =\> xpeditis-documents/csv-bookings/{bookingId}/{documentId}-{originalFilename}; prod endpoint https://fsn1.your-objectstorage.com. Contrôle: carrier token, ACCEPTED status, password hash when present, document belongs to token booking. AWS_S3_BUCKET=xpeditis-prod-documents does not select this bucket Sources: apps/backend/src/application/services/csv-booking.service.ts:1269, apps/backend/src/application/services/csv-booking.service.ts:848, apps/backend/src/application/services/csv-booking.service.ts:866
|
||
- Booking PDF automation / all S3 deployments: hardcoded bucket and booking-derived key =\> xpeditis-bookings/bookings/{booking.id}/{booking.bookingNumber.value}.pdf. Contrôle: backend automation and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/services/booking-automation.service.ts:98
|
||
- Blog image API / all S3 deployments: hardcoded bucket; public route constructs blog-images filename key =\> xpeditis-blog/blog-images/{filename}. Contrôle: public publication workflow and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/controllers/blog.controller.ts:23, apps/backend/src/application/controllers/blog.controller.ts:27, apps/backend/src/application/controllers/blog.controller.ts:76
|
||
- Trade assistant AI / configured OPENAI_API_KEY: fixed Responses endpoint; OPENAI_MODEL defaults gpt-4.1-mini =\> https://api.openai.com/v1/responses; question/history/passages and invoked tool outcomes. Contrôle: actor-bound registry invocation; 4 tool rounds, 800 output tokens, store:false, 30 second timeout. Sources: apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:51, apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:115, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:162, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216
|
||
|
||
## Findings
|
||
|
||
| Finding | Severity | Confidence | Detailed write-up |
|
||
| --- | --- | --- | --- |
|
||
| [La redirection de connexion permet une XSS DOM](#finding-1) | high | high | inline below |
|
||
| [Un manager peut modifier une autre organisation](#finding-2) | high | high | inline below |
|
||
| [Les téléversements ne bornent pas la mémoire utilisée](#finding-3) | medium | high | inline below |
|
||
| [Les WebSockets acceptent des sessions révoquées ou désactivées](#finding-4) | medium | high | inline below |
|
||
| [Le client reçoit le jeton de réponse du transporteur](#finding-5) | medium | high | inline below |
|
||
| [Les logs contiennent mots de passe et invitations](#finding-6) | medium | high | inline below |
|
||
| [La résiliation peut conserver les avantages payants](#finding-7) | medium | high | inline below |
|
||
| [VIEWER peut créer et modifier des réservations](#finding-8) | medium | high | inline below |
|
||
| [Un membre peut marquer toutes les notifications comme lues](#finding-9) | medium | high | inline below |
|
||
| [Le changement de mot de passe conserve les anciennes sessions](#finding-10) | medium | high | inline below |
|
||
| [Une clé SMTP figure dans un fichier suivi](#finding-11) | medium | medium | inline below |
|
||
| [Les exports CSV conservent les formules injectées](#finding-12) | low | medium | inline below |
|
||
| [Les dossiers des collègues sont accessibles sans rôle de gestion](#finding-13) | low | high | inline below |
|
||
| [Un manager peut rétrograder un administrateur de son organisation](#finding-14) | low | high | inline below |
|
||
|
||
### Confidence Scale
|
||
|
||
| Label | Meaning |
|
||
| --- | --- |
|
||
| high | Direct evidence supports the finding with no material unresolved blocker. |
|
||
| medium | Evidence supports a plausible issue, but material runtime or reachability proof remains. |
|
||
| low | Evidence is incomplete and the item is retained only for explicit follow-up. |
|
||
|
||
<a id="finding-1"></a>
|
||
|
||
### [1] La redirection de connexion permet une XSS DOM
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | high |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-79 |
|
||
| Affected lines | apps/frontend/app/\[locale\]/login/page.tsx:95-99, apps/frontend/app/\[locale\]/login/page.tsx:162-167, apps/frontend/src/lib/context/auth-context.tsx:105-110, apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175, apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105, apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403 |
|
||
|
||
#### Summary
|
||
|
||
login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication.
|
||
|
||
#### Root Cause
|
||
|
||
No protocol/origin allowlist; Next installed app-router.js:169 builds URL, :95 compares origin, :401 uses location.assign for external navigation. login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication.
|
||
|
||
**Source 1** — `apps/frontend/app/\[locale\]/login/page.tsx:95-99`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const { login } = useAuth();
|
||
const searchParams = useSearchParams();
|
||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||
const tLogin = useTranslations('auth.login');
|
||
const tPanel = useTranslations('auth.sidePanel');
|
||
```
|
||
|
||
**Source 2** — `apps/frontend/app/\[locale\]/login/page.tsx:162-167`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
await login(email, password, redirectTo, rememberMe);
|
||
} catch (err: any) {
|
||
const { message, field } = mapLoginError(err, tLogin);
|
||
```
|
||
|
||
**Source 3** — `apps/frontend/src/lib/context/auth-context.tsx:105-110`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
try {
|
||
await apiLogin({ email, password, rememberMe });
|
||
// Fetch complete user profile after login (session lives in httpOnly cookies)
|
||
const currentUser = await getCurrentUser();
|
||
setUser(currentUser);
|
||
router.push(redirectTo);
|
||
```
|
||
|
||
**Source 4** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function useNavigate(dispatch) {
|
||
return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{
|
||
const url = new URL((0, _addbasepath.addBasePath)(href), location.href);
|
||
return dispatch({
|
||
type: _routerreducertypes.ACTION_NAVIGATE,
|
||
url,
|
||
isExternalUrl: isExternalURL(url),
|
||
locationSearch: location.search,
|
||
shouldScroll: shouldScroll != null ? shouldScroll : true,
|
||
```
|
||
|
||
**Source 5** — `apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const pendingPush = navigateType === "push";
|
||
// we want to prune the prefetch cache on every navigation to avoid it growing too large
|
||
(0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache);
|
||
mutable.preserveCustomHistoryState = false;
|
||
if (isExternalUrl) {
|
||
return handleExternalUrl(state, mutable, url.toString(), pendingPush);
|
||
}
|
||
const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({
|
||
```
|
||
|
||
**Source 6** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (pushRef.mpaNavigation) {
|
||
// if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL
|
||
if (globalMutable.pendingMpaPath !== canonicalUrl) {
|
||
const location1 = window.location;
|
||
if (pushRef.pendingPush) {
|
||
location1.assign(canonicalUrl);
|
||
} else {
|
||
location1.replace(canonicalUrl);
|
||
```
|
||
|
||
#### Validation
|
||
|
||
login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. Contre-preuves : HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/frontend/app/\[locale\]/login/page.tsx:95-99`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const { login } = useAuth();
|
||
const searchParams = useSearchParams();
|
||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||
const tLogin = useTranslations('auth.login');
|
||
const tPanel = useTranslations('auth.sidePanel');
|
||
```
|
||
|
||
**Source 2** — `apps/frontend/app/\[locale\]/login/page.tsx:162-167`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
await login(email, password, redirectTo, rememberMe);
|
||
} catch (err: any) {
|
||
const { message, field } = mapLoginError(err, tLogin);
|
||
```
|
||
|
||
**Source 3** — `apps/frontend/src/lib/context/auth-context.tsx:105-110`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
try {
|
||
await apiLogin({ email, password, rememberMe });
|
||
// Fetch complete user profile after login (session lives in httpOnly cookies)
|
||
const currentUser = await getCurrentUser();
|
||
setUser(currentUser);
|
||
router.push(redirectTo);
|
||
```
|
||
|
||
**Source 4** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function useNavigate(dispatch) {
|
||
return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{
|
||
const url = new URL((0, _addbasepath.addBasePath)(href), location.href);
|
||
return dispatch({
|
||
type: _routerreducertypes.ACTION_NAVIGATE,
|
||
url,
|
||
isExternalUrl: isExternalURL(url),
|
||
locationSearch: location.search,
|
||
shouldScroll: shouldScroll != null ? shouldScroll : true,
|
||
```
|
||
|
||
**Source 5** — `apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const pendingPush = navigateType === "push";
|
||
// we want to prune the prefetch cache on every navigation to avoid it growing too large
|
||
(0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache);
|
||
mutable.preserveCustomHistoryState = false;
|
||
if (isExternalUrl) {
|
||
return handleExternalUrl(state, mutable, url.toString(), pendingPush);
|
||
}
|
||
const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({
|
||
```
|
||
|
||
**Source 6** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (pushRef.mpaNavigation) {
|
||
// if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL
|
||
if (globalMutable.pendingMpaPath !== canonicalUrl) {
|
||
const location1 = window.location;
|
||
if (pushRef.pendingPush) {
|
||
location1.assign(canonicalUrl);
|
||
} else {
|
||
location1.replace(canonicalUrl);
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication.
|
||
|
||
- **Source:** Unauthenticated attacker who persuades victim to authenticate using crafted login URL
|
||
|
||
- **Sink:** apps/frontend/src/lib/context/auth-context.tsx
|
||
|
||
**Source 1** — `apps/frontend/app/\[locale\]/login/page.tsx:95-99`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const { login } = useAuth();
|
||
const searchParams = useSearchParams();
|
||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||
const tLogin = useTranslations('auth.login');
|
||
const tPanel = useTranslations('auth.sidePanel');
|
||
```
|
||
|
||
**Source 2** — `apps/frontend/app/\[locale\]/login/page.tsx:162-167`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
await login(email, password, redirectTo, rememberMe);
|
||
} catch (err: any) {
|
||
const { message, field } = mapLoginError(err, tLogin);
|
||
```
|
||
|
||
**Source 3** — `apps/frontend/src/lib/context/auth-context.tsx:105-110`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
try {
|
||
await apiLogin({ email, password, rememberMe });
|
||
// Fetch complete user profile after login (session lives in httpOnly cookies)
|
||
const currentUser = await getCurrentUser();
|
||
setUser(currentUser);
|
||
router.push(redirectTo);
|
||
```
|
||
|
||
**Source 4** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function useNavigate(dispatch) {
|
||
return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{
|
||
const url = new URL((0, _addbasepath.addBasePath)(href), location.href);
|
||
return dispatch({
|
||
type: _routerreducertypes.ACTION_NAVIGATE,
|
||
url,
|
||
isExternalUrl: isExternalURL(url),
|
||
locationSearch: location.search,
|
||
shouldScroll: shouldScroll != null ? shouldScroll : true,
|
||
```
|
||
|
||
**Source 5** — `apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const pendingPush = navigateType === "push";
|
||
// we want to prune the prefetch cache on every navigation to avoid it growing too large
|
||
(0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache);
|
||
mutable.preserveCustomHistoryState = false;
|
||
if (isExternalUrl) {
|
||
return handleExternalUrl(state, mutable, url.toString(), pendingPush);
|
||
}
|
||
const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({
|
||
```
|
||
|
||
**Source 6** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (pushRef.mpaNavigation) {
|
||
// if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL
|
||
if (globalMutable.pendingMpaPath !== canonicalUrl) {
|
||
const location1 = window.location;
|
||
if (pushRef.pendingPush) {
|
||
location1.assign(canonicalUrl);
|
||
} else {
|
||
location1.replace(canonicalUrl);
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Unauthenticated attacker who persuades victim to authenticate using crafted login URL. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically.
|
||
|
||
- **Attacker:** Unauthenticated attacker who persuades victim to authenticate using crafted login URL
|
||
|
||
- **Entry point:** apps/frontend/app/\[locale\]/login/page.tsx
|
||
|
||
#### Severity
|
||
|
||
**High** — login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Resolve destination against expected origin, require same-origin http(s) URL and canonical internal pathname; reject protocol-relative and non-http schemes. Enforce at AuthProvider boundary.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-2"></a>
|
||
|
||
### [2] Un manager peut modifier une autre organisation
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | high |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-863 |
|
||
| Affected lines | apps/backend/src/application/auth/jwt.strategy.ts:75-81, apps/backend/src/application/guards/roles.guard.ts:43-50, apps/backend/src/application/controllers/organizations.controller.ts:241-256, apps/backend/src/application/controllers/organizations.controller.ts:291-304 |
|
||
|
||
#### Summary
|
||
|
||
PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche.
|
||
|
||
#### Root Cause
|
||
|
||
Seul ADMIN peut modifier une autre organisation. PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche.
|
||
|
||
**Source 1** — `apps/backend/src/application/auth/jwt.strategy.ts:75-81`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
role: user.role,
|
||
organizationId: user.organizationId,
|
||
firstName: user.firstName,
|
||
lastName: user.lastName,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/guards/roles.guard.ts:43-50`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
|
||
// Case-insensitive role comparison
|
||
const userRole = user.role.toLowerCase();
|
||
const requiredRolesLower = requiredRoles.map(r => r.toLowerCase());
|
||
|
||
return requiredRolesLower.includes(userRole);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/organizations.controller.ts:241-256`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async updateOrganization(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() dto: UpdateOrganizationDto,
|
||
@CurrentUser() user: UserPayload
|
||
): Promise<OrganizationResponseDto> {
|
||
this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
|
||
|
||
const organization = await this.organizationRepository.findById(id);
|
||
if (!organization) {
|
||
throw new NotFoundException(`Organization ${id} not found`);
|
||
}
|
||
|
||
// Authorization: Managers can only update their own organization
|
||
if (user.role === 'manager' && organization.id !== user.organizationId) {
|
||
throw new ForbiddenException('You can only update your own organization');
|
||
}
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/controllers/organizations.controller.ts:291-304`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (dto.isActive !== undefined) {
|
||
if (dto.isActive) {
|
||
organization.activate();
|
||
} else {
|
||
organization.deactivate();
|
||
}
|
||
}
|
||
|
||
// Save updated organization
|
||
const updatedOrg = await this.organizationRepository.save(organization);
|
||
|
||
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
|
||
|
||
return OrganizationMapper.toDto(updatedOrg);
|
||
```
|
||
|
||
#### Validation
|
||
|
||
PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche. Contre-preuves : UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/auth/jwt.strategy.ts:75-81`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
role: user.role,
|
||
organizationId: user.organizationId,
|
||
firstName: user.firstName,
|
||
lastName: user.lastName,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/guards/roles.guard.ts:43-50`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
|
||
// Case-insensitive role comparison
|
||
const userRole = user.role.toLowerCase();
|
||
const requiredRolesLower = requiredRoles.map(r => r.toLowerCase());
|
||
|
||
return requiredRolesLower.includes(userRole);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/organizations.controller.ts:241-256`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async updateOrganization(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() dto: UpdateOrganizationDto,
|
||
@CurrentUser() user: UserPayload
|
||
): Promise<OrganizationResponseDto> {
|
||
this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
|
||
|
||
const organization = await this.organizationRepository.findById(id);
|
||
if (!organization) {
|
||
throw new NotFoundException(`Organization ${id} not found`);
|
||
}
|
||
|
||
// Authorization: Managers can only update their own organization
|
||
if (user.role === 'manager' && organization.id !== user.organizationId) {
|
||
throw new ForbiddenException('You can only update your own organization');
|
||
}
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/controllers/organizations.controller.ts:291-304`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (dto.isActive !== undefined) {
|
||
if (dto.isActive) {
|
||
organization.activate();
|
||
} else {
|
||
organization.deactivate();
|
||
}
|
||
}
|
||
|
||
// Save updated organization
|
||
const updatedOrg = await this.organizationRepository.save(organization);
|
||
|
||
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
|
||
|
||
return OrganizationMapper.toDto(updatedOrg);
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche.
|
||
|
||
- **Source:** Manager authentifié connaissant l’UUID d’une organisation cible
|
||
|
||
- **Sink:** apps/backend/src/application/controllers/organizations.controller.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/auth/jwt.strategy.ts:75-81`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
role: user.role,
|
||
organizationId: user.organizationId,
|
||
firstName: user.firstName,
|
||
lastName: user.lastName,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/guards/roles.guard.ts:43-50`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
|
||
// Case-insensitive role comparison
|
||
const userRole = user.role.toLowerCase();
|
||
const requiredRolesLower = requiredRoles.map(r => r.toLowerCase());
|
||
|
||
return requiredRolesLower.includes(userRole);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/organizations.controller.ts:241-256`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async updateOrganization(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() dto: UpdateOrganizationDto,
|
||
@CurrentUser() user: UserPayload
|
||
): Promise<OrganizationResponseDto> {
|
||
this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
|
||
|
||
const organization = await this.organizationRepository.findById(id);
|
||
if (!organization) {
|
||
throw new NotFoundException(`Organization ${id} not found`);
|
||
}
|
||
|
||
// Authorization: Managers can only update their own organization
|
||
if (user.role === 'manager' && organization.id !== user.organizationId) {
|
||
throw new ForbiddenException('You can only update your own organization');
|
||
}
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/controllers/organizations.controller.ts:291-304`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (dto.isActive !== undefined) {
|
||
if (dto.isActive) {
|
||
organization.activate();
|
||
} else {
|
||
organization.deactivate();
|
||
}
|
||
}
|
||
|
||
// Save updated organization
|
||
const updatedOrg = await this.organizationRepository.save(organization);
|
||
|
||
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
|
||
|
||
return OrganizationMapper.toDto(updatedOrg);
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Manager authentifié connaissant l’UUID d’une organisation cible. UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role.
|
||
|
||
- **Attacker:** Manager authentifié connaissant l’UUID d’une organisation cible
|
||
|
||
- **Entry point:** apps/backend/src/application/auth/jwt.strategy.ts
|
||
|
||
#### Severity
|
||
|
||
**High** — PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche. UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Refuser tout appel non ADMIN dont la cible diffère de l’organisation de la session ; utiliser l’enum de rôle et appliquer le prédicat dans le service.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-3"></a>
|
||
|
||
### [3] Les téléversements ne bornent pas la mémoire utilisée
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-400 |
|
||
| Affected lines | apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88, apps/backend/src/application/controllers/csv-bookings.controller.ts:88, apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37, apps/backend/src/infrastructure/security/security.config.ts:179-194, apps/backend/node_modules/multer/index.js:11-23, apps/backend/node_modules/multer/storage/memory.js:3-12, apps/backend/node_modules/busboy/lib/types/multipart.js:250-256 |
|
||
|
||
#### Summary
|
||
|
||
POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory.
|
||
|
||
#### Root Cause
|
||
|
||
Single upload requests must have bounded resource use before buffering POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory.
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
|
||
ConfigModule,
|
||
NotificationsModule,
|
||
EmailModule,
|
||
StorageModule,
|
||
SubscriptionsModule,
|
||
StripeModule,
|
||
],
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/infrastructure/security/security.config.ts:179-194`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
/** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */
|
||
session: 'xpeditis_session',
|
||
} as const;
|
||
|
||
export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {
|
||
httpOnly: boolean;
|
||
secure: boolean;
|
||
sameSite: 'lax' | 'strict' | 'none';
|
||
path: string;
|
||
domain?: string;
|
||
maxAge?: number;
|
||
} {
|
||
// SameSite must be 'none' when the frontend and the API live on different
|
||
// sites (cross-origin), otherwise the browser drops the auth cookies set in
|
||
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
|
||
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
|
||
```
|
||
|
||
**Source 5** — `apps/backend/node_modules/multer/index.js:11-23`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function Multer (options) {
|
||
if (options.storage) {
|
||
this.storage = options.storage
|
||
} else if (options.dest) {
|
||
this.storage = diskStorage({ destination: options.dest })
|
||
} else {
|
||
this.storage = memoryStorage()
|
||
}
|
||
|
||
this.limits = options.limits
|
||
this.preservePath = options.preservePath
|
||
this.fileFilter = options.fileFilter || allowAll
|
||
}
|
||
```
|
||
|
||
**Source 6** — `apps/backend/node_modules/multer/storage/memory.js:3-12`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function MemoryStorage (opts) {}
|
||
|
||
MemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) {
|
||
file.stream.pipe(concat({ encoding: 'buffer' }, function (data) {
|
||
cb(null, {
|
||
buffer: data,
|
||
size: data.length
|
||
})
|
||
}))
|
||
}
|
||
```
|
||
|
||
**Source 7** — `apps/backend/node_modules/busboy/lib/types/multipart.js:250-256`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const limits = cfg.limits;
|
||
const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'
|
||
? limits.fieldSize
|
||
: 1 * 1024 * 1024);
|
||
const fileSizeLimit = (limits && typeof limits.fileSize === 'number'
|
||
? limits.fileSize
|
||
: Infinity);
|
||
```
|
||
|
||
#### Validation
|
||
|
||
POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Contre-preuves : Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
|
||
ConfigModule,
|
||
NotificationsModule,
|
||
EmailModule,
|
||
StorageModule,
|
||
SubscriptionsModule,
|
||
StripeModule,
|
||
],
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/infrastructure/security/security.config.ts:179-194`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
/** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */
|
||
session: 'xpeditis_session',
|
||
} as const;
|
||
|
||
export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {
|
||
httpOnly: boolean;
|
||
secure: boolean;
|
||
sameSite: 'lax' | 'strict' | 'none';
|
||
path: string;
|
||
domain?: string;
|
||
maxAge?: number;
|
||
} {
|
||
// SameSite must be 'none' when the frontend and the API live on different
|
||
// sites (cross-origin), otherwise the browser drops the auth cookies set in
|
||
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
|
||
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
|
||
```
|
||
|
||
**Source 5** — `apps/backend/node_modules/multer/index.js:11-23`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function Multer (options) {
|
||
if (options.storage) {
|
||
this.storage = options.storage
|
||
} else if (options.dest) {
|
||
this.storage = diskStorage({ destination: options.dest })
|
||
} else {
|
||
this.storage = memoryStorage()
|
||
}
|
||
|
||
this.limits = options.limits
|
||
this.preservePath = options.preservePath
|
||
this.fileFilter = options.fileFilter || allowAll
|
||
}
|
||
```
|
||
|
||
**Source 6** — `apps/backend/node_modules/multer/storage/memory.js:3-12`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function MemoryStorage (opts) {}
|
||
|
||
MemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) {
|
||
file.stream.pipe(concat({ encoding: 'buffer' }, function (data) {
|
||
cb(null, {
|
||
buffer: data,
|
||
size: data.length
|
||
})
|
||
}))
|
||
}
|
||
```
|
||
|
||
**Source 7** — `apps/backend/node_modules/busboy/lib/types/multipart.js:250-256`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const limits = cfg.limits;
|
||
const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'
|
||
? limits.fieldSize
|
||
: 1 * 1024 * 1024);
|
||
const fileSizeLimit = (limits && typeof limits.fileSize === 'number'
|
||
? limits.fileSize
|
||
: Infinity);
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory.
|
||
|
||
- **Source:** Any authenticated account, including newly registered free-plan user
|
||
|
||
- **Sink:** apps/backend/src/application/controllers/csv-bookings.controller.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
|
||
ConfigModule,
|
||
NotificationsModule,
|
||
EmailModule,
|
||
StorageModule,
|
||
SubscriptionsModule,
|
||
StripeModule,
|
||
],
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/infrastructure/security/security.config.ts:179-194`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
/** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */
|
||
session: 'xpeditis_session',
|
||
} as const;
|
||
|
||
export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {
|
||
httpOnly: boolean;
|
||
secure: boolean;
|
||
sameSite: 'lax' | 'strict' | 'none';
|
||
path: string;
|
||
domain?: string;
|
||
maxAge?: number;
|
||
} {
|
||
// SameSite must be 'none' when the frontend and the API live on different
|
||
// sites (cross-origin), otherwise the browser drops the auth cookies set in
|
||
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
|
||
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
|
||
```
|
||
|
||
**Source 5** — `apps/backend/node_modules/multer/index.js:11-23`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function Multer (options) {
|
||
if (options.storage) {
|
||
this.storage = options.storage
|
||
} else if (options.dest) {
|
||
this.storage = diskStorage({ destination: options.dest })
|
||
} else {
|
||
this.storage = memoryStorage()
|
||
}
|
||
|
||
this.limits = options.limits
|
||
this.preservePath = options.preservePath
|
||
this.fileFilter = options.fileFilter || allowAll
|
||
}
|
||
```
|
||
|
||
**Source 6** — `apps/backend/node_modules/multer/storage/memory.js:3-12`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
function MemoryStorage (opts) {}
|
||
|
||
MemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) {
|
||
file.stream.pipe(concat({ encoding: 'buffer' }, function (data) {
|
||
cb(null, {
|
||
buffer: data,
|
||
size: data.length
|
||
})
|
||
}))
|
||
}
|
||
```
|
||
|
||
**Source 7** — `apps/backend/node_modules/busboy/lib/types/multipart.js:250-256`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const limits = cfg.limits;
|
||
const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'
|
||
? limits.fieldSize
|
||
: 1 * 1024 * 1024);
|
||
const fileSizeLimit = (limits && typeof limits.fileSize === 'number'
|
||
? limits.fileSize
|
||
: Infinity);
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Any authenticated account, including newly registered free-plan user. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test.
|
||
|
||
- **Attacker:** Any authenticated account, including newly registered free-plan user
|
||
|
||
- **Entry point:** apps/backend/src/application/controllers/csv-bookings.controller.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Configure limits.fileSize, files, fields and parts on all upload interceptors; enforce ingress total-body limit and stream large uploads to storage.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-4"></a>
|
||
|
||
### [4] Les WebSockets acceptent des sessions révoquées ou désactivées
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-287 |
|
||
| Affected lines | apps/backend/src/application/gateways/notifications.gateway.ts:60-61, apps/backend/src/application/gateways/notifications.gateway.ts:60-82, apps/backend/src/application/notifications/notifications.module.ts:21-28, apps/backend/src/application/auth/auth.service.ts:234-253, apps/backend/src/application/auth/jwt.strategy.ts:60-72 |
|
||
|
||
#### Summary
|
||
|
||
Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired.
|
||
|
||
#### Root Cause
|
||
|
||
Notifications must require a current active account and access-token authentication Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired.
|
||
|
||
**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-61`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync(token);
|
||
const userId = payload.sub;
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-82`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync(token);
|
||
const userId = payload.sub;
|
||
|
||
// Store socket connection for user
|
||
if (!this.userSockets.has(userId)) {
|
||
this.userSockets.set(userId, new Set());
|
||
}
|
||
this.userSockets.get(userId)!.add(client.id);
|
||
|
||
// Store user ID in socket data for later use
|
||
client.data.userId = userId;
|
||
client.data.organizationId = payload.organizationId;
|
||
|
||
// Join user-specific room
|
||
client.join(`user:${userId}`);
|
||
|
||
this.logger.log(`Client ${client.id} connected for user ${userId}`);
|
||
|
||
// Send unread count on connection
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
client.emit('unread_count', { count: unreadCount });
|
||
|
||
// Send recent notifications on connection
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/notifications/notifications.module.ts:21-28`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
JwtModule.registerAsync({
|
||
imports: [ConfigModule],
|
||
useFactory: (configService: ConfigService) => ({
|
||
secret: configService.get<string>('JWT_SECRET'),
|
||
signOptions: {
|
||
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION', '15m'),
|
||
},
|
||
}),
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/auth/auth.service.ts:234-253`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||
secret: this.configService.get('JWT_SECRET'),
|
||
});
|
||
|
||
if (payload.type !== 'refresh') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
if (await this.isRefreshTokenRevoked(refreshToken)) {
|
||
throw new UnauthorizedException('Refresh token has been revoked');
|
||
}
|
||
|
||
const user = await this.userRepository.findById(payload.sub);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
const rememberMe = payload.rememberMe === true;
|
||
const tokens = await this.generateTokens(user, rememberMe);
|
||
```
|
||
|
||
**Source 5** — `apps/backend/src/application/auth/jwt.strategy.ts:60-72`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (payload.type !== 'access') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
// Validate user exists and is active
|
||
const user = await this.authService.validateUser(payload);
|
||
|
||
if (!user) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
// This object will be attached to request.user
|
||
return {
|
||
```
|
||
|
||
#### Validation
|
||
|
||
Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. Contre-preuves : JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-61`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync(token);
|
||
const userId = payload.sub;
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-82`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync(token);
|
||
const userId = payload.sub;
|
||
|
||
// Store socket connection for user
|
||
if (!this.userSockets.has(userId)) {
|
||
this.userSockets.set(userId, new Set());
|
||
}
|
||
this.userSockets.get(userId)!.add(client.id);
|
||
|
||
// Store user ID in socket data for later use
|
||
client.data.userId = userId;
|
||
client.data.organizationId = payload.organizationId;
|
||
|
||
// Join user-specific room
|
||
client.join(`user:${userId}`);
|
||
|
||
this.logger.log(`Client ${client.id} connected for user ${userId}`);
|
||
|
||
// Send unread count on connection
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
client.emit('unread_count', { count: unreadCount });
|
||
|
||
// Send recent notifications on connection
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/notifications/notifications.module.ts:21-28`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
JwtModule.registerAsync({
|
||
imports: [ConfigModule],
|
||
useFactory: (configService: ConfigService) => ({
|
||
secret: configService.get<string>('JWT_SECRET'),
|
||
signOptions: {
|
||
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION', '15m'),
|
||
},
|
||
}),
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/auth/auth.service.ts:234-253`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||
secret: this.configService.get('JWT_SECRET'),
|
||
});
|
||
|
||
if (payload.type !== 'refresh') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
if (await this.isRefreshTokenRevoked(refreshToken)) {
|
||
throw new UnauthorizedException('Refresh token has been revoked');
|
||
}
|
||
|
||
const user = await this.userRepository.findById(payload.sub);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
const rememberMe = payload.rememberMe === true;
|
||
const tokens = await this.generateTokens(user, rememberMe);
|
||
```
|
||
|
||
**Source 5** — `apps/backend/src/application/auth/jwt.strategy.ts:60-72`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (payload.type !== 'access') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
// Validate user exists and is active
|
||
const user = await this.authService.validateUser(payload);
|
||
|
||
if (!user) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
// This object will be attached to request.user
|
||
return {
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired.
|
||
|
||
- **Source:** Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout
|
||
|
||
- **Sink:** apps/backend/src/application/gateways/notifications.gateway.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-61`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync(token);
|
||
const userId = payload.sub;
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-82`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync(token);
|
||
const userId = payload.sub;
|
||
|
||
// Store socket connection for user
|
||
if (!this.userSockets.has(userId)) {
|
||
this.userSockets.set(userId, new Set());
|
||
}
|
||
this.userSockets.get(userId)!.add(client.id);
|
||
|
||
// Store user ID in socket data for later use
|
||
client.data.userId = userId;
|
||
client.data.organizationId = payload.organizationId;
|
||
|
||
// Join user-specific room
|
||
client.join(`user:${userId}`);
|
||
|
||
this.logger.log(`Client ${client.id} connected for user ${userId}`);
|
||
|
||
// Send unread count on connection
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
client.emit('unread_count', { count: unreadCount });
|
||
|
||
// Send recent notifications on connection
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/notifications/notifications.module.ts:21-28`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
JwtModule.registerAsync({
|
||
imports: [ConfigModule],
|
||
useFactory: (configService: ConfigService) => ({
|
||
secret: configService.get<string>('JWT_SECRET'),
|
||
signOptions: {
|
||
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION', '15m'),
|
||
},
|
||
}),
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/auth/auth.service.ts:234-253`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||
secret: this.configService.get('JWT_SECRET'),
|
||
});
|
||
|
||
if (payload.type !== 'refresh') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
if (await this.isRefreshTokenRevoked(refreshToken)) {
|
||
throw new UnauthorizedException('Refresh token has been revoked');
|
||
}
|
||
|
||
const user = await this.userRepository.findById(payload.sub);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
const rememberMe = payload.rememberMe === true;
|
||
const tokens = await this.generateTokens(user, rememberMe);
|
||
```
|
||
|
||
**Source 5** — `apps/backend/src/application/auth/jwt.strategy.ts:60-72`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (payload.type !== 'access') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
// Validate user exists and is active
|
||
const user = await this.authService.validateUser(payload);
|
||
|
||
if (!user) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
// This object will be attached to request.user
|
||
return {
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover.
|
||
|
||
- **Attacker:** Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout
|
||
|
||
- **Entry point:** apps/backend/src/application/gateways/notifications.gateway.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Require access payload type, validate live user, and enforce socket expiry/account revocation; use a shared authentication policy.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-5"></a>
|
||
|
||
### [5] Le client reçoit le jeton de réponse du transporteur
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-863 |
|
||
| Affected lines | apps/backend/src/application/services/csv-booking.service.ts:1608-1612, apps/backend/src/application/services/csv-booking.service.ts:244, apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47, apps/backend/src/application/services/csv-booking.service.ts:886-908, apps/backend/src/domain/entities/csv-booking.entity.ts:55-65 |
|
||
|
||
#### Summary
|
||
|
||
Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier.
|
||
|
||
#### Root Cause
|
||
|
||
Only the carrier receiving the email credential may accept or reject a booking Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier.
|
||
|
||
**Source 1** — `apps/backend/src/application/services/csv-booking.service.ts:1608-1612`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
status: booking.status,
|
||
documents: booking.documents.map(this.toDocumentDto),
|
||
confirmationToken: booking.confirmationToken,
|
||
requestedAt: booking.requestedAt,
|
||
respondedAt: booking.respondedAt || null,
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/services/csv-booking.service.ts:244`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
return this.toResponseDto(savedBooking);
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Public()
|
||
@Get('accept/:token')
|
||
@ApiOperation({
|
||
summary: 'Accept booking request (public)',
|
||
description:
|
||
'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',
|
||
})
|
||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||
@ApiResponse({
|
||
status: 200,
|
||
description: 'Booking accepted successfully.',
|
||
})
|
||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||
@ApiResponse({
|
||
status: 400,
|
||
description: 'Booking cannot be accepted (invalid status or expired)',
|
||
})
|
||
async acceptBooking(@Param('token') token: string) {
|
||
// Accept the booking
|
||
const booking = await this.csvBookingService.acceptBooking(token);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:886-908`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async acceptBooking(token: string): Promise<CsvBookingResponseDto> {
|
||
this.logger.log(`Accepting booking with token: ${token}`);
|
||
|
||
const booking = await this.csvBookingRepository.findByToken(token);
|
||
|
||
if (!booking) {
|
||
throw new NotFoundException('Booking not found');
|
||
}
|
||
|
||
// Get ORM entity for bookingNumber
|
||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||
where: { confirmationToken: token },
|
||
});
|
||
|
||
// Accept the booking (domain logic validates status)
|
||
booking.accept();
|
||
|
||
// Apply the flat per-booking service fee (forfait par booking) from the org's plan
|
||
const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
|
||
booking.applyBookingFee(bookingFeeEur);
|
||
this.logger.log(
|
||
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
|
||
);
|
||
```
|
||
|
||
**Source 5** — `apps/backend/src/domain/entities/csv-booking.entity.ts:55-65`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
* This is a simplified booking workflow for CSV-based rates where the user
|
||
* selects a rate and sends a booking request to the carrier with documents.
|
||
*
|
||
* Business Rules:
|
||
* - Booking can only be accepted/rejected when status is PENDING
|
||
* - Once accepted/rejected, status cannot be changed
|
||
* - Booking expires after 7 days if not responded to
|
||
* - At least one document is required for booking creation
|
||
* - Confirmation token is used for email accept/reject links
|
||
* - Only carrier can accept/reject via email link
|
||
* - User can cancel pending bookings
|
||
```
|
||
|
||
#### Validation
|
||
|
||
Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Contre-preuves : Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/services/csv-booking.service.ts:1608-1612`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
status: booking.status,
|
||
documents: booking.documents.map(this.toDocumentDto),
|
||
confirmationToken: booking.confirmationToken,
|
||
requestedAt: booking.requestedAt,
|
||
respondedAt: booking.respondedAt || null,
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/services/csv-booking.service.ts:244`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
return this.toResponseDto(savedBooking);
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Public()
|
||
@Get('accept/:token')
|
||
@ApiOperation({
|
||
summary: 'Accept booking request (public)',
|
||
description:
|
||
'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',
|
||
})
|
||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||
@ApiResponse({
|
||
status: 200,
|
||
description: 'Booking accepted successfully.',
|
||
})
|
||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||
@ApiResponse({
|
||
status: 400,
|
||
description: 'Booking cannot be accepted (invalid status or expired)',
|
||
})
|
||
async acceptBooking(@Param('token') token: string) {
|
||
// Accept the booking
|
||
const booking = await this.csvBookingService.acceptBooking(token);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:886-908`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async acceptBooking(token: string): Promise<CsvBookingResponseDto> {
|
||
this.logger.log(`Accepting booking with token: ${token}`);
|
||
|
||
const booking = await this.csvBookingRepository.findByToken(token);
|
||
|
||
if (!booking) {
|
||
throw new NotFoundException('Booking not found');
|
||
}
|
||
|
||
// Get ORM entity for bookingNumber
|
||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||
where: { confirmationToken: token },
|
||
});
|
||
|
||
// Accept the booking (domain logic validates status)
|
||
booking.accept();
|
||
|
||
// Apply the flat per-booking service fee (forfait par booking) from the org's plan
|
||
const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
|
||
booking.applyBookingFee(bookingFeeEur);
|
||
this.logger.log(
|
||
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
|
||
);
|
||
```
|
||
|
||
**Source 5** — `apps/backend/src/domain/entities/csv-booking.entity.ts:55-65`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
* This is a simplified booking workflow for CSV-based rates where the user
|
||
* selects a rate and sends a booking request to the carrier with documents.
|
||
*
|
||
* Business Rules:
|
||
* - Booking can only be accepted/rejected when status is PENDING
|
||
* - Once accepted/rejected, status cannot be changed
|
||
* - Booking expires after 7 days if not responded to
|
||
* - At least one document is required for booking creation
|
||
* - Confirmation token is used for email accept/reject links
|
||
* - Only carrier can accept/reject via email link
|
||
* - User can cancel pending bookings
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier.
|
||
|
||
- **Source:** Booking creator or another organization member reading organization/all
|
||
|
||
- **Sink:** apps/backend/src/application/services/csv-booking.service.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/services/csv-booking.service.ts:1608-1612`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
status: booking.status,
|
||
documents: booking.documents.map(this.toDocumentDto),
|
||
confirmationToken: booking.confirmationToken,
|
||
requestedAt: booking.requestedAt,
|
||
respondedAt: booking.respondedAt || null,
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/services/csv-booking.service.ts:244`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
return this.toResponseDto(savedBooking);
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Public()
|
||
@Get('accept/:token')
|
||
@ApiOperation({
|
||
summary: 'Accept booking request (public)',
|
||
description:
|
||
'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',
|
||
})
|
||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||
@ApiResponse({
|
||
status: 200,
|
||
description: 'Booking accepted successfully.',
|
||
})
|
||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||
@ApiResponse({
|
||
status: 400,
|
||
description: 'Booking cannot be accepted (invalid status or expired)',
|
||
})
|
||
async acceptBooking(@Param('token') token: string) {
|
||
// Accept the booking
|
||
const booking = await this.csvBookingService.acceptBooking(token);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:886-908`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async acceptBooking(token: string): Promise<CsvBookingResponseDto> {
|
||
this.logger.log(`Accepting booking with token: ${token}`);
|
||
|
||
const booking = await this.csvBookingRepository.findByToken(token);
|
||
|
||
if (!booking) {
|
||
throw new NotFoundException('Booking not found');
|
||
}
|
||
|
||
// Get ORM entity for bookingNumber
|
||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||
where: { confirmationToken: token },
|
||
});
|
||
|
||
// Accept the booking (domain logic validates status)
|
||
booking.accept();
|
||
|
||
// Apply the flat per-booking service fee (forfait par booking) from the org's plan
|
||
const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
|
||
booking.applyBookingFee(bookingFeeEur);
|
||
this.logger.log(
|
||
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
|
||
);
|
||
```
|
||
|
||
**Source 5** — `apps/backend/src/domain/entities/csv-booking.entity.ts:55-65`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
* This is a simplified booking workflow for CSV-based rates where the user
|
||
* selects a rate and sends a booking request to the carrier with documents.
|
||
*
|
||
* Business Rules:
|
||
* - Booking can only be accepted/rejected when status is PENDING
|
||
* - Once accepted/rejected, status cannot be changed
|
||
* - Booking expires after 7 days if not responded to
|
||
* - At least one document is required for booking creation
|
||
* - Confirmation token is used for email accept/reject links
|
||
* - Only carrier can accept/reject via email link
|
||
* - User can cancel pending bookings
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Booking creator or another organization member reading organization/all. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision.
|
||
|
||
- **Attacker:** Booking creator or another organization member reading organization/all
|
||
|
||
- **Entry point:** apps/backend/src/application/services/csv-booking.service.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Remove action credentials from all normal booking responses and use separate carrier-only scoped tokens. Require carrier-side authenticated confirmation and rotate exposed tokens.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-6"></a>
|
||
|
||
### [6] Les logs contiennent mots de passe et invitations
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-532 |
|
||
| Affected lines | apps/backend/src/application/controllers/users.controller.ts:163-166, apps/backend/src/application/controllers/users.controller.ts:134-156, apps/backend/src/application/services/invitation.service.ts:178-181, apps/backend/src/app.module.ts:123-135 |
|
||
|
||
#### Summary
|
||
|
||
Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee.
|
||
|
||
#### Root Cause
|
||
|
||
Authentication secrets must not be exposed to log readers Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee.
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:163-166`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// TODO: Send invitation email with temporary password
|
||
this.logger.warn(
|
||
`TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`
|
||
);
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:134-156`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const tempPassword = dto.password || this.generateTemporaryPassword();
|
||
|
||
// Hash password with Argon2id
|
||
const passwordHash = await argon2.hash(tempPassword, {
|
||
type: argon2.argon2id,
|
||
memoryCost: 65536, // 64 MB
|
||
timeCost: 3,
|
||
parallelism: 4,
|
||
});
|
||
|
||
// Map DTO role to Domain role
|
||
const domainRole = dto.role as unknown as DomainUserRole;
|
||
|
||
// Create user entity
|
||
const newUser = User.create({
|
||
id: uuidv4(),
|
||
organizationId: dto.organizationId,
|
||
email: dto.email,
|
||
passwordHash,
|
||
firstName: dto.firstName,
|
||
lastName: dto.lastName,
|
||
role: domainRole,
|
||
});
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/invitation.service.ts:178-181`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;
|
||
|
||
this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);
|
||
this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/app.module.ts:123-135`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
level: isDev ? 'debug' : 'info',
|
||
// Redact sensitive fields from logs
|
||
redact: {
|
||
paths: [
|
||
'req.headers.authorization',
|
||
'req.headers["x-api-key"]',
|
||
'req.body.password',
|
||
'req.body.currentPassword',
|
||
'req.body.newPassword',
|
||
],
|
||
censor: '[REDACTED]',
|
||
},
|
||
},
|
||
```
|
||
|
||
#### Validation
|
||
|
||
Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Contre-preuves : Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:163-166`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// TODO: Send invitation email with temporary password
|
||
this.logger.warn(
|
||
`TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`
|
||
);
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:134-156`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const tempPassword = dto.password || this.generateTemporaryPassword();
|
||
|
||
// Hash password with Argon2id
|
||
const passwordHash = await argon2.hash(tempPassword, {
|
||
type: argon2.argon2id,
|
||
memoryCost: 65536, // 64 MB
|
||
timeCost: 3,
|
||
parallelism: 4,
|
||
});
|
||
|
||
// Map DTO role to Domain role
|
||
const domainRole = dto.role as unknown as DomainUserRole;
|
||
|
||
// Create user entity
|
||
const newUser = User.create({
|
||
id: uuidv4(),
|
||
organizationId: dto.organizationId,
|
||
email: dto.email,
|
||
passwordHash,
|
||
firstName: dto.firstName,
|
||
lastName: dto.lastName,
|
||
role: domainRole,
|
||
});
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/invitation.service.ts:178-181`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;
|
||
|
||
this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);
|
||
this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/app.module.ts:123-135`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
level: isDev ? 'debug' : 'info',
|
||
// Redact sensitive fields from logs
|
||
redact: {
|
||
paths: [
|
||
'req.headers.authorization',
|
||
'req.headers["x-api-key"]',
|
||
'req.body.password',
|
||
'req.body.currentPassword',
|
||
'req.body.newPassword',
|
||
],
|
||
censor: '[REDACTED]',
|
||
},
|
||
},
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee.
|
||
|
||
- **Source:** Operator or attacker able to read application logs but not authorized to authenticate as users
|
||
|
||
- **Sink:** apps/backend/src/application/controllers/users.controller.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:163-166`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// TODO: Send invitation email with temporary password
|
||
this.logger.warn(
|
||
`TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`
|
||
);
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:134-156`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const tempPassword = dto.password || this.generateTemporaryPassword();
|
||
|
||
// Hash password with Argon2id
|
||
const passwordHash = await argon2.hash(tempPassword, {
|
||
type: argon2.argon2id,
|
||
memoryCost: 65536, // 64 MB
|
||
timeCost: 3,
|
||
parallelism: 4,
|
||
});
|
||
|
||
// Map DTO role to Domain role
|
||
const domainRole = dto.role as unknown as DomainUserRole;
|
||
|
||
// Create user entity
|
||
const newUser = User.create({
|
||
id: uuidv4(),
|
||
organizationId: dto.organizationId,
|
||
email: dto.email,
|
||
passwordHash,
|
||
firstName: dto.firstName,
|
||
lastName: dto.lastName,
|
||
role: domainRole,
|
||
});
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/invitation.service.ts:178-181`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;
|
||
|
||
this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);
|
||
this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/app.module.ts:123-135`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
level: isDev ? 'debug' : 'info',
|
||
// Redact sensitive fields from logs
|
||
redact: {
|
||
paths: [
|
||
'req.headers.authorization',
|
||
'req.headers["x-api-key"]',
|
||
'req.body.password',
|
||
'req.body.currentPassword',
|
||
'req.body.newPassword',
|
||
],
|
||
censor: '[REDACTED]',
|
||
},
|
||
},
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Operator or attacker able to read application logs but not authorized to authenticate as users. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented.
|
||
|
||
- **Attacker:** Operator or attacker able to read application logs but not authorized to authenticate as users
|
||
|
||
- **Entry point:** apps/backend/src/application/controllers/users.controller.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Delete secret-bearing logger messages, redact cookie/token fields, use expiring one-use invitation activation instead of logging generated passwords, and rotate any exposed credentials.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-7"></a>
|
||
|
||
### [7] La résiliation peut conserver les avantages payants
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-841 |
|
||
| Affected lines | apps/backend/src/application/services/subscription.service.ts:608-619, apps/backend/src/domain/entities/subscription.entity.ts:262-269, apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55, apps/backend/src/application/controllers/subscriptions.controller.ts:279-281 |
|
||
|
||
#### Summary
|
||
|
||
customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200.
|
||
|
||
#### Root Cause
|
||
|
||
Une résiliation doit retirer les droits même si le compte dépasse la capacité gratuite. customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200.
|
||
|
||
**Source 1** — `apps/backend/src/application/services/subscription.service.ts:608-619`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
}
|
||
|
||
// Downgrade to FREE plan - count only non-ADMIN licenses
|
||
const canceledSubscription = subscription
|
||
.updatePlan(
|
||
SubscriptionPlan.bronze(),
|
||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
|
||
)
|
||
.updateStatus(SubscriptionStatus.canceled());
|
||
|
||
await this.subscriptionRepository.save(canceledSubscription);
|
||
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/domain/entities/subscription.entity.ts:262-269`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (!newPlan.canAccommodateUsers(currentUserCount)) {
|
||
throw new InvalidSubscriptionDowngradeException(
|
||
this.props.plan.value,
|
||
newPlan.value,
|
||
currentUserCount,
|
||
newPlan.maxLicenses
|
||
);
|
||
}
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
maxLicenses: 1,
|
||
monthlyPriceEur: 0,
|
||
yearlyPriceEur: 0,
|
||
maxShipmentsPerYear: 5,
|
||
bookingFeeEur: 15,
|
||
statusBadge: 'none',
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/controllers/subscriptions.controller.ts:279-281`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
this.logger.error('Webhook processing failed', error);
|
||
return { received: false };
|
||
}
|
||
```
|
||
|
||
#### Validation
|
||
|
||
customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200. Contre-preuves : Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/services/subscription.service.ts:608-619`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
}
|
||
|
||
// Downgrade to FREE plan - count only non-ADMIN licenses
|
||
const canceledSubscription = subscription
|
||
.updatePlan(
|
||
SubscriptionPlan.bronze(),
|
||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
|
||
)
|
||
.updateStatus(SubscriptionStatus.canceled());
|
||
|
||
await this.subscriptionRepository.save(canceledSubscription);
|
||
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/domain/entities/subscription.entity.ts:262-269`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (!newPlan.canAccommodateUsers(currentUserCount)) {
|
||
throw new InvalidSubscriptionDowngradeException(
|
||
this.props.plan.value,
|
||
newPlan.value,
|
||
currentUserCount,
|
||
newPlan.maxLicenses
|
||
);
|
||
}
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
maxLicenses: 1,
|
||
monthlyPriceEur: 0,
|
||
yearlyPriceEur: 0,
|
||
maxShipmentsPerYear: 5,
|
||
bookingFeeEur: 15,
|
||
statusBadge: 'none',
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/controllers/subscriptions.controller.ts:279-281`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
this.logger.error('Webhook processing failed', error);
|
||
return { received: false };
|
||
}
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200.
|
||
|
||
- **Source:** Manager d’une organisation payante ayant au moins deux licences actives non ADMIN
|
||
|
||
- **Sink:** apps/backend/src/application/services/subscription.service.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/services/subscription.service.ts:608-619`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
}
|
||
|
||
// Downgrade to FREE plan - count only non-ADMIN licenses
|
||
const canceledSubscription = subscription
|
||
.updatePlan(
|
||
SubscriptionPlan.bronze(),
|
||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
|
||
)
|
||
.updateStatus(SubscriptionStatus.canceled());
|
||
|
||
await this.subscriptionRepository.save(canceledSubscription);
|
||
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/domain/entities/subscription.entity.ts:262-269`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (!newPlan.canAccommodateUsers(currentUserCount)) {
|
||
throw new InvalidSubscriptionDowngradeException(
|
||
this.props.plan.value,
|
||
newPlan.value,
|
||
currentUserCount,
|
||
newPlan.maxLicenses
|
||
);
|
||
}
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
maxLicenses: 1,
|
||
monthlyPriceEur: 0,
|
||
yearlyPriceEur: 0,
|
||
maxShipmentsPerYear: 5,
|
||
bookingFeeEur: 15,
|
||
statusBadge: 'none',
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/controllers/subscriptions.controller.ts:279-281`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
this.logger.error('Webhook processing failed', error);
|
||
return { received: false };
|
||
}
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Manager d’une organisation payante ayant au moins deux licences actives non ADMIN. Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe.
|
||
|
||
- **Attacker:** Manager d’une organisation payante ayant au moins deux licences actives non ADMIN
|
||
|
||
- **Entry point:** apps/backend/src/application/services/subscription.service.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200. Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Persister la résiliation indépendamment des limites de licences, retirer les droits effectifs puis résoudre le surnombre. Ne pas acquitter une erreur de traitement comme un succès.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-8"></a>
|
||
|
||
### [8] VIEWER peut créer et modifier des réservations
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-862 |
|
||
| Affected lines | apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88, apps/backend/src/domain/entities/user.entity.ts:19, apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88, apps/backend/src/application/services/csv-booking.service.ts:146-168 |
|
||
|
||
#### Summary
|
||
|
||
VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings.
|
||
|
||
#### Root Cause
|
||
|
||
VIEWER role is read-only and cannot create bookings VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings.
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/domain/entities/user.entity.ts:19`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
VIEWER = 'VIEWER', // Read-only access
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:146-168`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
this.logger.log(`Creating CSV booking for user ${userId}`);
|
||
|
||
// Validate minimum document requirement
|
||
if (!files || files.length === 0) {
|
||
throw new BadRequestException('At least one document is required');
|
||
}
|
||
|
||
// Generate unique confirmation token and booking number
|
||
const confirmationToken = uuidv4();
|
||
const bookingId = uuidv4();
|
||
const bookingNumber = this.generateBookingNumber();
|
||
const documentPassword = this.deriveDocumentPassword(bookingId);
|
||
|
||
// Hash the password for storage
|
||
const passwordHash = await argon2.hash(documentPassword);
|
||
|
||
// Upload documents to S3
|
||
const documents = await this.uploadDocuments(files, bookingId);
|
||
|
||
// Flat per-booking service fee (forfait par booking) based on the org's plan.
|
||
// A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
|
||
// booking skips the payment gate and the carrier is notified immediately.
|
||
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
|
||
```
|
||
|
||
#### Validation
|
||
|
||
VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Contre-preuves : Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/domain/entities/user.entity.ts:19`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
VIEWER = 'VIEWER', // Read-only access
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:146-168`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
this.logger.log(`Creating CSV booking for user ${userId}`);
|
||
|
||
// Validate minimum document requirement
|
||
if (!files || files.length === 0) {
|
||
throw new BadRequestException('At least one document is required');
|
||
}
|
||
|
||
// Generate unique confirmation token and booking number
|
||
const confirmationToken = uuidv4();
|
||
const bookingId = uuidv4();
|
||
const bookingNumber = this.generateBookingNumber();
|
||
const documentPassword = this.deriveDocumentPassword(bookingId);
|
||
|
||
// Hash the password for storage
|
||
const passwordHash = await argon2.hash(documentPassword);
|
||
|
||
// Upload documents to S3
|
||
const documents = await this.uploadDocuments(files, bookingId);
|
||
|
||
// Flat per-booking service fee (forfait par booking) based on the org's plan.
|
||
// A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
|
||
// booking skips the payment gate and the carrier is notified immediately.
|
||
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings.
|
||
|
||
- **Source:** Active VIEWER account including account downgraded from USER
|
||
|
||
- **Sink:** apps/backend/src/application/controllers/csv-bookings.controller.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/domain/entities/user.entity.ts:19`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
VIEWER = 'VIEWER', // Read-only access
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Post()
|
||
@ApiBearerAuth()
|
||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:146-168`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
this.logger.log(`Creating CSV booking for user ${userId}`);
|
||
|
||
// Validate minimum document requirement
|
||
if (!files || files.length === 0) {
|
||
throw new BadRequestException('At least one document is required');
|
||
}
|
||
|
||
// Generate unique confirmation token and booking number
|
||
const confirmationToken = uuidv4();
|
||
const bookingId = uuidv4();
|
||
const bookingNumber = this.generateBookingNumber();
|
||
const documentPassword = this.deriveDocumentPassword(bookingId);
|
||
|
||
// Hash the password for storage
|
||
const passwordHash = await argon2.hash(documentPassword);
|
||
|
||
// Upload documents to S3
|
||
const documents = await this.uploadDocuments(files, bookingId);
|
||
|
||
// Flat per-booking service fee (forfait par booking) based on the org's plan.
|
||
// A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
|
||
// booking skips the payment gate and the carrier is notified immediately.
|
||
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Active VIEWER account including account downgraded from USER. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source.
|
||
|
||
- **Attacker:** Active VIEWER account including account downgraded from USER
|
||
|
||
- **Entry point:** apps/backend/src/application/controllers/csv-bookings.controller.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Apply role policy to every booking mutation (ADMIN/MANAGER/USER), while preserving VIEWER read paths.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-9"></a>
|
||
|
||
### [9] Un membre peut marquer toutes les notifications comme lues
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-639 |
|
||
| Affected lines | apps/backend/src/application/gateways/notifications.gateway.ts:117-124, apps/backend/src/application/gateways/notifications.gateway.ts:112-124, apps/backend/src/application/services/notification.service.ts:125-127, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158, apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365 |
|
||
|
||
#### Summary
|
||
|
||
Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria).
|
||
|
||
#### Root Cause
|
||
|
||
Only the notification recipient may mark their own notification as read Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria).
|
||
|
||
**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:117-124`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
) {
|
||
try {
|
||
const userId = client.data.userId;
|
||
await this.notificationService.markAsRead(data.notificationId);
|
||
|
||
// Send updated unread count
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:112-124`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
*/
|
||
@SubscribeMessage('mark_as_read')
|
||
async handleMarkAsRead(
|
||
@ConnectedSocket() client: Socket,
|
||
@MessageBody() data: { notificationId: string }
|
||
) {
|
||
try {
|
||
const userId = client.data.userId;
|
||
await this.notificationService.markAsRead(data.notificationId);
|
||
|
||
// Send updated unread count
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/notification.service.ts:125-127`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
/**
|
||
* Delete notification
|
||
*/
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async markAsRead(id: string): Promise<void> {
|
||
await this.ormRepository.update(id, {
|
||
read: true,
|
||
read_at: new Date(),
|
||
});
|
||
}
|
||
```
|
||
|
||
**Source 5** — `apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
update(target, criteria, partialEntity) {
|
||
// if user passed empty criteria or empty list of criterias, then throw an error
|
||
if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) {
|
||
return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`));
|
||
}
|
||
if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) {
|
||
return this.createQueryBuilder()
|
||
.update(target)
|
||
.set(partialEntity)
|
||
.whereInIds(criteria)
|
||
.execute();
|
||
}
|
||
else {
|
||
return this.createQueryBuilder()
|
||
.update(target)
|
||
.set(partialEntity)
|
||
.where(criteria)
|
||
.execute();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Validation
|
||
|
||
Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). Contre-preuves : REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:117-124`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
) {
|
||
try {
|
||
const userId = client.data.userId;
|
||
await this.notificationService.markAsRead(data.notificationId);
|
||
|
||
// Send updated unread count
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:112-124`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
*/
|
||
@SubscribeMessage('mark_as_read')
|
||
async handleMarkAsRead(
|
||
@ConnectedSocket() client: Socket,
|
||
@MessageBody() data: { notificationId: string }
|
||
) {
|
||
try {
|
||
const userId = client.data.userId;
|
||
await this.notificationService.markAsRead(data.notificationId);
|
||
|
||
// Send updated unread count
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/notification.service.ts:125-127`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
/**
|
||
* Delete notification
|
||
*/
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async markAsRead(id: string): Promise<void> {
|
||
await this.ormRepository.update(id, {
|
||
read: true,
|
||
read_at: new Date(),
|
||
});
|
||
}
|
||
```
|
||
|
||
**Source 5** — `apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
update(target, criteria, partialEntity) {
|
||
// if user passed empty criteria or empty list of criterias, then throw an error
|
||
if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) {
|
||
return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`));
|
||
}
|
||
if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) {
|
||
return this.createQueryBuilder()
|
||
.update(target)
|
||
.set(partialEntity)
|
||
.whereInIds(criteria)
|
||
.execute();
|
||
}
|
||
else {
|
||
return this.createQueryBuilder()
|
||
.update(target)
|
||
.set(partialEntity)
|
||
.where(criteria)
|
||
.execute();
|
||
}
|
||
}
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria).
|
||
|
||
- **Source:** Any authenticated WebSocket user
|
||
|
||
- **Sink:** apps/backend/src/application/gateways/notifications.gateway.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:117-124`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
) {
|
||
try {
|
||
const userId = client.data.userId;
|
||
await this.notificationService.markAsRead(data.notificationId);
|
||
|
||
// Send updated unread count
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:112-124`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
*/
|
||
@SubscribeMessage('mark_as_read')
|
||
async handleMarkAsRead(
|
||
@ConnectedSocket() client: Socket,
|
||
@MessageBody() data: { notificationId: string }
|
||
) {
|
||
try {
|
||
const userId = client.data.userId;
|
||
await this.notificationService.markAsRead(data.notificationId);
|
||
|
||
// Send updated unread count
|
||
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/notification.service.ts:125-127`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
/**
|
||
* Delete notification
|
||
*/
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async markAsRead(id: string): Promise<void> {
|
||
await this.ormRepository.update(id, {
|
||
read: true,
|
||
read_at: new Date(),
|
||
});
|
||
}
|
||
```
|
||
|
||
**Source 5** — `apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
update(target, criteria, partialEntity) {
|
||
// if user passed empty criteria or empty list of criterias, then throw an error
|
||
if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) {
|
||
return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`));
|
||
}
|
||
if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) {
|
||
return this.createQueryBuilder()
|
||
.update(target)
|
||
.set(partialEntity)
|
||
.whereInIds(criteria)
|
||
.execute();
|
||
}
|
||
else {
|
||
return this.createQueryBuilder()
|
||
.update(target)
|
||
.set(partialEntity)
|
||
.where(criteria)
|
||
.execute();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Any authenticated WebSocket user. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty.
|
||
|
||
- **Attacker:** Any authenticated WebSocket user
|
||
|
||
- **Entry point:** apps/backend/src/application/gateways/notifications.gateway.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Use a validated UUID message DTO and an update predicate containing id AND authenticated user_id; never pass caller-selected criteria into ORM methods.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-10"></a>
|
||
|
||
### [10] Le changement de mot de passe conserve les anciennes sessions
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-613 |
|
||
| Affected lines | apps/backend/src/application/auth/auth.service.ts:386-392, apps/backend/src/application/auth/auth.service.ts:354-376, apps/backend/src/application/auth/auth.service.ts:234-253, apps/backend/src/domain/entities/user.entity.ts:196-199 |
|
||
|
||
#### Summary
|
||
|
||
Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access.
|
||
|
||
#### Root Cause
|
||
|
||
Recovering a compromised account must invalidate pre-reset authentication sessions Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access.
|
||
|
||
**Source 1** — `apps/backend/src/application/auth/auth.service.ts:386-392`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Update password (mutates in place)
|
||
user.updatePassword(passwordHash);
|
||
await this.userRepository.save(user);
|
||
|
||
// Mark token as used
|
||
await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });
|
||
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/auth/auth.service.ts:354-376`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||
const resetToken = await this.passwordResetTokenRepository.findOne({
|
||
where: { token: this.hashResetToken(token) },
|
||
});
|
||
|
||
if (!resetToken) {
|
||
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
|
||
}
|
||
|
||
if (resetToken.usedAt) {
|
||
throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé');
|
||
}
|
||
|
||
if (resetToken.expiresAt < new Date()) {
|
||
throw new BadRequestException(
|
||
'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'
|
||
);
|
||
}
|
||
|
||
const user = await this.userRepository.findById(resetToken.userId);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new NotFoundException('Utilisateur introuvable');
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/auth/auth.service.ts:234-253`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||
secret: this.configService.get('JWT_SECRET'),
|
||
});
|
||
|
||
if (payload.type !== 'refresh') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
if (await this.isRefreshTokenRevoked(refreshToken)) {
|
||
throw new UnauthorizedException('Refresh token has been revoked');
|
||
}
|
||
|
||
const user = await this.userRepository.findById(payload.sub);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
const rememberMe = payload.rememberMe === true;
|
||
const tokens = await this.generateTokens(user, rememberMe);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/domain/entities/user.entity.ts:196-199`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
updatePassword(newPasswordHash: string): void {
|
||
this.props.passwordHash = newPasswordHash;
|
||
this.props.updatedAt = new Date();
|
||
}
|
||
```
|
||
|
||
#### Validation
|
||
|
||
Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Contre-preuves : Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/auth/auth.service.ts:386-392`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Update password (mutates in place)
|
||
user.updatePassword(passwordHash);
|
||
await this.userRepository.save(user);
|
||
|
||
// Mark token as used
|
||
await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });
|
||
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/auth/auth.service.ts:354-376`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||
const resetToken = await this.passwordResetTokenRepository.findOne({
|
||
where: { token: this.hashResetToken(token) },
|
||
});
|
||
|
||
if (!resetToken) {
|
||
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
|
||
}
|
||
|
||
if (resetToken.usedAt) {
|
||
throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé');
|
||
}
|
||
|
||
if (resetToken.expiresAt < new Date()) {
|
||
throw new BadRequestException(
|
||
'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'
|
||
);
|
||
}
|
||
|
||
const user = await this.userRepository.findById(resetToken.userId);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new NotFoundException('Utilisateur introuvable');
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/auth/auth.service.ts:234-253`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||
secret: this.configService.get('JWT_SECRET'),
|
||
});
|
||
|
||
if (payload.type !== 'refresh') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
if (await this.isRefreshTokenRevoked(refreshToken)) {
|
||
throw new UnauthorizedException('Refresh token has been revoked');
|
||
}
|
||
|
||
const user = await this.userRepository.findById(payload.sub);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
const rememberMe = payload.rememberMe === true;
|
||
const tokens = await this.generateTokens(user, rememberMe);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/domain/entities/user.entity.ts:196-199`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
updatePassword(newPasswordHash: string): void {
|
||
this.props.passwordHash = newPasswordHash;
|
||
this.props.updatedAt = new Date();
|
||
}
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access.
|
||
|
||
- **Source:** Attacker holding a victim refresh token before password recovery
|
||
|
||
- **Sink:** apps/backend/src/application/auth/auth.service.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/auth/auth.service.ts:386-392`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Update password (mutates in place)
|
||
user.updatePassword(passwordHash);
|
||
await this.userRepository.save(user);
|
||
|
||
// Mark token as used
|
||
await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });
|
||
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/auth/auth.service.ts:354-376`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||
const resetToken = await this.passwordResetTokenRepository.findOne({
|
||
where: { token: this.hashResetToken(token) },
|
||
});
|
||
|
||
if (!resetToken) {
|
||
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
|
||
}
|
||
|
||
if (resetToken.usedAt) {
|
||
throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé');
|
||
}
|
||
|
||
if (resetToken.expiresAt < new Date()) {
|
||
throw new BadRequestException(
|
||
'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'
|
||
);
|
||
}
|
||
|
||
const user = await this.userRepository.findById(resetToken.userId);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new NotFoundException('Utilisateur introuvable');
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/auth/auth.service.ts:234-253`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||
secret: this.configService.get('JWT_SECRET'),
|
||
});
|
||
|
||
if (payload.type !== 'refresh') {
|
||
throw new UnauthorizedException('Invalid token type');
|
||
}
|
||
|
||
if (await this.isRefreshTokenRevoked(refreshToken)) {
|
||
throw new UnauthorizedException('Refresh token has been revoked');
|
||
}
|
||
|
||
const user = await this.userRepository.findById(payload.sub);
|
||
|
||
if (!user || !user.isActive) {
|
||
throw new UnauthorizedException('User not found or inactive');
|
||
}
|
||
|
||
const rememberMe = payload.rememberMe === true;
|
||
const tokens = await this.generateTokens(user, rememberMe);
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/domain/entities/user.entity.ts:196-199`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
updatePassword(newPasswordHash: string): void {
|
||
this.props.passwordHash = newPasswordHash;
|
||
this.props.updatedAt = new Date();
|
||
}
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Attacker holding a victim refresh token before password recovery. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed.
|
||
|
||
- **Attacker:** Attacker holding a victim refresh token before password recovery
|
||
|
||
- **Entry point:** apps/backend/src/application/auth/auth.service.ts
|
||
|
||
#### Severity
|
||
|
||
**Medium** — Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Store session/token version or passwordChangedAt and check it for every refresh/access token; increment/revoke all sessions on password recovery and offer revocation on ordinary password change.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-11"></a>
|
||
|
||
### [11] Une clé SMTP figure dans un fichier suivi
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | medium |
|
||
| Confidence | medium |
|
||
| Confidence rationale | Traçage statique du code courant. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-798 |
|
||
| Affected lines | docker/docker-compose.full.yml:137 |
|
||
|
||
#### Summary
|
||
|
||
Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment.
|
||
|
||
#### Root Cause
|
||
|
||
Credential is inline rather than secret reference. Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment.
|
||
|
||
**Source 1** — `docker/docker-compose.full.yml:137`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
SMTP_PASS: [REDACTED]
|
||
```
|
||
|
||
#### Validation
|
||
|
||
Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment. Contre-preuves : Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `docker/docker-compose.full.yml:137`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
SMTP_PASS: [REDACTED]
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment.
|
||
|
||
- **Source:** Anyone who obtains repository/configuration content
|
||
|
||
- **Sink:** docker/docker-compose.full.yml
|
||
|
||
**Source 1** — `docker/docker-compose.full.yml:137`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
SMTP_PASS: [REDACTED]
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Anyone who obtains repository/configuration content. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential.
|
||
|
||
- **Attacker:** Anyone who obtains repository/configuration content
|
||
|
||
- **Entry point:** docker/docker-compose.full.yml
|
||
|
||
#### Severity
|
||
|
||
**Medium** — Format fournisseur confirmé, mais validité non testée. Un lecteur du dépôt peut obtenir la clé ; usage abusif possible si elle est toujours active. Valeur masquée.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Revoke/rotate provider credential, remove literal from current tracked configuration and source it through secret injection; assess distribution without exposing secret.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-12"></a>
|
||
|
||
### [12] Les exports CSV conservent les formules injectées
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | low |
|
||
| Confidence | medium |
|
||
| Confidence rationale | Traçage statique du code courant. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-1236 |
|
||
| Affected lines | apps/backend/src/application/controllers/users.controller.ts:266-273, apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347, apps/frontend/src/components/ExportButton.tsx:65-80 |
|
||
|
||
#### Summary
|
||
|
||
UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active.
|
||
|
||
#### Root Cause
|
||
|
||
CSV quote escaping is not formula neutralization. UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active.
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:266-273`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Update fields
|
||
if (dto.firstName) {
|
||
user.updateFirstName(dto.firstName);
|
||
}
|
||
|
||
if (dto.lastName) {
|
||
user.updateLastName(dto.lastName);
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
<ExportButton
|
||
data={allUsers}
|
||
filename={t('exportFilename')}
|
||
columns={[
|
||
{ key: 'firstName', label: t('export.firstName') },
|
||
{ key: 'lastName', label: t('export.lastName') },
|
||
{ key: 'email', label: t('export.email') },
|
||
```
|
||
|
||
**Source 3** — `apps/frontend/src/components/ExportButton.tsx:65-80`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const generateCSV = (): string => {
|
||
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
|
||
|
||
const rows = data.map(row => {
|
||
return columns
|
||
.map(col => {
|
||
const value = getNestedValue(row, col.key as string);
|
||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
||
return `"${formattedValue.replace(/"/g, '""')}"`;
|
||
})
|
||
.join(';');
|
||
});
|
||
|
||
return [headers, ...rows].join('\n');
|
||
};
|
||
|
||
```
|
||
|
||
#### Validation
|
||
|
||
UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active. Contre-preuves : Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:266-273`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Update fields
|
||
if (dto.firstName) {
|
||
user.updateFirstName(dto.firstName);
|
||
}
|
||
|
||
if (dto.lastName) {
|
||
user.updateLastName(dto.lastName);
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
<ExportButton
|
||
data={allUsers}
|
||
filename={t('exportFilename')}
|
||
columns={[
|
||
{ key: 'firstName', label: t('export.firstName') },
|
||
{ key: 'lastName', label: t('export.lastName') },
|
||
{ key: 'email', label: t('export.email') },
|
||
```
|
||
|
||
**Source 3** — `apps/frontend/src/components/ExportButton.tsx:65-80`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const generateCSV = (): string => {
|
||
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
|
||
|
||
const rows = data.map(row => {
|
||
return columns
|
||
.map(col => {
|
||
const value = getNestedValue(row, col.key as string);
|
||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
||
return `"${formattedValue.replace(/"/g, '""')}"`;
|
||
})
|
||
.join(';');
|
||
});
|
||
|
||
return [headers, ...rows].join('\n');
|
||
};
|
||
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active.
|
||
|
||
- **Source:** Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software
|
||
|
||
- **Sink:** apps/frontend/src/components/ExportButton.tsx
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:266-273`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Update fields
|
||
if (dto.firstName) {
|
||
user.updateFirstName(dto.firstName);
|
||
}
|
||
|
||
if (dto.lastName) {
|
||
user.updateLastName(dto.lastName);
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
<ExportButton
|
||
data={allUsers}
|
||
filename={t('exportFilename')}
|
||
columns={[
|
||
{ key: 'firstName', label: t('export.firstName') },
|
||
{ key: 'lastName', label: t('export.lastName') },
|
||
{ key: 'email', label: t('export.email') },
|
||
```
|
||
|
||
**Source 3** — `apps/frontend/src/components/ExportButton.tsx:65-80`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
const generateCSV = (): string => {
|
||
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
|
||
|
||
const rows = data.map(row => {
|
||
return columns
|
||
.map(col => {
|
||
const value = getNestedValue(row, col.key as string);
|
||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
||
return `"${formattedValue.replace(/"/g, '""')}"`;
|
||
})
|
||
.join(';');
|
||
});
|
||
|
||
return [headers, ...rows].join('\n');
|
||
};
|
||
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced.
|
||
|
||
- **Attacker:** Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software
|
||
|
||
- **Entry point:** apps/backend/src/application/controllers/users.controller.ts
|
||
|
||
#### Severity
|
||
|
||
**Low** — Attaque limitée à des collègues et nécessitant une ouverture dans un tableur qui interprète les formules. Aucune exécution système ni exfiltration automatique démontrée.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Neutralize formula-leading strings in centralized CSV serializer; preserve typed-string behavior for XLSX/XML and add export-focused regression tests.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-13"></a>
|
||
|
||
### [13] Les dossiers des collègues sont accessibles sans rôle de gestion
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | low |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-862 |
|
||
| Affected lines | apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321, apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335, apps/backend/src/application/services/csv-booking.service.ts:1200-1221, apps/backend/src/application/services/csv-booking.service.ts:685-697 |
|
||
|
||
#### Summary
|
||
|
||
A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately.
|
||
|
||
#### Root Cause
|
||
|
||
Owner-only CSV booking visibility can be expanded to organization scope only for managers/admins A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately.
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Get('organization/all')
|
||
@UseGuards(JwtAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({
|
||
summary: 'Get organization bookings',
|
||
description:
|
||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||
})
|
||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Get('organization/all')
|
||
@UseGuards(JwtAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({
|
||
summary: 'Get organization bookings',
|
||
description:
|
||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||
})
|
||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||
@ApiResponse({
|
||
status: 200,
|
||
description: 'Organization bookings retrieved successfully',
|
||
type: CsvBookingListResponseDto,
|
||
})
|
||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||
async getOrganizationBookings(
|
||
@Request() req: any,
|
||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||
): Promise<CsvBookingListResponseDto> {
|
||
const organizationId = req.user.organizationId;
|
||
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/csv-booking.service.ts:1200-1221`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
page,
|
||
limit,
|
||
totalPages: Math.ceil(bookings.length / limit),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get bookings for an organization (paginated)
|
||
*/
|
||
async getOrganizationBookings(
|
||
organizationId: string,
|
||
page: number = 1,
|
||
limit: number = 10
|
||
): Promise<CsvBookingListResponseDto> {
|
||
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||
|
||
// Simple pagination (in-memory)
|
||
const start = (page - 1) * limit;
|
||
const end = start + limit;
|
||
const paginatedBookings = bookings.slice(start, end);
|
||
|
||
return {
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:685-697`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Verify user owns this booking OR is the assigned carrier
|
||
const isOwner = booking.userId === userId;
|
||
const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId;
|
||
|
||
if (!isOwner && !isAssignedCarrier) {
|
||
throw new NotFoundException(`Booking with ID ${id} not found`);
|
||
}
|
||
|
||
return this.toResponseDto(booking);
|
||
}
|
||
|
||
/**
|
||
* Get booking by confirmation token (public endpoint)
|
||
```
|
||
|
||
#### Validation
|
||
|
||
A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Contre-preuves : Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Get('organization/all')
|
||
@UseGuards(JwtAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({
|
||
summary: 'Get organization bookings',
|
||
description:
|
||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||
})
|
||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Get('organization/all')
|
||
@UseGuards(JwtAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({
|
||
summary: 'Get organization bookings',
|
||
description:
|
||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||
})
|
||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||
@ApiResponse({
|
||
status: 200,
|
||
description: 'Organization bookings retrieved successfully',
|
||
type: CsvBookingListResponseDto,
|
||
})
|
||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||
async getOrganizationBookings(
|
||
@Request() req: any,
|
||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||
): Promise<CsvBookingListResponseDto> {
|
||
const organizationId = req.user.organizationId;
|
||
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/csv-booking.service.ts:1200-1221`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
page,
|
||
limit,
|
||
totalPages: Math.ceil(bookings.length / limit),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get bookings for an organization (paginated)
|
||
*/
|
||
async getOrganizationBookings(
|
||
organizationId: string,
|
||
page: number = 1,
|
||
limit: number = 10
|
||
): Promise<CsvBookingListResponseDto> {
|
||
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||
|
||
// Simple pagination (in-memory)
|
||
const start = (page - 1) * limit;
|
||
const end = start + limit;
|
||
const paginatedBookings = bookings.slice(start, end);
|
||
|
||
return {
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:685-697`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Verify user owns this booking OR is the assigned carrier
|
||
const isOwner = booking.userId === userId;
|
||
const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId;
|
||
|
||
if (!isOwner && !isAssignedCarrier) {
|
||
throw new NotFoundException(`Booking with ID ${id} not found`);
|
||
}
|
||
|
||
return this.toResponseDto(booking);
|
||
}
|
||
|
||
/**
|
||
* Get booking by confirmation token (public endpoint)
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately.
|
||
|
||
- **Source:** Authenticated USER or VIEWER in organization with other users bookings
|
||
|
||
- **Sink:** apps/backend/src/application/controllers/csv-bookings.controller.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Get('organization/all')
|
||
@UseGuards(JwtAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({
|
||
summary: 'Get organization bookings',
|
||
description:
|
||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||
})
|
||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
@Get('organization/all')
|
||
@UseGuards(JwtAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({
|
||
summary: 'Get organization bookings',
|
||
description:
|
||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||
})
|
||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||
@ApiResponse({
|
||
status: 200,
|
||
description: 'Organization bookings retrieved successfully',
|
||
type: CsvBookingListResponseDto,
|
||
})
|
||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||
async getOrganizationBookings(
|
||
@Request() req: any,
|
||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||
): Promise<CsvBookingListResponseDto> {
|
||
const organizationId = req.user.organizationId;
|
||
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/services/csv-booking.service.ts:1200-1221`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
page,
|
||
limit,
|
||
totalPages: Math.ceil(bookings.length / limit),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get bookings for an organization (paginated)
|
||
*/
|
||
async getOrganizationBookings(
|
||
organizationId: string,
|
||
page: number = 1,
|
||
limit: number = 10
|
||
): Promise<CsvBookingListResponseDto> {
|
||
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||
|
||
// Simple pagination (in-memory)
|
||
const start = (page - 1) * limit;
|
||
const end = start + limit;
|
||
const paginatedBookings = bookings.slice(start, end);
|
||
|
||
return {
|
||
```
|
||
|
||
**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:685-697`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Verify user owns this booking OR is the assigned carrier
|
||
const isOwner = booking.userId === userId;
|
||
const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId;
|
||
|
||
if (!isOwner && !isAssignedCarrier) {
|
||
throw new NotFoundException(`Booking with ID ${id} not found`);
|
||
}
|
||
|
||
return this.toResponseDto(booking);
|
||
}
|
||
|
||
/**
|
||
* Get booking by confirmation token (public endpoint)
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
Authenticated USER or VIEWER in organization with other users bookings. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads.
|
||
|
||
- **Attacker:** Authenticated USER or VIEWER in organization with other users bookings
|
||
|
||
- **Entry point:** apps/backend/src/application/controllers/csv-bookings.controller.ts
|
||
|
||
#### Severity
|
||
|
||
**Low** — A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Apply RolesGuard and manager/admin roles to organization listing/statistics or explicitly redesign and document CSV visibility.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
<a id="finding-14"></a>
|
||
|
||
### [14] Un manager peut rétrograder un administrateur de son organisation
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Severity | low |
|
||
| Confidence | high |
|
||
| Confidence rationale | Traçage statique du code courant. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation. |
|
||
| Category | Authorization / security control |
|
||
| CWE | CWE-863 |
|
||
| Affected lines | apps/backend/src/application/controllers/users.controller.ts:256-264, apps/backend/src/application/controllers/users.controller.ts:257-279, apps/backend/src/application/controllers/users.controller.ts:396-400 |
|
||
|
||
#### Summary
|
||
|
||
Manager invokes PATCH /users/\<admin-uuid\> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration.
|
||
|
||
#### Root Cause
|
||
|
||
Managers must not alter platform administrator privileges/status Manager invokes PATCH /users/\<admin-uuid\> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration.
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:256-264`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Authorization: Only ADMIN can assign ADMIN role
|
||
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
||
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
||
}
|
||
|
||
// Authorization: Managers can only update users in their own organization
|
||
if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {
|
||
throw new ForbiddenException('You can only update users in your own organization');
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:257-279`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
||
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
||
}
|
||
|
||
// Authorization: Managers can only update users in their own organization
|
||
if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {
|
||
throw new ForbiddenException('You can only update users in your own organization');
|
||
}
|
||
|
||
// Update fields
|
||
if (dto.firstName) {
|
||
user.updateFirstName(dto.firstName);
|
||
}
|
||
|
||
if (dto.lastName) {
|
||
user.updateLastName(dto.lastName);
|
||
}
|
||
|
||
if (dto.role) {
|
||
const domainRole = dto.role as unknown as DomainUserRole;
|
||
user.updateRole(domainRole);
|
||
}
|
||
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/users.controller.ts:396-400`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
|
||
// Fetch users from current user's organization
|
||
this.logger.log(
|
||
`[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}`
|
||
);
|
||
```
|
||
|
||
#### Validation
|
||
|
||
Manager invokes PATCH /users/\<admin-uuid\> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Contre-preuves : Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation.
|
||
|
||
Validation method: static source trace
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:256-264`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Authorization: Only ADMIN can assign ADMIN role
|
||
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
||
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
||
}
|
||
|
||
// Authorization: Managers can only update users in their own organization
|
||
if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {
|
||
throw new ForbiddenException('You can only update users in your own organization');
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:257-279`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
||
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
||
}
|
||
|
||
// Authorization: Managers can only update users in their own organization
|
||
if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {
|
||
throw new ForbiddenException('You can only update users in your own organization');
|
||
}
|
||
|
||
// Update fields
|
||
if (dto.firstName) {
|
||
user.updateFirstName(dto.firstName);
|
||
}
|
||
|
||
if (dto.lastName) {
|
||
user.updateLastName(dto.lastName);
|
||
}
|
||
|
||
if (dto.role) {
|
||
const domainRole = dto.role as unknown as DomainUserRole;
|
||
user.updateRole(domainRole);
|
||
}
|
||
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/users.controller.ts:396-400`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
|
||
// Fetch users from current user's organization
|
||
this.logger.log(
|
||
`[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}`
|
||
);
|
||
```
|
||
|
||
Limitations:
|
||
- Pas d’exécution du produit, de test de charge ni d’exploitation réseau.
|
||
|
||
#### Dataflow
|
||
|
||
Manager invokes PATCH /users/\<admin-uuid\> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration.
|
||
|
||
- **Source:** MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID
|
||
|
||
- **Sink:** apps/backend/src/application/controllers/users.controller.ts
|
||
|
||
**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:256-264`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
// Authorization: Only ADMIN can assign ADMIN role
|
||
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
||
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
||
}
|
||
|
||
// Authorization: Managers can only update users in their own organization
|
||
if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {
|
||
throw new ForbiddenException('You can only update users in your own organization');
|
||
}
|
||
```
|
||
|
||
**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:257-279`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
|
||
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
|
||
}
|
||
|
||
// Authorization: Managers can only update users in their own organization
|
||
if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {
|
||
throw new ForbiddenException('You can only update users in your own organization');
|
||
}
|
||
|
||
// Update fields
|
||
if (dto.firstName) {
|
||
user.updateFirstName(dto.firstName);
|
||
}
|
||
|
||
if (dto.lastName) {
|
||
user.updateLastName(dto.lastName);
|
||
}
|
||
|
||
if (dto.role) {
|
||
const domainRole = dto.role as unknown as DomainUserRole;
|
||
user.updateRole(domainRole);
|
||
}
|
||
|
||
```
|
||
|
||
**Source 3** — `apps/backend/src/application/controllers/users.controller.ts:396-400`
|
||
|
||
Étape du parcours source décrit dans la cause et la validation.
|
||
|
||
```
|
||
|
||
// Fetch users from current user's organization
|
||
this.logger.log(
|
||
`[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}`
|
||
);
|
||
```
|
||
|
||
#### Reachability
|
||
|
||
MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation.
|
||
|
||
- **Attacker:** MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID
|
||
|
||
- **Entry point:** apps/backend/src/application/controllers/users.controller.ts
|
||
|
||
#### Severity
|
||
|
||
**Low** — Manager invokes PATCH /users/\<admin-uuid\> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation.
|
||
|
||
Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé.
|
||
|
||
#### Remediation
|
||
|
||
Reject any non-admin update whose target currently has ADMIN role; enforce explicit actor/target role hierarchy before field changes.
|
||
|
||
Tests:
|
||
- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées.
|
||
|
||
Preventive controls:
|
||
- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource.
|
||
|
||
## Reviewed Surfaces
|
||
|
||
| Surface | Risk Area | Outcome | Notes |
|
||
| --- | --- | --- | --- |
|
||
| Authentification, récupération et WebSockets | Sessions | Reported | JWT HTTP vérifie le type access et le compte actif ; inscription liée à invitation vérifiée. Bypass WebSocket, sessions après reset et secrets dans logs confirmés. |
|
||
| Organisations et rôles CSV | Isolation et permissions | Reported | Contrôle inter-organisations cassé par casse du rôle ; liste CSV sans rôle et mutations VIEWER. Les mutations individuelles CSV vérifient le propriétaire. |
|
||
| Liens transporteurs et documents | Autorité et stockage | Reported | Jeton divulgué au client. Téléchargements vérifient ACCEPTED, mot de passe si configuré et appartenance du document ; PDFKit rend du texte sans navigateur ni chargement HTML. |
|
||
| Souscriptions Stripe | Intégrité financière | Reported | Signatures vérifiées ; résiliation bloquée par licences. Sync ne compare pas metadata.organizationId mais UNIQUE stripe_subscription_id bloque la réassociation normale ; scénario de course non confirmé. |
|
||
| MCP et assistant IA | Outils et données | No issue found | Rôle/offre contrôlés à chaque invocation ; acteur lié à session, SQL des conversations paramétré avec user_id, quota atomique et tours IA bornés. |
|
||
| Frontend et exports | XSS et CSV | Reported | Redirection brute vérifiée dans Next installé. Formules CSV non neutralisées. Contexte actif avec cookies HttpOnly, distinct de l’ancien client localStorage. |
|
||
| Logs et déploiements | Secrets et réseau | Reported | Clé SMTP littérale masquée, validité inconnue. Logs de production internes avec NetworkPolicy ; Compose dev expose 3100/3200 sans authentification, sans preuve d’exposition Internet. |
|
||
| Persistance, GDPR et configuration | Injection et données | No issue found | Requêtes recherche/GDPR/conversations paramétrées ; export GDPR exclut hash mot de passe, TOTP et hash de clé. DATABASE_SSL ignoré par runtime/startup et validation de certificat désactivée dans CLI ; buckets distincts, état réel externe non testé. 95 fichiers suivis lus intégralement ; lectures ciblées supplémentaires non comptées. |
|
||
| Webhook SSRF à l’enregistrement | Requêtes sortantes | Rejected | WebhookService poste vers la destination enregistrée sans filtre IP, mais les DTO CreateWebhookDto/UpdateWebhookDto n’ont aucun décorateur de validation ; la validation globale whitelist + forbidNonWhitelisted de main.ts rejette leurs champs. Aucune voie actuelle de création par un attaquant n’a été établie. Corriger les DTO doit impérativement ajouter aussi une politique de destination. |
|
||
|
||
## Open Questions And Follow Up
|
||
|
||
- Compléter les fichiers non lus intégralement avant de qualifier la couverture d’exhaustive.
|
||
- Vérifier rotation SMTP et plafond multipart au proxy sans réutiliser le secret.
|
||
- Vérifier liaison Stripe session/organisation et droits des abonnements UNPAID/PAUSED : plusieurs consommateurs lisent seulement plan.
|
||
- Aligner DATABASE_SSL, validation TLS SMTP/SQL et buckets provisionnés/ACL.
|
||
- Agents interrompus par limites d’usage. Pages/composants frontend, migrations/scripts, adaptateurs transporteurs et portions CSV restent non lus intégralement ; couverture non exhaustive.
|
||
- Follow-up prompt: Review deferred unit remaining-source and close its stated proof gap.
|
||
- syncFromStripe ne lie pas metadata.organizationId ; UNIQUE stripe_subscription_id bloque le scénario normal. Course avant webhook ou ancien abonnement non lié non validés.
|
||
- Follow-up prompt: Review deferred unit subscription-sync-binding and close its stated proof gap.
|
||
- Recovering interrupted investigator result for validation
|
||
- Follow-up prompt: Review deferred unit login-redirect and close its stated proof gap.
|
||
- Recovering interrupted baseline result
|
||
- Follow-up prompt: Review deferred unit notification-owner and close its stated proof gap.
|
||
- Recovering interrupted baseline result
|
||
- Follow-up prompt: Review deferred unit carrier-token and close its stated proof gap.
|