Security

웹 애플리케이션 보안 모범 사례 완벽 가이드

OWASP Top 10을 기반으로 한 웹 애플리케이션 보안 모범 사례를 실전 예제와 함께 상세히 설명합니다. 인증, 인가, 데이터 보호, 취약점 방지 등을 다룹니다.

웹 애플리케이션 보안 모범 사례 완벽 가이드

소개

웹 애플리케이션 보안은 “나중에 하자”라고 미루다가 한 번 털리면 복구 비용이 커지는 영역입니다. OWASP Top 10을 기준으로, 우리가 서비스 배포 전에 꼭 점검하는 인증·인가·입력 검증·로그 설정 등을 코드 예제와 함께 정리했습니다.

OWASP Top 10 개요

2021년 OWASP Top 10 취약점:

  1. Broken Access Control
  2. Cryptographic Failures
  3. Injection
  4. Insecure Design
  5. Security Misconfiguration
  6. Vulnerable and Outdated Components
  7. Identification and Authentication Failures
  8. Software and Data Integrity Failures
  9. Security Logging and Monitoring Failures
  10. Server-Side Request Forgery (SSRF)

1. 인증 및 인가 (Authentication & Authorization)

강력한 비밀번호 정책

typescript
1import bcrypt from 'bcrypt';
2import { z } from 'zod';
3
4const passwordSchema = z.string()
5  .min(12, '비밀번호는 최소 12자 이상이어야 합니다')
6  .regex(/[A-Z]/, '대문자가 포함되어야 합니다')
7  .regex(/[a-z]/, '소문자가 포함되어야 합니다')
8  .regex(/[0-9]/, '숫자가 포함되어야 합니다')
9  .regex(/[^A-Za-z0-9]/, '특수문자가 포함되어야 합니다');
10
11async function hashPassword(password: string): Promise<string> {
12  const saltRounds = 12;
13  return await bcrypt.hash(password, saltRounds);
14}
15
16async function verifyPassword(
17  password: string,
18  hashedPassword: string
19): Promise<boolean> {
20  return await bcrypt.compare(password, hashedPassword);
21}

JWT 토큰 관리

typescript
1import jwt from 'jsonwebtoken';
2import crypto from 'crypto';
3
4const JWT_SECRET = process.env.JWT_SECRET!;
5const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;
6
7interface TokenPayload {
8  userId: string;
9  email: string;
10  role: string;
11}
12
13function generateAccessToken(payload: TokenPayload): string {
14  return jwt.sign(payload, JWT_SECRET, {
15    expiresIn: '15m',
16    issuer: 'your-app',
17    audience: 'your-app-users'
18  });
19}
20
21function generateRefreshToken(payload: TokenPayload): string {
22  return jwt.sign(payload, REFRESH_TOKEN_SECRET, {
23    expiresIn: '7d',
24    issuer: 'your-app',
25    audience: 'your-app-users'
26  });
27}
28
29function verifyToken(token: string): TokenPayload {
30  try {
31    return jwt.verify(token, JWT_SECRET) as TokenPayload;
32  } catch (error) {
33    throw new Error('Invalid token');
34  }
35}
36
37// 토큰 블랙리스트 관리
38class TokenBlacklist {
39  private blacklist = new Set<string>();
40  
41  add(token: string, expiresIn: number) {
42    this.blacklist.add(token);
43    setTimeout(() => {
44      this.blacklist.delete(token);
45    }, expiresIn * 1000);
46  }
47  
48  isBlacklisted(token: string): boolean {
49    return this.blacklist.has(token);
50  }
51}

Rate Limiting

typescript
1import rateLimit from 'express-rate-limit';
2import RedisStore from 'rate-limit-redis';
3import Redis from 'ioredis';
4
5const redis = new Redis(process.env.REDIS_URL);
6
7// 로그인 시도 제한
8export const loginLimiter = rateLimit({
9  store: new RedisStore({
10    client: redis,
11    prefix: 'rl:login:'
12  }),
13  windowMs: 15 * 60 * 1000, // 15분
14  max: 5, // 최대 5번 시도
15  message: '너무 많은 로그인 시도가 있었습니다. 15분 후 다시 시도해주세요.',
16  standardHeaders: true,
17  legacyHeaders: false,
18});
19
20// API 요청 제한
21export const apiLimiter = rateLimit({
22  store: new RedisStore({
23    client: redis,
24    prefix: 'rl:api:'
25  }),
26  windowMs: 60 * 1000, // 1분
27  max: 100, // 최대 100번 요청
28  message: '너무 많은 요청이 있었습니다. 잠시 후 다시 시도해주세요.',
29});

2. SQL Injection 방지

파라미터화된 쿼리

