KwadMarket Docs
Remediation Plan

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)

config/env.config.ts — replace the JWT_SECRET / DATABASE_URL lines
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:

jwt.strategy.ts
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)

  • deleteImage membership check (deals.service.ts:306-322): before uploadService.deleteImage(url), require the URL to be present in the deal's images array in addition to the existing isS3Url check — otherwise an authenticated user can delete arbitrary bucket objects via their own deal id.
  • publish transition guard (deals.controller.ts:112-120): only DRAFT | DECLINEDPENDING; reject re-publishing PUBLISHED/SOLD/ARCHIVED with BadRequestException.
  • startDiscussion (discussions.controller.ts:62-104): require the deal to be PUBLISHED; reject deal.userId === user.id (self-discussion) with BadRequestException. Move the whole find-or-create into DiscussionsService.startDiscussion(dealId, buyer) — the controller currently queries prisma directly (:83).
  • Messages 403 (messages.controller.ts:31): the participant check throws new Error('Not a participant...') → HTTP 500. Use ForbiddenException.
  • Move ownership checks into services: assertOwner and the inline markSold/discussion checks live in controllers today; relocate per the convention (one assertCanModify(id, user) per service, admin bypass inside it) so future callers can't forget them.

5. Bootstrap hardening — main.ts (phase 4)

main.ts
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 request

Behind 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_EMAIL env-driven seed/promotion; registration always creates USER.
  • OAuth account linking: validateOAuthUser links by email — require the provider's email_verified claim 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.sql in git: if it contains real user data, purge from history (git filter-repo) and rotate credentials; either way gitignore dump/.

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.

On this page