본문으로 이동
·26분 읽기

API 보안 모범 사례: 모든 프로젝트에서 실행하는 체크리스트

인증, 인가, 입력 유효성 검사, 레이트 리미팅, CORS, 시크릿 관리, OWASP API Top 10. 모든 프로덕션 배포 전에 확인하는 항목들.

공유:X / TwitterLinkedIn

완전히 열린 상태의 API를 배포한 적이 있다. 악의적으로도, 게으르게도 아니었다 — 단지 내가 모르는 것을 몰랐을 뿐이다. 해시된 비밀번호를 포함해 사용자 객체의 모든 필드를 반환하는 엔드포인트. IP 주소만 확인하는 레이트 리미터 — 프록시 뒤에 있는 사람은 누구나 API를 마구 호출할 수 있었다. iss 클레임을 검증하는 걸 까먹은 JWT 구현 — 완전히 다른 서비스의 토큰이 잘만 동작했다.

이 실수들 하나하나가 프로덕션까지 갔다. 하나하나가 발견되었다 — 어떤 건 내가 찾았고, 어떤 건 사용자가 찾았고, 하나는 트위터에 올리지 않고 이메일을 보내줄 만큼 친절한 보안 연구원이 찾아줬다.

이 글은 그 실수들로부터 만든 체크리스트다. 모든 프로덕션 배포 전에 이걸 훑어본다. 편집증이라서가 아니라, 보안 버그가 가장 아프다는 걸 배웠기 때문이다. 깨진 버튼은 사용자를 짜증나게 한다. 깨진 인증 흐름은 사용자의 데이터를 유출한다.

인증 vs 인가#

이 두 단어는 회의에서, 문서에서, 심지어 코드 주석에서도 혼용된다. 같은 것이 아니다.

**인증(Authentication)**은 이런 질문에 답한다: "당신은 누구인가?" 로그인 단계다. 사용자명과 비밀번호, OAuth 플로우, 매직 링크 — 신원을 증명하는 무엇이든.

**인가(Authorization)**는 이런 질문에 답한다: "무엇을 할 수 있는가?" 권한 단계다. 이 사용자가 이 리소스를 삭제할 수 있는가? 이 관리자 엔드포인트에 접근할 수 있는가? 다른 사용자의 데이터를 읽을 수 있는가?

프로덕션 API에서 가장 흔히 본 보안 버그는 깨진 로그인 흐름이 아니다. 누락된 인가 확인이다. 사용자는 인증되어 있다 — 유효한 토큰을 가지고 있다 — 하지만 API가 요청하는 작업을 수행할 권한이 있는지 확인하지 않는다.

JWT: 구조와 중요한 실수들#

JWT는 어디에나 있다. 어디서나 오해받기도 한다. JWT는 점(.)으로 구분된 세 부분으로 되어 있다:

header.payload.signature

헤더는 어떤 알고리즘이 사용되었는지 말해준다. 페이로드는 클레임(사용자 ID, 역할, 만료 시간)을 담고 있다. 서명은 앞의 두 부분을 아무도 변조하지 않았음을 증명한다.

Node.js에서의 적절한 JWT 검증은 이렇다:

typescript
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
 
interface TokenPayload {
  sub: string;
  role: "user" | "admin";
  iss: string;
  aud: string;
  exp: number;
  iat: number;
  jti: string;
}
 
function verifyToken(token: string): TokenPayload {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"], // 절대 "none"을 허용하지 마라
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 시계 오차를 위한 30초 여유
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token expired");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Invalid token");
    }
    throw new ApiError(401, "Authentication failed");
  }
}

주목할 점이 몇 가지 있다:

  1. algorithms: ["HS256"] — 이것은 핵심이다. 알고리즘을 명시하지 않으면, 공격자가 헤더에 "alg": "none"을 넣은 토큰을 보내 검증을 완전히 건너뛸 수 있다. 이것이 alg: none 공격이며, 실제 프로덕션 시스템에 영향을 미쳤다.

  2. issueraudience — 이것들 없이는 서비스 A를 위해 발급된 토큰이 서비스 B에서도 동작한다. 같은 시크릿을 공유하는 여러 서비스를 운영한다면(그러면 안 되지만, 사람들이 그렇게 한다), 이런 식으로 교차 서비스 토큰 남용이 발생한다.

  3. 구체적인 에러 처리 — 모든 실패에 대해 "invalid token"을 반환하지 마라. 만료된 것과 잘못된 것을 구분하면 클라이언트가 갱신해야 하는지 재인증해야 하는지 알 수 있다.

리프레시 토큰 로테이션#

액세스 토큰은 수명이 짧아야 한다 — 15분이 표준이다. 하지만 사용자가 15분마다 비밀번호를 다시 입력하게 하고 싶지는 않다. 그래서 리프레시 토큰이 필요하다.

프로덕션에서 실제로 동작하는 패턴:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // 로테이션 감지를 위한 토큰 패밀리
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // 토큰을 찾을 수 없음 — 만료되었거나 이미 사용됨.
    // 이미 사용된 거라면, 잠재적 리플레이 공격이다.
    // 전체 토큰 패밀리를 무효화한다.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // 이전 토큰을 즉시 삭제 — 일회용
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // 새 토큰 생성
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // 같은 패밀리로 새 리프레시 토큰 저장
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30일
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // 이 패밀리의 모든 토큰을 스캔하고 삭제한다.
  // 이것은 최후의 수단이다 — 누군가 리프레시 토큰을 리플레이하면,
  // 패밀리의 모든 토큰을 죽여 재인증을 강제한다.
  const keys = await redis.keys(`refresh:*`);
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      const parsed = JSON.parse(data) as RefreshTokenData;
      if (parsed.family === family) {
        await redis.del(key);
      }
    }
  }
}

