security: enforce TenantGuard and ThrottlerGuard globally, lock down CORS

Both guards were fully implemented but never actually wired in -- Nest
doesn't enforce a guard just because its module is imported, it needs
an explicit APP_GUARD registration. Registered both globally so every
new controller is deny-by-default and rate-limited unless it opts out.

Added a @Public() decorator (checked via Reflector in TenantGuard) for
routes that legitimately have no session, applied it to /health so the
global guard doesn't break it.

CORS was wide open (enableCors() with no origin restriction, effectively
allow-any-origin). Now reads an explicit CORS_ORIGINS allowlist from env,
defaulting to localhost:3000 for local dev.
This commit is contained in:
valentinbvro 2026-07-21 19:48:17 +02:00
parent 5656f1cc1c
commit 5a1206118a
6 changed files with 36 additions and 2 deletions

View file

@ -1,6 +1,9 @@
NODE_ENV=development
PORT=3001
# Comma-separated list of allowed browser origins for CORS -- never use a wildcard in production
CORS_ORIGINS=http://localhost:3000
# Supabase (CEO-OS project, Coolify)
DATABASE_URL=
SUPABASE_URL=

View file

@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { BullModule } from '@nestjs/bullmq';
import { LoggerModule } from 'nestjs-pino';
import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { EventsModule } from './events/events.module';
import { TenantGuard } from './auth/tenant.guard';
function parseRedisConnection(redisUrl: string | undefined) {
if (!redisUrl) {
@ -31,5 +33,9 @@ function parseRedisConnection(redisUrl: string | undefined) {
EventsModule,
],
controllers: [HealthController],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: TenantGuard },
],
})
export class AppModule {}

View file

@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
// Marchez explicit rutele fara sesiune (health checks, webhooks semnate separat).
// Fara acest decorator, TenantGuard respinge implicit orice request -- deny-by-default,
// per blueprint 11.3, in loc sa se bazeze pe fiecare controller nou sa adauge garda manual.
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -1,5 +1,7 @@
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { IS_PUBLIC_KEY } from './public.decorator';
export interface AuthenticatedSession {
userId: string;
@ -15,10 +17,22 @@ interface RequestWithSession extends Request {
* Blueprint 11.3: tenant_id vine intotdeauna din sesiune, niciodata din
* body/query/params. Orice request fara sesiune valida, sau care incearca
* sa suprascrie tenant_id din client, este respins (deny-by-default).
* Inregistrata global (APP_GUARD) -- rutele publice trebuie sa foloseasca
* explicit @Public(), altfel raman blocate implicit.
*/
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithSession>();
const session = request.session;

View file

@ -1,7 +1,9 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/public.decorator';
@Controller('health')
export class HealthController {
@Public()
@Get()
check() {
return { status: 'ok', service: 'ceo-api', timestamp: new Date().toISOString() };

View file

@ -9,7 +9,8 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
app.use(helmet());
app.enableCors();
const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3000').split(',').map((o) => o.trim());
app.enableCors({ origin: allowedOrigins, credentials: true });
const config = new DocumentBuilder()
.setTitle('CEO OS API')