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 애플리케이션의 보안은 하나의 솔루션이나 라이브러리로 해결되지 않는 지속적인 과정입니다. 위에서 다룬 모범 사례를 준수하면, 대부분의 일반적인 보안 위협으로부터 애플리케이션을 보호할 수 있습니다.
요약하면 다음 사항에 주의해야 합니다:
- 모든 사용자 입력을 검증하고 위생 처리
- 최신 의존성 및 보안 패치 유지
- 암호를 안전하게 해싱하고 저장
- 적절한 인증 및 권한 부여 메커니즘 구현
- 안전한 HTTP 헤더와 쿠키 설정
- 속도 제한 및 기타 브루트 포스 방어 구현
- 안전한 방식으로 로깅 및 오류 처리
- 파일 업로드 제한 및 검증
- 환경 변수를 사용하여 민감한 정보 관리
- 정기적인 보안 감사 및 테스트 수행
이러한 모범 사례를 애플리케이션 개발 초기 단계부터 적용하면, 보안을 뒤늦게 고려하는 것보다 더 효과적으로 보안 태세를 구축할 수 있습니다.