Security
Implementation spec for plan phases 1, 3 and 4
Feeds plan phases 1, 3 and 4. The June IDOR criticals (deal ownership, discussion/message participant checks) are fixed and verified — removed from this doc. What remains, in implementation order:
1. Fail fast on missing secrets (phase 1)
JWT_SECRET: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(),
otherwise: Joi.string().default('marketplace-ts-dev-secret'),
}),Then delete every runtime fallback — grep gate: grep -rn "default-secret" apps/back → 0. Affected: auth.service.ts:51 (login()), messages.gateway.ts:58 (handleConnection()). Both read via this.configService.get<string>('JWT_SECRET')! (validation guarantees presence). Same when(NODE_ENV) treatment for DATABASE_URL (env.config.ts:4).
2. Stop putting the password hash on request.user (phase 3)
jwt.strategy.ts:32-35 returns the full Prisma user — including password. Fix at the source:
async validate(payload: JwtPayload): Promise<RequestUser | null> {
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, name: true, image: true, role: true },
});
return user; // null → 401 from passport
}With RequestUser defined once in common/types/request-user.ts (see Backend quality §1) and every @CurrentUser() user: any replaced. Audit that no controller returns a raw user object.
3. OAuth callback: token out of the query string (phase 4)
auth.controller.ts:88,111 redirects to ?token=${accessToken} — query strings land in browser history, proxy/server logs and Referer headers. Minimal fix, no new infra:
return res.redirect(`${frontendUrl}/auth/callback#token=${user.accessToken}`);Frontend (auth)/callback/page.tsx reads window.location.hash instead of searchParams, then history.replaceState to scrub — same PR. (Proper fix later: one-time short-lived code exchanged via POST.)
4. Remaining object-level gaps (phase 4)
deleteImagemembership check (deals.service.ts:306-322): beforeuploadService.deleteImage(url), require the URL to be present in the deal'simagesarray in addition to the existingisS3Urlcheck — otherwise an authenticated user can delete arbitrary bucket objects via their own deal id.publishtransition guard (deals.controller.ts:112-120): onlyDRAFT | DECLINED→PENDING; reject re-publishingPUBLISHED/SOLD/ARCHIVEDwithBadRequestException.startDiscussion(discussions.controller.ts:62-104): require the deal to bePUBLISHED; rejectdeal.userId === user.id(self-discussion) withBadRequestException. Move the whole find-or-create intoDiscussionsService.startDiscussion(dealId, buyer)— the controller currently queriesprismadirectly (:83).- Messages 403 (
messages.controller.ts:31): the participant check throwsnew Error('Not a participant...')→ HTTP 500. UseForbiddenException. - Move ownership checks into services:
assertOwnerand the inlinemarkSold/discussion checks live in controllers today; relocate per the convention (oneassertCanModify(id, user)per service, admin bypass inside it) so future callers can't forget them.
5. Bootstrap hardening — main.ts (phase 4)
import helmet from 'helmet'; // pnpm --filter back add helmet
app.use(helmet());
app.enableShutdownHooks(); // graceful Prisma/Redis disconnect
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true, // reject, don't silently strip
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);forbidNonWhitelisted may surface frontend payloads sending extra fields — run the e2e suite after enabling and fix senders, not the flag. Replace the bootstrap console.log with Logger.log.
6. Rate limiting: replace the hand-rolled implementation (phase 4)
Delete recordLoginAttempt/checkRateLimit (auth.service.ts:225-262 — per-email only, checked after the user lookup, state in Postgres) and the RateLimit Prisma model (drop in the phase-5 migration). Replace with @nestjs/throttler:
// app.module.ts
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 100 }]),
{ provide: APP_GUARD, useClass: ThrottlerGuard },
// auth.controller.ts — strict limits on abuse-prone endpoints
@Throttle({ default: { ttl: 60_000, limit: 5 } }) // login
@Throttle({ default: { ttl: 3_600_000, limit: 5 } }) // register
@Throttle({ default: { ttl: 3_600_000, limit: 3 } }) // password-reset requestBehind nginx set trust proxy so throttling keys on the real client IP. In-memory storage is fine single-instance; Redis adapter only when >1 replica.
7. WebSocket gateway (phase 4)
messages.gateway.ts: restrict the @WebSocketGateway() CORS origin to FRONTEND_URL (same as HTTP CORS), and replace its console.* with Logger (connection logs currently print user ids).
8. Smaller items (opportunistic)
- First-admin bootstrap: "first registered user becomes ADMIN" (register + OAuth) is race-prone — replace with an
ADMIN_EMAILenv-driven seed/promotion; registration always createsUSER. - OAuth account linking:
validateOAuthUserlinks by email — require the provider'semail_verifiedclaim before linking to prevent account takeover via a lax provider. - JWT lifetime: 7 days with no revocation is long; when reworking auth, shorten the access token (≤1h) + refresh flow.
- SSRF note:
upload.service.ts uploadFromUrl()fetches arbitrary URLs server-side; today only admin flows reach it. It must never take user-supplied URLs; if that changes, add a private-IP blocklist + response size cap first. Leave a comment on the method. dump/dump.sqlin git: if it contains real user data, purge from history (git filter-repo) and rotate credentials; either way gitignoredump/.
Dependency vulnerabilities (phase 7 — but security-relevant)
pnpm audit --prod: critical handlebars + liquidjs and high nodemailer/lodash/html-minifier, all via @nestjs-modules/mailer@2 → bump major or consolidate on resend (already a direct dep). Also high: multer (<2.2.0, direct), socket.io-parser, path-to-regexp; web: next 16.2.1 → ≥16.2.5.