토큰 패밀리 개념이 이것을 안전하게 만든다. 모든 리프레시 토큰은 패밀리에 속한다(로그인 시 생성). 로테이션할 때 새 토큰이 패밀리를 상속받는다. 공격자가 이전 리프레시 토큰을 리플레이하면, 재사용을 감지하고 전체 패밀리를 죽인다. 합법적인 사용자는 로그아웃되지만, 공격자는 들어오지 못한다.

토큰 저장: httpOnly 쿠키 vs localStorage 논쟁#

이 논쟁은 수년째 계속되고 있고, 답은 명확하다: 리프레시 토큰은 항상 httpOnly 쿠키.

localStorage는 페이지에서 실행되는 모든 JavaScript가 접근할 수 있다. XSS 취약점이 하나라도 있으면 — 규모가 커지면 결국 생기게 된다 — 공격자가 토큰을 읽어 빼돌릴 수 있다. 게임 오버다.

httpOnly 쿠키는 JavaScript가 접근할 수 없다. 마침표. XSS 취약점으로도 사용자 대신 요청을 보낼 수는 있지만(쿠키는 자동으로 전송되니까), 공격자가 토큰 자체를 훔칠 수는 없다. 이것은 의미 있는 차이다.

typescript
// 안전한 리프레시 토큰 쿠키 설정
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // JavaScript로 접근 불가
    secure: true,       // HTTPS만
    sameSite: "strict", // 크로스사이트 요청 불가
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30일
    path: "/api/auth",  // 인증 엔드포인트에만 전송
  });
}

path: "/api/auth"는 대부분의 사람들이 놓치는 세부사항이다. 기본적으로 쿠키는 도메인의 모든 엔드포인트에 전송된다. 리프레시 토큰이 /api/users/api/products에 갈 필요는 없다. 경로를 제한하고, 공격 표면을 줄여라.

액세스 토큰은 메모리(JavaScript 변수)에 보관한다. localStorage도 아니고, sessionStorage도 아니고, 쿠키도 아니다. 메모리다. 수명이 짧고(15분), 페이지가 새로고침되면 클라이언트가 조용히 갱신 엔드포인트를 호출해 새 토큰을 받는다. 맞다, 페이지 로드 시 추가 요청이 발생한다. 그만한 가치가 있다.

입력 유효성 검사: 클라이언트를 절대 신뢰하지 마라#

클라이언트는 친구가 아니다. 클라이언트는 집에 들어와서 "여기 있어도 되는 사람입니다"라고 말하는 낯선 사람이다. 어쨌든 신분증을 확인해야 한다.

서버 외부에서 오는 모든 데이터 — 요청 본문, 쿼리 파라미터, URL 파라미터, 헤더 — 는 신뢰할 수 없는 입력이다. React 폼에 유효성 검사가 있는지는 중요하지 않다. 누군가 curl로 우회할 것이다.

타입 안전 유효성 검사를 위한 Zod#

Zod는 Node.js 입력 유효성 검사에 일어난 최고의 일이다. 런타임 유효성 검사와 TypeScript 타입을 무료로 제공한다:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("잘못된 이메일 형식")
    .max(254, "이메일이 너무 깁니다")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "비밀번호는 최소 12자여야 합니다")
    .max(128, "비밀번호가 너무 깁니다") // bcrypt DoS 방지
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "비밀번호에는 대문자, 소문자, 숫자가 포함되어야 합니다"
    ),
 
  name: z
    .string()
    .min(1, "이름은 필수입니다")
    .max(100, "이름이 너무 깁니다")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "이름에 잘못된 문자가 포함되어 있습니다"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // 참고: "admin"은 의도적으로 여기에 없다.
  // 관리자 역할 할당은 별도의 권한 있는 엔드포인트를 통한다.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Express 핸들러에서의 사용
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "유효성 검사 실패",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data는 CreateUserInput으로 완전히 타이핑됨
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

보안과 관련된 세부사항 몇 가지:

  • 비밀번호에 max(128) — bcrypt는 72바이트 입력 제한이 있고, 일부 구현은 조용히 잘라버린다. 하지만 더 중요한 것은, 10MB 비밀번호를 허용하면 bcrypt가 해싱에 상당한 시간을 소비한다는 것이다. 이것은 DoS 벡터다.
  • 이메일에 max(254) — RFC 5321은 이메일 주소를 254자로 제한한다. 그보다 긴 것은 유효한 이메일이 아니다.
  • admin 없는 역할 Enum — 매스 어사인먼트는 가장 오래된 API 취약점 중 하나다. 요청 본문에서 역할을 유효성 검사 없이 받아들이면, 누군가 "role": "admin"을 보내고 결과를 기대할 것이다.

SQL 인젝션은 해결되지 않았다#

"ORM만 쓰면 됩니다"는 성능을 위해 원시 쿼리를 작성하면 보호해주지 않는다. 그리고 성능을 위해 원시 쿼리를 작성하는 것은 결국 다 한다.

typescript
// 취약 — 문자열 결합
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// 안전 — 매개변수화된 쿼리
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Prisma를 쓰면 대체로 안전하지만 — $queryRaw는 여전히 물 수 있다:

typescript
// 취약 — $queryRaw에서 템플릿 리터럴
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// 안전 — Prisma.sql로 매개변수화
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL 인젝션#

MongoDB는 SQL을 사용하지 않지만, 인젝션에 면역이 있는 것은 아니다. 정제되지 않은 사용자 입력을 쿼리 객체로 전달하면 문제가 생긴다:

typescript
// 취약 — req.body.username이 { "$gt": "" }이면
// 컬렉션의 첫 번째 사용자를 반환한다
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// 안전 — 명시적으로 문자열로 변환
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// 더 나은 방법 — 먼저 Zod로 유효성 검사
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

해결책은 간단하다: 입력 타입이 데이터베이스 드라이버에 도달하기 전에 유효성을 검사하라. username이 문자열이어야 한다면, 문자열인지 확인하라.

경로 탐색(Path Traversal)#

API가 파일을 제공하거나 사용자 입력이 포함된 경로에서 읽는다면, 경로 탐색이 한 주를 망칠 것이다:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // 정규화하고 절대 경로로 해석
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // 핵심: 해석된 경로가 여전히 허용된 디렉토리 안에 있는지 확인
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // 파일이 실제로 존재하는지 확인
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// 이 검사가 없으면:
// GET /api/files?name=../../../etc/passwd
// /etc/passwd로 해석됨

path.resolve + startsWith 패턴이 올바른 접근법이다. ../를 수동으로 제거하려 하지 마라 — 정규식을 우회하는 인코딩 트릭이 너무 많다(..%2F, ..%252F, ....//).

레이트 리미팅#

레이트 리미팅 없이는 API가 봇을 위한 무한리필 뷔페다. 무차별 대입 공격, 크리덴셜 스터핑, 리소스 고갈 — 레이트 리미팅은 이 모든 것에 대한 첫 번째 방어선이다.

토큰 버킷 vs 슬라이딩 윈도우#

토큰 버킷: N개의 토큰이 담긴 버킷이 있다. 각 요청은 토큰 하나를 소비한다. 토큰은 일정한 속도로 충전된다. 버킷이 비면 요청이 거부된다. 버스트가 가능하다 — 버킷이 가득 차 있으면 N개의 요청을 즉시 보낼 수 있다.

슬라이딩 윈도우: 이동하는 시간 창 내의 요청을 세는 방식이다. 더 예측 가능하고, 버스트로 뚫기가 어렵다.

대부분의 경우 슬라이딩 윈도우를 사용한다. 동작을 이해하고 팀에 설명하기가 더 쉽기 때문이다:

typescript
import { Redis } from "ioredis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function slidingWindowRateLimit(
  redis: Redis,
  key: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const multi = redis.multi();
 
  // 윈도우 밖의 항목 제거
  multi.zremrangebyscore(key, 0, windowStart);
 
  // 윈도우 내 항목 수 세기
  multi.zcard(key);
 
  // 현재 요청 추가 (제한 초과 시 제거할 것)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // 키에 만료 설정
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Redis 트랜잭션 실패");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // 제한 초과 — 방금 추가한 항목 제거
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

계층화된 레이트 리미트#

전역 레이트 리미트 하나로는 부족하다. 엔드포인트마다 위험 프로필이 다르다:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // 인증 엔드포인트 — 엄격한 제한, 무차별 대입 타겟
  "POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
  "POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
  "POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
 
  // 데이터 읽기 — 더 관대하게
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // 데이터 쓰기 — 보통
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // 전역 폴백
  "*": { window: 60 * 1000, max: 60 },
};
 
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
  const identifier = req.user?.id ?? getClientIp(req);
  const endpoint = `${req.method}:${req.path}`;
  return `ratelimit:${identifier}:${endpoint}`;
}

주목: 인증된 사용자는 IP가 아니라 사용자 ID로 레이트 리미팅된다. 이것은 중요한데, 많은 합법적인 사용자들이 IP를 공유하기 때문이다(회사 네트워크, VPN, 모바일 통신사). IP로만 제한하면 사무실 전체를 차단하게 된다.

레이트 리미트 헤더#

항상 클라이언트에 상황을 알려줘라:

typescript
function setRateLimitHeaders(
  res: Response,
  result: RateLimitResult,
  limit: number
): void {
  res.set({
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": result.remaining.toString(),
    "X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
    "Retry-After": result.allowed
      ? undefined
      : Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
  });
 
  if (!result.allowed) {
    res.status(429).json({
      error: "요청이 너무 많습니다",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

CORS 설정#

CORS는 아마 웹 개발에서 가장 오해받는 보안 메커니즘일 것이다. Stack Overflow에서 CORS에 대한 답변의 절반은 "그냥 Access-Control-Allow-Origin: *을 설정하면 됩니다"이다. 기술적으로 맞다. 인터넷의 모든 악성 사이트에 API를 여는 방법이기도 하다.

CORS가 실제로 하는 것(그리고 하지 않는 것)#

CORS는 브라우저 메커니즘이다. 오리진 A의 JavaScript가 오리진 B의 응답을 읽어도 되는지 브라우저에 알려준다. 그게 전부다.

CORS가 하지 않는 것:

  • curl, Postman, 또는 서버 간 요청으로부터 API를 보호하지 않는다
  • 요청을 인증하지 않는다
  • 아무것도 암호화하지 않는다
  • 그 자체만으로는 CSRF를 방지하지 않는다(다른 메커니즘과 결합하면 도움이 되지만)

CORS가 하는 것:

  • malicious-website.com이 your-api.com에 fetch 요청을 보내고 사용자의 브라우저에서 응답을 읽는 것을 방지한다
  • 공격자의 JavaScript가 피해자의 인증된 세션을 통해 데이터를 빼돌리는 것을 방지한다

와일드카드 함정#

typescript
// 위험 — 모든 웹사이트가 API 응답을 읽을 수 있다
app.use(cors({ origin: "*" }));
 
// 역시 위험 — 이것은 단계만 추가된 *와 같다
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // 모든 것을 허용
    },
  })
);