typescript
1import { Pool } from 'pg';
2
3const pool = new Pool({
4  connectionString: process.env.DATABASE_URL
5});
6
7// 나쁜 예: SQL Injection 취약
8async function getUserBad(email: string) {
9  const query = `SELECT * FROM users WHERE email = '${email}'`;
10  return pool.query(query);
11}
12
13// 좋은 예: 파라미터화된 쿼리
14async function getUserGood(email: string) {
15  const query = 'SELECT * FROM users WHERE email = $1';
16  return pool.query(query, [email]);
17}
18
19// ORM 사용 (TypeORM 예시)
20import { EntityRepository, Repository } from 'typeorm';
21import { User } from './entity/User';
22
23@EntityRepository(User)
24export class UserRepository extends Repository<User> {
25  async findByEmail(email: string): Promise<User | undefined> {
26    return this.findOne({ where: { email } });
27  }
28}

입력 검증

typescript
1import { z } from 'zod';
2import { sanitize } from 'sanitize-html';
3
4const emailSchema = z.string().email();
5const usernameSchema = z.string()
6  .min(3)
7  .max(20)
8  .regex(/^[a-zA-Z0-9_]+$/, '영문, 숫자, 언더스코어만 사용 가능합니다');
9
10function sanitizeInput(input: string): string {
11  return sanitize(input, {
12    allowedTags: [],
13    allowedAttributes: {}
14  });
15}
16
17function validateAndSanitize<T>(
18  schema: z.ZodSchema<T>,
19  data: unknown
20): T {
21  const validated = schema.parse(data);
22  if (typeof validated === 'string') {
23    return sanitizeInput(validated) as T;
24  }
25  return validated;
26}

3. XSS (Cross-Site Scripting) 방지

출력 인코딩

typescript
1import { escape } from 'html-escaper';
2
3// 서버 사이드 렌더링 시
4function renderUserContent(content: string): string {
5  return escape(content);
6}
7
8// React에서 자동 이스케이프
9function UserProfile({ name, bio }: { name: string; bio: string }) {
10  return (
11    <div>
12      <h1>{name}</h1> {/* 자동 이스케이프 */}
13      <p dangerouslySetInnerHTML={{ __html: sanitize(bio) }} />
14    </div>
15  );
16}

Content Security Policy (CSP)

typescript
1import helmet from 'helmet';
2
3app.use(helmet.contentSecurityPolicy({
4  directives: {
5    defaultSrc: ["'self'"],
6    scriptSrc: ["'self'", "'unsafe-inline'", "https://trusted-cdn.com"],
7    styleSrc: ["'self'", "'unsafe-inline'"],
8    imgSrc: ["'self'", "data:", "https:"],
9    connectSrc: ["'self'"],
10    fontSrc: ["'self'"],
11    objectSrc: ["'none'"],
12    mediaSrc: ["'self'"],
13    frameSrc: ["'none'"],
14  },
15}));

4. CSRF (Cross-Site Request Forgery) 방지

CSRF 토큰

typescript
1import csrf from 'csurf';
2import cookieParser from 'cookie-parser';
3
4app.use(cookieParser());
5app.use(csrf({ cookie: true }));
6
7app.get('/api/csrf-token', (req, res) => {
8  res.json({ csrfToken: req.csrfToken() });
9});
10
11app.post('/api/users', (req, res) => {
12  // CSRF 토큰 자동 검증
13  // 검증 실패 시 403 Forbidden
14  // 성공 시 요청 처리
15});

SameSite Cookie

typescript
1app.use(session({
2  secret: process.env.SESSION_SECRET!,
3  cookie: {
4    secure: true, // HTTPS에서만 전송
5    httpOnly: true, // JavaScript에서 접근 불가
6    sameSite: 'strict', // CSRF 방지
7    maxAge: 24 * 60 * 60 * 1000 // 24시간
8  }
9}));

5. 데이터 암호화

민감 정보 암호화

typescript
1import crypto from 'crypto';
2
3const ALGORITHM = 'aes-256-gcm';
4const KEY = crypto.scryptSync(process.env.ENCRYPTION_KEY!, 'salt', 32);
5
6function encrypt(text: string): { encrypted: string; iv: string; tag: string } {
7  const iv = crypto.randomBytes(16);
8  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
9  
10  let encrypted = cipher.update(text, 'utf8', 'hex');
11  encrypted += cipher.final('hex');
12  
13  const tag = cipher.getAuthTag();
14  
15  return {
16    encrypted,
17    iv: iv.toString('hex'),
18    tag: tag.toString('hex')
19  };
20}
21
22function decrypt(encrypted: string, iv: string, tag: string): string {
23  const decipher = crypto.createDecipheriv(
24    ALGORITHM,
25    KEY,
26    Buffer.from(iv, 'hex')
27  );
28  
29  decipher.setAuthTag(Buffer.from(tag, 'hex'));
30  
31  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
32  decrypted += decipher.final('utf8');
33  
34  return decrypted;
35}

