Node.js 인터뷰 질문 29

질문: Node.js 애플리케이션의 보안 모범 사례는 무엇인가요?

답변:

Node.js 애플리케이션의 보안은 매우 중요한 측면으로, 다양한 취약점으로부터 애플리케이션을 보호하기 위한 여러 모범 사례가 있습니다. 다음은 Node.js 애플리케이션의 주요 보안 모범 사례입니다.

1. 의존성 관리

취약점이 있는 패키지 사용 방지

# 취약점 검사
npm audit

# 취약점 자동 수정 (가능한 경우)
npm audit fix

# 보안 취약점이 있는 패키지 업데이트
npm update

# 특정 패키지만 업데이트
npm update package-name

의존성 잠금

package-lock.json 파일을 사용하여 의존성 버전을 고정하고, 정확한 버전을 사용하도록 합니다.

# package-lock.json 생성/업데이트
npm install

프로덕션 의존성만 설치

프로덕션 환경에서는 개발 의존성이 필요하지 않으므로 프로덕션 의존성만 설치합니다.

npm install --production

2. 데이터 검증 및 입력 위생 처리

사용자 입력 검증

모든 사용자 입력은 신뢰할 수 없으므로 항상 검증해야 합니다. Express.js에서는 express-validator와 같은 라이브러리를 사용할 수 있습니다.

const { body, validationResult } = require("express-validator");

app.post(
  "/user",
  // 이메일과 비밀번호 검증
  body("email").isEmail(),
  body("password").isLength({ min: 8 }),
  (req, res) => {
    // 검증 결과 확인
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    // 검증 통과, 사용자 생성 로직 실행
    // ...
  }
);

SQL 인젝션 방지

SQL 쿼리를 구성할 때 파라미터화된 쿼리를 사용하고, ORM(Object-Relational Mapping)을 활용합니다.

// 좋지 않은 방법 (SQL 인젝션 취약점)
const query = `SELECT * FROM users WHERE username = '${username}'`;

// 좋은 방법 (파라미터화된 쿼리)
const query = "SELECT * FROM users WHERE username = ?";
db.query(query, [username], (err, results) => {
  // ...
});

// Sequelize(ORM) 사용 예시
const user = await User.findOne({
  where: { username: username },
});

NoSQL 인젝션 방지

MongoDB와 같은 NoSQL 데이터베이스를 사용할 때도 입력 검증이 필요합니다.

// 좋지 않은 방법 (NoSQL 인젝션 취약점)
const query = { username: req.body.username };

// 좋은 방법 (입력 검증)
const username = req.body.username;
if (typeof username !== "string") {
  return res.status(400).json({ message: "유효하지 않은 username" });
}
const query = { username: username };

XSS(Cross-Site Scripting) 방지

사용자 입력을 출력할 때 HTML 인코딩을 사용하고, Content Security Policy(CSP)를 구현합니다.

// helmet 라이브러리 사용
const helmet = require("helmet");
app.use(helmet());

// 직접 CSP 구성
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'", "cdn.jsdelivr.net"],
      styleSrc: ["'self'", "'unsafe-inline'", "cdn.jsdelivr.net"],
      imgSrc: ["'self'", "data:", "cdn.jsdelivr.net"],
    },
  })
);

// 출력에 html-entities 사용
const { encode } = require("html-entities");
app.get("/profile", (req, res) => {
  res.send(`<p>Welcome, ${encode(req.query.name)}</p>`);
});

3. 인증 및 세션 관리

강력한 비밀번호 해싱

bcrypt와 같은 강력한, 적응형 해싱 알고리즘을 사용합니다.

const bcrypt = require("bcrypt");

// 비밀번호 해싱
async function hashPassword(password) {
  const saltRounds = 12; // 높을수록 더 안전하지만, 더 많은 리소스 사용
  return await bcrypt.hash(password, saltRounds);
}

// 비밀번호 검증
async function verifyPassword(submittedPassword, storedHash) {
  return await bcrypt.compare(submittedPassword, storedHash);
}

// 사용 예시
app.post("/register", async (req, res) => {
  try {
    const hashedPassword = await hashPassword(req.body.password);
    // 데이터베이스에 해시된 비밀번호 저장
    // ...
  } catch (error) {
    res.status(500).send("서버 오류");
  }
});

안전한 쿠키 설정

쿠키를 사용할 때는 보안 속성을 설정합니다.

// express-session 사용 예시
const session = require("express-session");

