security: add helmet, rate limiting, webhook auth, fix token storage, restrict hard deletes

- Add helmet for HTTP security headers (CSP, HSTS, X-Frame-Options, etc.)
- Add @nestjs/throttler for rate limiting (100 req/60s per IP)
- Add shared secret validation on Signal webhook endpoint
- Remove JWT token from localStorage, use Auth0 SDK memory cache
  with async getAccessTokenSilently() in API interceptor
- Restrict hard delete (?hard=true) to ADMINISTRATOR role in service layer
- Replace exposed Anthropic API key with placeholder in .env

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 18:30:14 +01:00
parent 8e88880838
commit 934464bf8e
18 changed files with 132 additions and 55 deletions

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
@@ -26,6 +27,12 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
envFilePath: '.env',
}),
// Rate limiting: 100 requests per 60 seconds per IP
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 100,
}]),
// Core modules
PrismaModule,
AuthModule,
@@ -51,6 +58,11 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Apply rate limiting globally
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View File

@@ -220,8 +220,9 @@ export class DriversController {
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.driversService.remove(id, isHardDelete);
return this.driversService.remove(id, isHardDelete, user?.role);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDriverDto, UpdateDriverDto } from './dto';
@@ -78,7 +78,11 @@ export class DriversService {
});
}
async remove(id: string, hardDelete = false) {
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const driver = await this.findOne(id);
if (hardDelete) {

View File

@@ -13,6 +13,7 @@ import { EventsService } from './events.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
@@ -59,8 +60,9 @@ export class EventsController {
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.eventsService.remove(id, isHardDelete);
return this.eventsService.remove(id, isHardDelete, user?.role);
}
}

View File

@@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@@ -248,7 +249,11 @@ export class EventsService {
return this.enrichEventWithVips(updatedEvent);
}
async remove(id: string, hardDelete = false) {
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const event = await this.findOne(id);
if (hardDelete) {

View File

@@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { json, urlencoded } from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters';
@@ -9,6 +10,9 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Security headers
app.use(helmet());
// Increase body size limit for PDF attachments (base64 encoded)
app.use(json({ limit: '5mb' }));
app.use(urlencoded({ extended: true, limit: '5mb' }));

View File

@@ -6,11 +6,12 @@ import {
Body,
Param,
Query,
Req,
UseGuards,
Logger,
Res,
} from '@nestjs/common';
import { Response } from 'express';
import { Request, Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@@ -105,7 +106,14 @@ export class MessagesController {
*/
@Public()
@Post('webhook')
async handleWebhook(@Body() payload: SignalWebhookPayload) {
async handleWebhook(@Body() payload: SignalWebhookPayload, @Req() req: Request) {
// Validate webhook secret if configured
const secret = process.env.SIGNAL_WEBHOOK_SECRET;
if (secret && req.headers['x-webhook-secret'] !== secret) {
this.logger.warn('Webhook rejected: invalid or missing secret');
return { success: false, error: 'Unauthorized' };
}
this.logger.debug('Received Signal webhook:', JSON.stringify(payload));
try {

View File

@@ -13,6 +13,7 @@ import { VehiclesService } from './vehicles.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
@@ -56,8 +57,12 @@ export class VehiclesController {
@Delete(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(@Param('id') id: string, @Query('hard') hard?: string) {
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.vehiclesService.remove(id, isHardDelete);
return this.vehiclesService.remove(id, isHardDelete, user?.role);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
@@ -89,7 +89,11 @@ export class VehiclesService {
});
}
async remove(id: string, hardDelete = false) {
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const vehicle = await this.findOne(id);
if (hardDelete) {

View File

@@ -13,6 +13,7 @@ import { VipsService } from './vips.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AbilitiesGuard } from '../auth/guards/abilities.guard';
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateVipDto, UpdateVipDto } from './dto';
@Controller('vips')
@@ -49,9 +50,10 @@ export class VipsController {
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@CurrentUser() user?: any,
) {
// Only administrators can hard delete
const isHardDelete = hard === 'true';
return this.vipsService.remove(id, isHardDelete);
return this.vipsService.remove(id, isHardDelete, user?.role);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVipDto, UpdateVipDto } from './dto';
@@ -58,7 +58,11 @@ export class VipsService {
});
}
async remove(id: string, hardDelete = false) {
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const vip = await this.findOne(id);
if (hardDelete) {