*의 문제는 모든 페이지의 모든 JavaScript가 API 응답을 읽을 수 있게 만든다는 것이다. API가 사용자 데이터를 반환하고 사용자가 쿠키로 인증되어 있다면, 사용자가 방문하는 모든 웹사이트가 그 데이터를 읽을 수 있다.

더 나쁜 것: Access-Control-Allow-Origin: *credentials: true와 결합할 수 없다. 따라서 쿠키가 필요하면(인증을 위해), 와일드카드를 문자 그대로 사용할 수 없다. 하지만 Origin 헤더를 반사하는 것으로 우회하려는 사람들을 봤다 — 이것은 자격 증명이 있는 *와 동등하며, 최악의 조합이다.

올바른 설정#

typescript
import cors from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://yourapp.com",
  "https://www.yourapp.com",
  "https://admin.yourapp.com",
]);
 
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}
 
app.use(
  cors({
    origin: (origin, callback) => {
      // 오리진이 없는 요청 허용 (모바일 앱, curl, 서버 간)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    },
    credentials: true, // 쿠키 허용
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // 프리플라이트 24시간 캐시
  })
);

핵심 결정:

  • 명시적 오리진 셋, 정규식이 아니다. 정규식은 까다롭다 — yourapp.com이 정규식이 제대로 앵커되지 않으면 evilyourapp.com과 매칭될 수 있다.
  • credentials: true — 리프레시 토큰에 httpOnly 쿠키를 사용하기 때문이다.
  • maxAge: 86400 — 프리플라이트 요청(OPTIONS)은 지연을 추가한다. 브라우저에 CORS 결과를 24시간 캐시하라고 하면 불필요한 왕복이 줄어든다.
  • exposedHeaders — 기본적으로 브라우저는 JavaScript에 "단순한" 응답 헤더 몇 개만 노출한다. 클라이언트가 레이트 리미트 헤더를 읽게 하려면 명시적으로 노출해야 한다.

프리플라이트 요청#

요청이 "단순"하지 않으면(비표준 헤더, 비표준 메서드, 또는 비표준 콘텐츠 타입을 사용하면), 브라우저가 먼저 OPTIONS 요청을 보내 허가를 요청한다. 이것이 프리플라이트다.

CORS 설정이 OPTIONS를 처리하지 않으면, 프리플라이트 요청이 실패하고, 실제 요청은 절대 전송되지 않는다. 대부분의 CORS 라이브러리가 자동으로 처리하지만, 그렇지 않은 프레임워크를 사용한다면 직접 처리해야 한다:

typescript
// 수동 프리플라이트 처리 (대부분의 프레임워크가 자동으로 해줌)
app.options("*", (req, res) => {
  res.set({
    "Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  });
  res.status(204).end();
});

보안 헤더#

보안 헤더는 가장 저렴한 보안 개선이다. 브라우저에 보안 기능을 활성화하라고 알려주는 응답 헤더다. 대부분 설정 한 줄이면 되고, 전체 공격 클래스를 방어한다.

중요한 헤더들#

typescript
import helmet from "helmet";
 
// 한 줄. Express 앱에서 가장 빠른 보안 승리다.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // 많은 CSS-in-JS 솔루션에 필요
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1년
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

각 헤더가 하는 일:

Content-Security-Policy (CSP) — 가장 강력한 보안 헤더다. 브라우저에 스크립트, 스타일, 이미지, 폰트 등에 어떤 소스가 허용되는지 정확히 알려준다. 공격자가 evil.com에서 로드하는 <script> 태그를 주입하면 CSP가 차단한다. XSS에 대한 가장 효과적인 단일 방어책이다.

Strict-Transport-Security (HSTS) — 사용자가 http://를 입력하더라도 브라우저에 항상 HTTPS를 사용하라고 말한다. preload 지시어를 사용하면 도메인을 브라우저의 내장 HSTS 목록에 제출할 수 있어, 첫 번째 요청부터 HTTPS가 강제된다.

X-Frame-Options — 사이트가 iframe에 임베드되는 것을 방지한다. 공격자가 보이지 않는 요소로 페이지를 덮어 씌우는 클릭재킹 공격을 막는다. Helmet은 기본적으로 SAMEORIGIN으로 설정한다. 현대적인 대체품은 CSP의 frame-ancestors다.

X-Content-Type-Options: nosniff — 브라우저가 응답의 MIME 타입을 추측(스니핑)하는 것을 방지한다. 이것이 없으면, 잘못된 Content-Type으로 파일을 제공하면 브라우저가 JavaScript로 실행할 수 있다.

Referrer-Policy — Referer 헤더에 얼마나 많은 URL 정보가 전송되는지 제어한다. strict-origin-when-cross-origin은 같은 오리진 요청에는 전체 URL을 보내지만 크로스 오리진 요청에는 오리진만 보낸다. 민감한 URL 파라미터가 서드파티에 누출되는 것을 방지한다.

헤더 테스트#

배포 후 securityheaders.com에서 점수를 확인하라. A+ 등급을 목표로 하라. 거기까지 도달하는 데 약 5분의 설정이면 된다.

프로그래밍 방식으로도 헤더를 검증할 수 있다:

typescript
import { describe, it, expect } from "vitest";
 
describe("보안 헤더", () => {
  it("필수 보안 헤더를 모두 포함해야 한다", async () => {
    const response = await fetch("https://api.yourapp.com/health");
 
    expect(response.headers.get("strict-transport-security")).toBeTruthy();
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
    expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
    expect(response.headers.get("content-security-policy")).toBeTruthy();
    expect(response.headers.get("referrer-policy")).toBeTruthy();
    expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet이 이것을 제거함
  });
});

x-powered-by 체크는 미묘하지만 중요하다. Express는 기본적으로 X-Powered-By: Express를 설정해 공격자에게 어떤 프레임워크를 사용하는지 알려준다. Helmet이 이것을 제거한다.

시크릿 관리#

이건 당연해야 하지만, 풀 리퀘스트에서 여전히 본다: 소스 파일에 하드코딩된 API 키, 데이터베이스 비밀번호, JWT 시크릿. 또는 .gitignore에 없는 .env 파일에 커밋된 것들. 한 번 git 히스토리에 들어가면 다음 커밋에서 파일을 삭제해도 영원히 거기 있다.

규칙들#

  1. 절대 시크릿을 git에 커밋하지 마라. 코드에도, .env에도, 설정 파일에도, Docker Compose 파일에도, "테스트용" 주석에도 안 된다.

  2. .env.example을 템플릿으로 사용하라. 어떤 환경 변수가 필요한지 문서화하되, 실제 값은 포함하지 않는다:

bash
# .env.example — 이것을 커밋
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
 
# .env — 절대 커밋하지 마라
# .gitignore에 나열됨
  1. 시작 시 환경 변수를 검증하라. 데이터베이스 URL이 필요한 엔드포인트에 요청이 올 때까지 기다리지 마라. 빠르게 실패하라:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT 시크릿은 최소 32자여야 합니다"),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
 
export type Env = z.infer<typeof envSchema>;
 
function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("잘못된 환경 변수:");
    console.error(result.error.format());
    process.exit(1); // 잘못된 설정으로 시작하지 마라
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. 프로덕션에서는 시크릿 매니저를 사용하라. 환경 변수는 간단한 설정에서는 동작하지만 한계가 있다: 프로세스 목록에서 보이고, 메모리에 지속되며, 에러 로그를 통해 누출될 수 있다.

프로덕션 시스템에서는 적절한 시크릿 매니저를 사용하라:

  • AWS Secrets Manager 또는 SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (모든 클라우드에서 동작하는 것을 원한다면)

패턴은 어떤 것을 쓰든 동일하다: 애플리케이션이 시작 시 환경 변수가 아닌 시크릿 매니저에서 시크릿을 가져온다.

  1. 시크릿을 정기적으로 로테이션하라. 같은 JWT 시크릿을 2년 동안 사용하고 있다면, 로테이션할 때다. 키 로테이션을 구현하라: 여러 유효한 서명 키를 동시에 지원하고, 새 토큰에는 새 키로 서명하고, 이전 키와 새 키 모두로 검증하고, 기존 토큰이 모두 만료된 후 이전 키를 폐기하라.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // 활성 키만 새 토큰에 서명
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // 모든 유효한 키 반환
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // 다음 키 시도
    }
  }
 
  throw new ApiError(401, "Invalid token");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // 헤더에 키 ID 포함
  });
}