app.use(
  session({
    secret: process.env.SESSION_SECRET, // 환경 변수에서 로드
    name: "sessionId", // 기본 이름(connect.sid) 변경
    cookie: {
      httpOnly: true, // JavaScript에서 쿠키에 접근 방지
      secure: process.env.NODE_ENV === "production", // HTTPS에서만 쿠키 전송 (프로덕션 환경)
      sameSite: "strict", // CSRF 방지
      maxAge: 1000 * 60 * 60 * 24, // 24시간
    },
    resave: false,
    saveUninitialized: false,
  })
);

JWT(JSON Web Token) 보안

JWT를 사용할 때의 보안 모범 사례입니다.

const jwt = require("jsonwebtoken");

// 토큰 생성
function generateToken(user) {
  const payload = {
    id: user.id,
    role: user.role,
  };

  const options = {
    expiresIn: "1h", // 짧은 만료 시간
    issuer: "your-app-name",
    audience: "your-users",
    subject: user.id.toString(),
  };

  return jwt.sign(payload, process.env.JWT_SECRET, options);
}

// 토큰 검증 미들웨어
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];

  if (!token) {
    return res.status(401).json({ message: "인증 토큰이 필요합니다" });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      issuer: "your-app-name",
      audience: "your-users",
    });

    req.user = decoded;
    next();
  } catch (error) {
    return res.status(403).json({ message: "유효하지 않은 토큰입니다" });
  }
}

// 사용 예시
app.post("/login", async (req, res) => {
  // 사용자 인증 로직
  // ...

  const token = generateToken(user);
  res.json({ token });
});

app.get("/protected-route", verifyToken, (req, res) => {
  res.json({ message: "인증된 엔드포인트", user: req.user });
});

CSRF(Cross-Site Request Forgery) 방지

CSRF 공격을 방지하기 위한 토큰을 사용합니다.

const csrf = require("csurf");
const cookieParser = require("cookie-parser");

app.use(cookieParser());
app.use(csrf({ cookie: true }));

// CSRF 토큰을 템플릿에 제공
app.get("/form", (req, res) => {
  res.render("form", { csrfToken: req.csrfToken() });
});

// CSRF 토큰 확인
app.post("/process", (req, res) => {
  // csrfProtection 미들웨어가 토큰을 자동으로 확인
  // 형식이 유효하지 않으면 403 Forbidden 에러 발생
  res.send("폼 처리 완료");
});

HTML 폼 예시:

<form action="/process" method="post">
  <input type="hidden" name="_csrf" value="{{csrfToken}}" />
  <!-- 다른 폼 필드들 -->
  <button type="submit">제출</button>
</form>

4. 보안 HTTP 헤더 설정

Helmet 라이브러리를 사용하여 여러 보안 관련 HTTP 헤더를 설정합니다.

const helmet = require("helmet");

// 기본 설정으로 Helmet 사용
app.use(helmet());

// 특정 헤더 커스터마이징
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        // CSP 정책 설정
      },
    },
    hsts: {
      maxAge: 31536000, // 1년
      includeSubDomains: true,
      preload: true,
    },
    frameguard: {
      action: "deny",
    },
    // 더 많은 설정...
  })
);

5. 속도 제한 및 브루트 포스 공격 방지

Express-rate-limit 라이브러리를 사용하여 API 요청 속도를 제한합니다.

const rateLimit = require("express-rate-limit");

// API 전체에 적용되는 전역 속도 제한
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 100, // IP당 최대 요청 수
  standardHeaders: true, // RateLimit 정보를 표준 헤더로 반환
  legacyHeaders: false, // X-RateLimit-* 헤더 비활성화
  message: "너무 많은 요청을 보냈습니다. 나중에 다시 시도해주세요.",
});

app.use("/api/", apiLimiter);

// 로그인 엔드포인트에 더 엄격한 제한 적용 (브루트 포스 공격 방지)
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 5, // IP당 최대 요청 수
  message: "로그인 시도가 너무 많습니다. 나중에 다시 시도해주세요.",
});

app.post("/api/login", loginLimiter, (req, res) => {
  // 로그인 로직
});

6. 보안 로깅 및 에러 처리

안전한 로깅

민감한 정보가 로그에 포함되지 않도록 합니다.

const winston = require("winston");

// 로거 설정
const logger = winston.createLogger({
  level: "info",
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: "error.log", level: "error" }),
    new winston.transports.File({ filename: "combined.log" }),
  ],
});

// 개발 환경에서 추가 설정
if (process.env.NODE_ENV !== "production") {
  logger.add(
    new winston.transports.Console({
      format: winston.format.simple(),
    })
  );
}

