feat(security): helmet + rate limit + env hardening — 8.3

This commit is contained in:
reloop
2026-04-12 08:26:17 +09:00
parent eb0e07bab9
commit c36978c363
6 changed files with 70 additions and 23 deletions

View File

@@ -1,15 +1,13 @@
# ReLoop backend env
# Copy to .env and fill in.
DATABASE_URL="mysql://reloop:CHANGE_ME@localhost:3306/reloop"
# Database
DATABASE_URL="mysql://user:pass@localhost:3306/reloop_v2"
# JWT
JWT_SECRET="change-me-in-prod-really-long-random-string"
JWT_EXPIRES_IN="30d"
JWT_SECRET="change-me-to-a-long-random-string-32-chars-min"
JWT_EXPIRES_IN="7d"
# CORS
CORS_ORIGINS="https://reloop.nabomhalang.co.kr,http://localhost:3000"
# CORS (comma-separated origins)
CORS_ORIGINS="http://localhost:3000,https://reloop.nabomhalang.co.kr"
# Server
PORT=3001
NODE_ENV=production
# Node
NODE_ENV="production"
PORT=4000

View File

@@ -66,8 +66,7 @@ async function main() {
onboardedAt: new Date(),
},
});
// TODO(phase7): 프로덕션 seed 에서는 평문 비밀번호 stdout 출력 제거
console.log(` user: ${user.email} (id=${user.id}) password=${DEMO_PASSWORD}`);
console.log(` user: ${user.email} (id=${user.id})`);
for (const s of SUBJECTS) {
const subject = await prisma.subject.upsert({

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { HttpException, HttpStatus, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module';
@@ -14,10 +15,22 @@ import { DashboardModule } from './dashboard/dashboard.module';
import { StatsModule } from './stats/stats.module';
import { ProblemSetsModule } from './problem-sets/problem-sets.module';
class AppThrottlerGuard extends ThrottlerGuard {
protected async throwThrottlingException(): Promise<void> {
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: '요청이 너무 많아요. 잠시 후 다시 시도해주세요.',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 120 }]),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }]),
PrismaModule,
HealthModule,
AuthModule,
@@ -31,5 +44,11 @@ import { ProblemSetsModule } from './problem-sets/problem-sets.module';
StatsModule,
ProblemSetsModule,
],
providers: [
{
provide: APP_GUARD,
useClass: AppThrottlerGuard,
},
],
})
export class AppModule {}

View File

@@ -5,6 +5,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto, RegisterDto } from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
@@ -16,11 +17,13 @@ export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
@Throttle({ default: { ttl: 60_000, limit: 10 } })
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
@Post('login')
@Throttle({ default: { ttl: 60_000, limit: 10 } })
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}

View File

@@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import express from 'express';
import helmet from 'helmet';
import { join } from 'path';
import { AppModule } from './app.module';
@@ -10,22 +11,51 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: false,
});
const isDevelopment = process.env.NODE_ENV !== 'production';
// CORS — allow the frontend origin explicitly (covers prod + dev)
const allowedOrigins = (
process.env.CORS_ORIGINS ??
'https://reloop.nabomhalang.co.kr,http://localhost:3000'
(isDevelopment ? 'http://localhost:3000' : '')
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const expressApp = app.getHttpAdapter().getInstance();
expressApp.use(express.json({ limit: '5mb' }));
expressApp.use(express.urlencoded({ extended: true, limit: '5mb' }));
app.enableCors({
origin: allowedOrigins,
origin: allowedOrigins.length > 0 ? allowedOrigins : false,
credentials: true,
});
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
"default-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", 'data:', 'blob:'],
"connect-src": ["'self'", 'https:'],
},
},
crossOriginResourcePolicy: false,
hsts: {
maxAge: 31_536_000,
includeSubDomains: true,
preload: true,
},
}),
);
expressApp.use((_req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()',
);
next();
});
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
prefix: '/uploads/',
});

View File

@@ -1,3 +1 @@
# Build-time env var — baked into the client bundle by Next.js.
# Must be set BEFORE running `pnpm build`, not at runtime.
NEXT_PUBLIC_API_URL=https://reloop-api.nabomhalang.co.kr/api
NEXT_PUBLIC_API_URL="https://reloop-api.nabomhalang.co.kr/api"