OWASP API Security Top 10#

OWASP API Security Top 10은 API 취약점에 대한 업계 표준 목록이다. 주기적으로 업데이트되며, 목록의 모든 항목은 실제 코드베이스에서 본 것이다. 하나씩 살펴보자.

API1: 객체 수준 인가 부재 (BOLA)#

가장 흔한 API 취약점이다. 사용자는 인증되어 있지만, API가 요청하는 특정 객체에 대한 접근 권한이 있는지 확인하지 않는다.

typescript
// 취약 — 인증된 모든 사용자가 모든 사용자의 데이터에 접근 가능
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// 수정 — 사용자가 자신의 데이터에 접근하는지 확인 (또는 관리자인지)
app.get("/api/users/:id", authenticate, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Access denied" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

취약한 버전은 어디에나 있다. 모든 인증 검사를 통과한다 — 사용자는 유효한 토큰을 가지고 있다 — 하지만 이 특정 리소스에 대한 접근 권한이 있는지 확인하지 않는다. URL의 ID를 바꾸면 다른 사람의 데이터를 받는다.

API2: 인증 부재#

약한 로그인 메커니즘, MFA 부재, 만료되지 않는 토큰, 평문으로 저장된 비밀번호. 이것은 인증 계층 자체를 다룬다.

해결책은 인증 섹션에서 논의한 모든 것이다: 강력한 비밀번호 요구사항, 충분한 라운드의 bcrypt, 수명이 짧은 액세스 토큰, 리프레시 토큰 로테이션, 실패 후 계정 잠금.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15분
 
async function handleLogin(email: string, password: string): Promise<AuthResult> {
  const lockoutKey = `lockout:${email}`;
  const attempts = await redis.get(lockoutKey);
 
  if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
    const ttl = await redis.pttl(lockoutKey);
    throw new ApiError(
      429,
      `계정이 잠겼습니다. ${Math.ceil(ttl / 60000)}분 후에 다시 시도하세요.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // 실패 시도 증가
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // 두 경우 모두 같은 에러 메시지 — 이메일 존재 여부를 노출하지 마라
    throw new ApiError(401, "이메일 또는 비밀번호가 잘못되었습니다");
  }
 
  // 성공 시 실패 시도 초기화
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

"같은 에러 메시지"에 대한 주석이 중요하다. API가 유효하지 않은 이메일에 "사용자를 찾을 수 없음"을 반환하고 유효한 이메일의 잘못된 비밀번호에 "비밀번호가 틀렸음"을 반환하면, 공격자에게 시스템에 어떤 이메일이 존재하는지 알려주는 것이다.

API3: 객체 속성 수준 인가 부재#

필요 이상의 데이터를 반환하거나, 사용자가 수정해서는 안 될 속성을 수정할 수 있게 허용하는 것.

typescript
// 취약 — 내부 필드를 포함한 전체 사용자 객체를 반환
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // 응답에 포함: passwordHash, internalNotes, billingId, ...
});
 
// 수정 — 반환할 필드의 명시적 허용 목록
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    createdAt: user.createdAt,
  });
});

절대 전체 데이터베이스 객체를 반환하지 마라. 항상 노출하고 싶은 필드를 선택하라. 이것은 쓰기에도 적용된다 — 전체 요청 본문을 업데이트 쿼리에 펼치지 마라:

typescript
// 취약 — 매스 어사인먼트
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // 공격자가 보냄: { "role": "admin", "verified": true }
});
 
// 수정 — 허용된 필드 선택
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  avatar: z.string().url().optional(),
});
 
app.put("/api/users/:id", authenticate, async (req, res) => {
  const data = UpdateUserSchema.parse(req.body);
  await db.users.update(req.params.id, data);
});

API4: 무제한 리소스 소비#

API는 리소스다. CPU, 메모리, 대역폭, 데이터베이스 연결 — 모두 유한하다. 제한이 없으면 단일 클라이언트가 모든 것을 고갈시킬 수 있다.

이것은 레이트 리미팅을 넘어선다. 다음을 포함한다:

typescript
// 요청 본문 크기 제한
app.use(express.json({ limit: "1mb" }));
 
// 쿼리 복잡도 제한
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
 
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_PAGE_SIZE)
    .default(DEFAULT_PAGE_SIZE),
});
 
// 파일 업로드 크기 제한
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("잘못된 파일 유형"));
    }
  },
});
 
// 장기 실행 요청 타임아웃
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "요청 타임아웃" });
  });
  next();
});

API5: 기능 수준 인가 부재#

BOLA와 다르다. 이것은 접근해서는 안 되는 기능(엔드포인트)에 접근하는 것이다, 객체가 아니라. 전형적인 예: 일반 사용자가 관리자 엔드포인트를 발견하는 것.

typescript
// 역할 기반 접근을 확인하는 미들웨어
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "인증되지 않음" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // 시도를 로그에 남긴다 — 공격일 수 있다
      logger.warn("무단 접근 시도", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "권한이 부족합니다" });
    }
 
    next();
  };
}
 
// 라우트에 적용
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);

엔드포인트를 숨기는 것에 의존하지 마라. "모호성을 통한 보안"은 보안이 아니다. 관리자 패널 URL이 어디에도 링크되어 있지 않더라도, 누군가 퍼징으로 /api/admin/users를 찾을 것이다.

API6: 민감한 비즈니스 플로우에 대한 무제한 접근#

합법적인 비즈니스 기능의 자동화된 남용. 생각해보라: 한정 재고 상품을 사는 봇, 스팸을 위한 자동 계정 생성, 상품 가격 스크래핑.

완화 방법은 컨텍스트에 따라 다르다: CAPTCHA, 디바이스 핑거프린팅, 행동 분석, 민감한 작업에 대한 단계별 인증. 하나의 코드 조각으로 모든 것을 해결할 수는 없다.

API7: 서버 측 요청 위조 (SSRF)#

API가 사용자가 제공한 URL을 가져온다면(웹훅, 프로필 사진 URL, 링크 미리보기), 공격자가 서버로 내부 리소스를 요청하게 만들 수 있다:

typescript
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
 
async function safeFetch(userProvidedUrl: string): Promise<Response> {
  let parsed: URL;
 
  try {
    parsed = new URL(userProvidedUrl);
  } catch {
    throw new ApiError(400, "잘못된 URL");
  }
 
  // HTTP(S)만 허용
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "HTTP(S) URL만 허용됩니다");
  }
 
  // 호스트명을 해석하고 사설 IP인지 확인
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "내부 주소는 허용되지 않습니다");
    }
  }
 
  // 이제 타임아웃과 크기 제한으로 fetch
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // 리다이렉트를 따르지 마라 (내부 IP로 리다이렉트될 수 있음)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

핵심 세부사항: 먼저 DNS를 해석하고 요청을 보내기 전에 IP를 확인하라. 리다이렉트를 차단하라 — 공격자가 http://169.254.169.254/(AWS 메타데이터 엔드포인트)로 리다이렉트하는 URL을 호스팅해 URL 수준 검사를 우회할 수 있다.

API8: 보안 설정 오류#

변경되지 않은 기본 자격 증명, 불필요한 HTTP 메서드 활성화, 프로덕션에서의 상세한 에러 메시지, 디렉토리 목록 활성화, CORS 잘못 설정. 이것은 "문 잠그는 것을 잊었다" 카테고리다.

typescript
// 프로덕션에서 스택 트레이스를 노출하지 마라
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("처리되지 않은 에러", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // 일반적인 에러 메시지 — 내부 정보를 노출하지 마라
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // 디버깅을 위한 요청 ID 포함
    });
  } else {
    // 개발 환경에서는 전체 에러 표시
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// 불필요한 HTTP 메서드 비활성화
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "허용되지 않는 메서드" });
  }
  next();
});

API9: 부적절한 인벤토리 관리#

API v2를 배포했지만 v1을 끄는 것을 잊었다. 또는 개발 중에 유용했던 /debug/ 엔드포인트가 프로덕션에서 여전히 실행 중이다. 또는 프로덕션 데이터가 있는 스테이징 서버가 공개 접근 가능하다.

이것은 코드 수정이 아니다 — 운영 규율이다. 모든 API 엔드포인트, 배포된 모든 버전, 모든 환경의 목록을 유지하라. 자동화된 스캐닝을 사용해 노출된 서비스를 찾아라. 필요 없는 것은 죽여라.

API10: 안전하지 않은 API 소비#

API가 서드파티 API를 소비한다. 응답을 유효성 검사하는가? Stripe의 웹훅 페이로드가 실제로 공격자가 보낸 것이라면?

typescript
import crypto from "crypto";
 
// Stripe 웹훅 서명 검증
function verifyStripeWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
  const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !expectedSig) return false;
 
  // 오래된 타임스탬프 거부 (리플레이 공격 방지)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5분 허용
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

웹훅의 서명은 항상 검증하라. 서드파티 API 응답의 구조는 항상 유효성을 검사하라. 나가는 요청에는 항상 타임아웃을 설정하라. "신뢰할 수 있는 파트너"에서 왔다는 이유만으로 데이터를 절대 신뢰하지 마라.

감사 로깅#

뭔가 잘못되면 — 그리고 잘못될 것이다 — 감사 로그가 무슨 일이 일어났는지 파악하는 방법이다. 하지만 로깅은 양날의 검이다. 너무 적게 로그하면 보이지 않는다. 너무 많이 로그하면 프라이버시 책임이 생긴다.

무엇을 로그할 것인가#

typescript
interface AuditLogEntry {
  timestamp: string;
  action: string;           // "user.login", "post.delete", "admin.role_change"
  actor: {
    id: string;
    ip: string;
    userAgent: string;
  };
  target: {
    type: string;           // "user", "post", "setting"
    id: string;
  };
  result: "success" | "failure";
  metadata: Record<string, unknown>; // 추가 컨텍스트
  requestId: string;        // 애플리케이션 로그와 상관 관계를 위해
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // 별도의 추가 전용 데이터 저장소에 기록
  // 애플리케이션이 사용하는 것과 같은 데이터베이스여서는 안 된다
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // 중요한 작업의 경우, 변경 불가능한 외부 로그에도 기록
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

이 이벤트들을 로그하라:

  • 인증: 로그인, 로그아웃, 실패한 시도, 토큰 갱신
  • 인가: 접근 거부 이벤트(공격 지표인 경우가 많다)
  • 데이터 수정: 생성, 수정, 삭제 — 누가 무엇을 언제 변경했는지
  • 관리자 작업: 역할 변경, 사용자 관리, 설정 변경
  • 보안 이벤트: 레이트 리미트 트리거, CORS 위반, 잘못된 형식의 요청

로그하지 말아야 할 것#

절대 로그하지 마라:

  • 비밀번호 (해시된 것도 — 해시는 자격 증명이다)
  • 전체 신용카드 번호 (마지막 4자리만 로그)
  • 주민등록번호 또는 정부 ID
  • API 키나 토큰 (최대 접두사만 로그: sk_live_...abc)
  • 개인 건강 정보
  • PII를 포함할 수 있는 전체 요청 본문
typescript
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = new Set([
    "password",
    "passwordHash",
    "token",
    "secret",
    "apiKey",
    "creditCard",
    "ssn",
    "authorization",
  ]);
 
  const sanitized: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(data)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      sanitized[key] = "[REDACTED]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

변조 감지 로그#

공격자가 시스템에 접근하면, 가장 먼저 하는 것 중 하나가 흔적을 감추기 위해 로그를 수정하는 것이다. 변조 감지 로깅은 이것을 감지 가능하게 만든다:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // 체인의 초기 해시
 
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
  const content = JSON.stringify(entry) + previousHash;
  const hash = crypto.createHash("sha256").update(content).digest("hex");
 
  previousHash = hash;
 
  return { ...entry, hash };
}
 
// 체인 무결성 검증:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
  let expectedPreviousHash = "GENESIS";
 
  for (const entry of entries) {
    const { hash, ...rest } = entry;
    const content = JSON.stringify(rest) + expectedPreviousHash;
    const computedHash = crypto.createHash("sha256").update(content).digest("hex");
 
    if (computedHash !== hash) {
      return false; // 체인이 끊어짐 — 로그가 변조되었다
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

이것은 블록체인과 같은 개념이다 — 각 로그 항목의 해시가 이전 항목에 의존한다. 누군가 항목을 수정하거나 삭제하면 체인이 끊어진다.

의존성 보안#

코드는 안전할 수 있다. 하지만 node_modules에 있는 847개의 npm 패키지는 어떤가? 공급망 문제는 실재하며, 수년에 걸쳐 더 심각해졌다.

npm audit는 최소한이다#

bash
# CI에서 실행하고, high/critical 취약점에서 빌드를 실패시켜라
npm audit --audit-level=high
 
# 자동 수정 가능한 것 수정
npm audit fix
 
# 실제로 무엇을 가져오고 있는지 확인
npm ls --all

하지만 npm audit에는 한계가 있다. npm 권고 데이터베이스만 확인하며, 심각도 등급이 항상 정확하지는 않다. 추가 도구를 겹겹이 쌓아라:

자동화된 의존성 스캐닝#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # 마이너와 패치 업데이트를 그룹화해 PR 노이즈 감소
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

락파일은 보안 도구다#

항상 package-lock.json(또는 pnpm-lock.yaml, yarn.lock)을 커밋하라. 락파일은 전이적 의존성을 포함한 모든 의존성의 정확한 버전을 고정한다. 이것 없이는 npm install이 테스트한 것과 다른 버전을 가져올 수 있다 — 그리고 그 다른 버전이 손상된 것일 수 있다.

bash
# CI에서는 install 대신 ci를 사용 — 락파일을 엄격하게 준수
npm ci

npm ci는 락파일이 package.json과 일치하지 않으면 조용히 업데이트하는 대신 실패한다. 누군가 package.json을 수정했지만 락파일을 업데이트하는 것을 잊은 경우를 잡아낸다.

설치 전에 평가하라#

의존성을 추가하기 전에 물어봐라:

  1. 정말 필요한가? 패키지를 추가하는 대신 20줄로 작성할 수 있는가?
  2. 다운로드 수는 얼마나 되는가? 낮은 다운로드 수가 반드시 나쁜 것은 아니지만, 코드를 리뷰하는 눈이 적다는 뜻이다.
  3. 마지막으로 업데이트된 것이 언제인가? 3년 동안 업데이트되지 않은 패키지에는 패치되지 않은 취약점이 있을 수 있다.
  4. 얼마나 많은 의존성을 끌어오는가? is-oddis-number에 의존하고 is-numberkind-of에 의존한다. 코드 한 줄이면 되는 일에 패키지 세 개다.
  5. 누가 관리하는가? 단일 관리자는 단일 침해 지점이다.
typescript
// 이것에 패키지가 필요하지 않다:
const isEven = (n: number): boolean => n % 2 === 0;
 
// 또는 이것에:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// 또는 이것에:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

배포 전 체크리스트#

이것이 모든 프로덕션 배포 전에 사용하는 실제 체크리스트다. 완전하지는 않다 — 보안은 절대 "완료"되지 않는다 — 하지만 가장 중요한 실수를 잡아낸다.

#점검 항목통과 기준우선순위
1인증JWT가 명시적 알고리즘, issuer, audience로 검증됨. alg: none 불가.치명적
2토큰 만료액세스 토큰 15분 이하 만료. 리프레시 토큰 사용 시 로테이션.치명적
3토큰 저장리프레시 토큰은 httpOnly secure 쿠키에. localStorage에 토큰 없음.치명적
4모든 엔드포인트에서 인가모든 데이터 접근 엔드포인트가 객체 수준 권한 확인. BOLA 테스트 완료.치명적
5입력 유효성 검사모든 사용자 입력이 Zod 또는 동등한 것으로 검증됨. 쿼리에 raw req.body 없음.치명적
6SQL/NoSQL 인젝션모든 데이터베이스 쿼리가 매개변수화된 쿼리 또는 ORM 메서드 사용. 문자열 결합 없음.치명적
7레이트 리미팅인증 엔드포인트: 5/15분. 일반 API: 60/분. 레이트 리미트 헤더 반환.높음
8CORS명시적 오리진 허용 목록. 자격 증명과 와일드카드 결합 없음. 프리플라이트 캐시됨.높음
9보안 헤더CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy 모두 존재.높음
10에러 처리프로덕션 에러는 일반적인 메시지 반환. 스택 트레이스나 SQL 에러 노출 없음.높음
11시크릿코드나 git 히스토리에 시크릿 없음. .env.gitignore에. 시작 시 검증됨.치명적
12의존성npm audit 통과(high/critical 없음). 락파일 커밋됨. CI에서 npm ci.높음
13HTTPS 전용HSTS preload 활성화. HTTP는 HTTPS로 리다이렉트. 쿠키에 secure 플래그 설정.치명적
14로깅인증 이벤트, 접근 거부, 데이터 변경 로그됨. 로그에 PII 없음.보통
15요청 크기 제한바디 파서 제한(기본 1MB). 파일 업로드 제한. 쿼리 페이지네이션 강제.보통
16SSRF 보호사용자 제공 URL 유효성 검사. 사설 IP 차단. 리다이렉트 비활성화 또는 검증.보통
17계정 잠금실패한 로그인 시도 5회 후 잠금 트리거. 잠금 로그됨.높음
18웹훅 검증모든 수신 웹훅이 서명으로 검증됨. 타임스탬프를 통한 리플레이 보호.높음
19관리자 엔드포인트모든 관리자 라우트에 역할 기반 접근 제어. 시도 로그됨.치명적
20매스 어사인먼트업데이트 엔드포인트가 허용 목록 필드의 Zod 스키마 사용. raw body 펼침 없음.높음

이것을 GitHub 이슈 템플릿으로 보관한다. 릴리스를 태그하기 전에 팀의 누군가가 모든 행을 확인하고 서명해야 한다. 화려하지는 않지만, 동작한다.

마인드셋 전환#

보안은 마지막에 추가하는 기능이 아니다. 1년에 한 번 하는 스프린트가 아니다. 작성하는 모든 코드 줄에 대해 생각하는 방식이다.

엔드포인트를 작성할 때 생각하라: "예상하지 못한 데이터를 보내면 어떻게 되는가?" 파라미터를 추가할 때 생각하라: "누군가 이것을 다른 사람의 ID로 바꾸면 어떻게 되는가?" 의존성을 추가할 때 생각하라: "이 패키지가 다음 주 화요일에 침해당하면 어떻게 되는가?"

모든 것을 잡지는 못할 것이다. 아무도 못한다. 하지만 이 체크리스트를 — 체계적으로, 모든 배포 전에 — 훑어보면 가장 중요한 것들을 잡는다. 쉬운 승리. 뻔한 허점. 나쁜 하루를 데이터 유출로 바꾸는 실수들.

습관을 만들어라. 체크리스트를 돌려라. 자신감을 가지고 배포하라.

관련 게시물