// 로깅 사용 예시
app.post("/login", (req, res) => {
  // 사용자 이름만 로깅, 비밀번호는 로깅하지 않음
  logger.info(`로그인 시도: ${req.body.username}`);

  // 로그인 로직...
});

// 민감한 정보 필터링
function filterSensitiveData(data) {
  // 깊은 복사
  const filteredData = JSON.parse(JSON.stringify(data));

  // 민감한 필드 마스킹
  if (filteredData.password) filteredData.password = "[FILTERED]";
  if (filteredData.creditCard) filteredData.creditCard = "[FILTERED]";

  return filteredData;
}

// 필터링된 데이터로 로깅
logger.info("사용자 데이터:", filterSensitiveData(userData));

적절한 에러 처리

사용자에게 제공되는 에러 메시지에서 민감한 정보를 제거합니다.

// 에러 처리 미들웨어
app.use((err, req, res, next) => {
  // 자세한 에러 정보는 로그에만 남김
  logger.error("애플리케이션 에러:", {
    message: err.message,
    stack: err.stack,
    requestId: req.id,
  });

  // 사용자에게는 일반적인 메시지만 제공
  res.status(err.status || 500).json({
    error: {
      message:
        process.env.NODE_ENV === "production"
          ? "서버 오류가 발생했습니다"
          : err.message,
    },
  });
});

7. 파일 업로드 보안

파일 업로드를 처리할 때 중요한 보안 고려사항입니다.

const multer = require("multer");
const path = require("path");
const crypto = require("crypto");

// 안전한 파일 저장 설정
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "./uploads/");
  },
  filename: function (req, file, cb) {
    // 원본 파일 이름 대신 랜덤 이름 사용
    crypto.randomBytes(16, function (err, raw) {
      if (err) return cb(err);

      cb(null, raw.toString("hex") + path.extname(file.originalname));
    });
  },
});

// 파일 타입 검증
const fileFilter = function (req, file, cb) {
  // 허용된 MIME 타입만 수락
  const allowedTypes = ["image/jpeg", "image/png", "image/gif"];

  if (!allowedTypes.includes(file.mimetype)) {
    const error = new Error("지원되지 않는 파일 형식입니다.");
    error.code = "UNSUPPORTED_FILE_TYPE";
    return cb(error, false);
  }

  cb(null, true);
};

// 파일 크기 제한
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 1024 * 1024 * 5, // 5MB
  },
});

// 파일 업로드 처리
app.post(
  "/upload",
  upload.single("image"),
  (req, res) => {
    // 파일 업로드 성공
    res.json({ file: req.file });
  },
  (error, req, res, next) => {
    // 에러 처리
    if (error.code === "LIMIT_FILE_SIZE") {
      return res
        .status(400)
        .json({ message: "파일이 너무 큽니다. 최대 5MB까지 허용됩니다." });
    }

    res.status(400).json({ message: error.message });
  }
);

8. 환경 변수 관리 및 기밀 정보 보호

환경 변수를 사용하여 민감한 정보 관리하기:

// .env 파일 사용 (gitignore에 추가해야 함)
// .env 파일 예시:
// DB_HOST=localhost
// DB_USER=admin
// DB_PASS=secret123
// JWT_SECRET=your-very-strong-jwt-secret-key

// 환경 변수 로드
require("dotenv").config();

// 환경 변수 사용
const dbConfig = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
};

// 비밀 키 확인
if (!process.env.JWT_SECRET) {
  console.error("JWT_SECRET 환경 변수가 설정되지 않았습니다!");
  process.exit(1);
}

9. 정기적인 보안 감사 및 업데이트

보안 취약점 검사 및 의존성 업데이트 자동화:

# 보안 취약점 검사
npm audit

# 패키지 업데이트 확인
npm outdated

# 보안 취약점 자동 수정
npm audit fix

# 주요 패키지 직접 업데이트
npm update express

10. 서버 보안 강화

Node.js 서버 자체의 보안 강화 방법:

// 서버 정보 노출 감소
app.disable("x-powered-by");

// HTTP만 HTTPS로 리다이렉트
app.use((req, res, next) => {
  if (!req.secure && process.env.NODE_ENV === "production") {
    return res.redirect(`https://${req.headers.host}${req.url}`);
  }
  next();
});

// HTTPS 서버 설정 (프로덕션에서 권장)
const https = require("https");
const fs = require("fs");

const options = {
  key: fs.readFileSync("path/to/private.key"),
  cert: fs.readFileSync("path/to/certificate.crt"),
  ca: fs.readFileSync("path/to/ca_bundle.crt"),
};