HTTPS 강제

typescript
1// Express에서 HTTPS 리다이렉트
2app.use((req, res, next) => {
3  if (process.env.NODE_ENV === 'production') {
4    if (req.header('x-forwarded-proto') !== 'https') {
5      res.redirect(`https://${req.header('host')}${req.url}`);
6    } else {
7      next();
8    }
9  } else {
10    next();
11  }
12});

6. 의존성 보안

취약점 스캔

bash
1# npm audit
2npm audit
3npm audit fix
4
5# Snyk 사용
6npm install -g snyk
7snyk test
8snyk monitor
9
10# OWASP Dependency-Check
11dependency-check --project "My Project" --scan ./package-lock.json

자동 업데이트

json
1{
2  "scripts": {
3    "security:check": "npm audit --audit-level=moderate",
4    "security:fix": "npm audit fix",
5    "precommit": "npm run security:check"
6  },
7  "dependencies": {
8    "express": "^4.18.0" // ^를 사용하여 마이너 버전 자동 업데이트
9  }
10}

7. 로깅 및 모니터링

보안 이벤트 로깅

typescript
1import winston from 'winston';
2
3const securityLogger = winston.createLogger({
4  level: 'info',
5  format: winston.format.json(),
6  transports: [
7    new winston.transports.File({ filename: 'security.log' })
8  ]
9});
10
11function logSecurityEvent(
12  event: string,
13  details: Record<string, any>
14) {
15  securityLogger.info({
16    timestamp: new Date().toISOString(),
17    event,
18    ...details,
19    ip: details.ip,
20    userAgent: details.userAgent
21  });
22}
23
24// 사용 예시
25logSecurityEvent('FAILED_LOGIN', {
26  email: 'user@example.com',
27  ip: req.ip,
28  userAgent: req.get('user-agent'),
29  reason: 'Invalid password'
30});

이상 탐지

typescript
1class AnomalyDetector {
2  private loginAttempts = new Map<string, number[]>();
3  
4  detectAnomaly(ip: string, event: string): boolean {
5    const now = Date.now();
6    const attempts = this.loginAttempts.get(ip) || [];
7    
8    // 최근 5분간의 시도만 유지
9    const recentAttempts = attempts.filter(
10      time => now - time < 5 * 60 * 1000
11    );
12    
13    recentAttempts.push(now);
14    this.loginAttempts.set(ip, recentAttempts);
15    
16    // 5분간 10번 이상 시도 시 이상으로 판단
17    if (recentAttempts.length > 10) {
18      logSecurityEvent('ANOMALY_DETECTED', {
19        ip,
20        event,
21        attempts: recentAttempts.length
22      });
23      return true;
24    }
25    
26    return false;
27  }
28}

8. 파일 업로드 보안

typescript
1import multer from 'multer';
2import { extname } from 'path';
3
4const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.pdf'];
5const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
6
7const storage = multer.diskStorage({
8  destination: (req, file, cb) => {
9    cb(null, 'uploads/');
10  },
11  filename: (req, file, cb) => {
12    // 원본 파일명 사용하지 않고 안전한 이름 생성
13    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
14    const ext = extname(file.originalname);
15    cb(null, `file-${uniqueSuffix}${ext}`);
16  }
17});
18
19const fileFilter = (req: any, file: Express.Multer.File, cb: any) => {
20  const ext = extname(file.originalname).toLowerCase();
21  
22  if (!ALLOWED_EXTENSIONS.includes(ext)) {
23    return cb(new Error('허용되지 않은 파일 형식입니다'));
24  }
25  
26  // MIME 타입 검증
27  const allowedMimes = [
28    'image/jpeg',
29    'image/png',
30    'application/pdf'
31  ];
32  
33  if (!allowedMimes.includes(file.mimetype)) {
34    return cb(new Error('허용되지 않은 MIME 타입입니다'));
35  }
36  
37  cb(null, true);
38};
39
40const upload = multer({
41  storage,
42  limits: {
43    fileSize: MAX_FILE_SIZE
44  },
45  fileFilter
46});

결론

웹 애플리케이션 보안을 강화하기 위한 핵심 원칙:

  1. 입력 검증: 모든 사용자 입력 검증 및 정제
  2. 출력 인코딩: XSS 방지를 위한 적절한 인코딩
  3. 인증 강화: 강력한 비밀번호 정책 및 다단계 인증
  4. 암호화: 전송 중 및 저장 시 데이터 암호화
  5. 최소 권한: 필요한 최소한의 권한만 부여
  6. 보안 로깅: 보안 이벤트 모니터링 및 로깅
  7. 의존성 관리: 정기적인 취약점 스캔 및 업데이트

이러한 보안 모범 사례를 적용하면 웹 애플리케이션의 보안 수준을 크게 향상시킬 수 있습니다.

참고 자료

공유하기

관련 포스트