https.createServer(options, app).listen(443, () => {
  console.log("HTTPS 서버가 포트 443에서 실행 중입니다");
});

11. 종합적인 보안 접근 방식의 예

Express.js 애플리케이션의 주요 보안 설정을 모두 적용한 예시:

const express = require("express");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const csrf = require("csurf");
const mongoSanitize = require("express-mongo-sanitize");
const { body, validationResult } = require("express-validator");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
require("dotenv").config();

const app = express();

// 미들웨어
app.use(express.json({ limit: "10kb" })); // 요청 body 크기 제한
app.use(cookieParser());
app.use(helmet()); // 보안 HTTP 헤더
app.use(mongoSanitize()); // NoSQL 인젝션 방지

// CORS 설정
app.use(
  cors({
    origin: process.env.CLIENT_URL,
    credentials: true,
  })
);

// CSRF 보호 (쿠키 기반)
app.use(csrf({ cookie: true }));

// API 속도 제한
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 100, // IP당 최대 요청 수
});
app.use("/api/", apiLimiter);

// 더 엄격한 로그인 속도 제한
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: "로그인 시도가 너무 많습니다. 나중에 다시 시도해주세요.",
});

// 라우트
app.post(
  "/api/login",
  loginLimiter,
  [body("email").isEmail().normalizeEmail(), body("password").notEmpty()],
  async (req, res) => {
    // 입력 검증
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    try {
      // 사용자 조회 및 비밀번호 검증
      const user = await User.findOne({ email: req.body.email });
      if (!user) {
        return res.status(401).json({ message: "인증 실패" });
      }

      const passwordMatch = await bcrypt.compare(
        req.body.password,
        user.password
      );
      if (!passwordMatch) {
        return res.status(401).json({ message: "인증 실패" });
      }

      // JWT 토큰 생성
      const token = jwt.sign(
        { id: user.id, role: user.role },
        process.env.JWT_SECRET,
        { expiresIn: "1h" }
      );

      // 안전한 쿠키 설정
      res.cookie("token", token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "strict",
        maxAge: 3600000, // 1시간
      });

      res.json({ message: "로그인 성공", userId: user.id });
    } catch (error) {
      console.error("로그인 에러:", error);
      res.status(500).json({ message: "서버 오류" });
    }
  }
);

// 인증 검증 미들웨어
const authenticate = (req, res, next) => {
  const token = req.cookies.token || req.headers.authorization?.split(" ")[1];

  if (!token) {
    return res.status(401).json({ message: "인증이 필요합니다" });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(403).json({ message: "유효하지 않은 토큰입니다" });
  }
};

// 보호된 라우트
app.get("/api/profile", authenticate, (req, res) => {
  res.json({ user: req.user });
});

// 에러 처리 미들웨어
app.use((err, req, res, next) => {
  console.error("애플리케이션 에러:", err);

  // CSRF 토큰 오류
  if (err.code === "EBADCSRFTOKEN") {
    return res.status(403).json({ message: "잘못된 CSRF 토큰" });
  }

  // 일반 에러
  res.status(err.status || 500).json({
    message:
      process.env.NODE_ENV === "production"
        ? "서버 오류가 발생했습니다"
        : err.message,
  });
});

// 서버 시작
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`서버가 포트 ${PORT}에서 실행 중입니다`);
});

결론

Node.js 애플리케이션의 보안은 하나의 솔루션이나 라이브러리로 해결되지 않는 지속적인 과정입니다. 위에서 다룬 모범 사례를 준수하면, 대부분의 일반적인 보안 위협으로부터 애플리케이션을 보호할 수 있습니다.

요약하면 다음 사항에 주의해야 합니다:

  1. 모든 사용자 입력을 검증하고 위생 처리
  2. 최신 의존성 및 보안 패치 유지
  3. 암호를 안전하게 해싱하고 저장
  4. 적절한 인증 및 권한 부여 메커니즘 구현
  5. 안전한 HTTP 헤더와 쿠키 설정
  6. 속도 제한 및 기타 브루트 포스 방어 구현
  7. 안전한 방식으로 로깅 및 오류 처리
  8. 파일 업로드 제한 및 검증
  9. 환경 변수를 사용하여 민감한 정보 관리
  10. 정기적인 보안 감사 및 테스트 수행

이러한 모범 사례를 애플리케이션 개발 초기 단계부터 적용하면, 보안을 뒤늦게 고려하는 것보다 더 효과적으로 보안 태세를 구축할 수 있습니다.

results matching ""

    No results matching ""