MCP 서버의 OAuth 2.1 인증, 입력 유효성 검사, 명령 주입 방지, 데이터 유출 방지 등 프로덕션 보안 모범 사례를 다룹니다.
MCP 서버는 데이터베이스, API, 파일 시스템 등 민감한 시스템에 대한 접근 경로를 제공합니다. 보안이 취약한 MCP 서버는 공격자에게 내부 시스템의 문을 열어주는 것과 같습니다.
MCP의 보안 위험은 일반적인 웹 애플리케이션과 다른 특수한 측면이 있습니다. 도구의 입력은 LLM이 생성하는데, LLM은 프롬프트 주입(Prompt Injection) 공격에 의해 조작될 수 있습니다. 또한 MCP 서버가 여러 사용자의 요청을 처리할 때, 한 사용자의 데이터가 다른 사용자에게 노출되는 혼동된 대리인(Confused Deputy) 문제가 발생할 수 있습니다.
이 장에서는 OWASP의 MCP 보안 가이드와 공식 MCP 사양의 보안 권고사항을 바탕으로, 프로덕션 환경에서 반드시 적용해야 할 보안 모범 사례를 다룹니다.
MCP 사양은 원격 서버(Streamable HTTP 전송)에 대한 인증 방식으로 OAuth 2.1을 권장합니다. OAuth 2.1은 기존 OAuth 2.0의 보안 모범 사례를 통합하고, PKCE(Proof Key for Code Exchange)를 필수로 요구하는 등 보안을 강화한 버전입니다.
MCP 서버는 모든 요청에서 토큰을 검증해야 합니다. 토큰이 존재한다는 것만으로 유효하다고 가정해서는 안 됩니다.
import jwt from "jsonwebtoken";
interface TokenPayload {
sub: string; // 사용자 ID
aud: string; // 대상 (이 MCP 서버)
scope: string; // 허용된 범위
exp: number; // 만료 시간
}
function validateToken(token: string): TokenPayload {
// 1. 토큰 디코딩 및 서명 검증
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ["RS256"],
audience: "mcp-server.example.com", // 이 서버의 식별자
issuer: "https://auth.example.com", // 인증 서버
}) as TokenPayload;
// 2. 만료 여부 확인
if (payload.exp < Date.now() / 1000) {
throw new Error("토큰이 만료되었습니다.");
}
// 3. 대상(audience) 확인
if (payload.aud !== "mcp-server.example.com") {
throw new Error("이 서버를 대상으로 발급된 토큰이 아닙니다.");
}
return payload;
}MCP 서버는 자신을 대상으로 발급되지 않은 토큰을 절대 수락해서는 안 됩니다. 다른 서비스를 위해 발급된 토큰을 수락하면, 공격자가 낮은 권한의 서비스에서 받은 토큰으로 높은 권한의 MCP 서버에 접근할 수 있습니다. 토큰의 aud(audience) 클레임을 반드시 검증하십시오.
Streamable HTTP 전송에서 세션을 사용하는 경우, 세션 ID의 안전한 관리가 중요합니다.
import crypto from "crypto";
class SessionManager {
private sessions: Map<string, SessionData> = new Map();
createSession(userId: string): string {
// 1. 암호학적으로 안전한 랜덤 세션 ID 생성
const sessionId = crypto.randomUUID();
// 2. 사용자 ID와 세션 ID를 결합하여 키 생성
const sessionKey = userId + ":" + sessionId;
// 3. 세션 데이터 저장 (TTL 포함)
this.sessions.set(sessionKey, {
userId: userId,
createdAt: Date.now(),
expiresAt: Date.now() + 3600000, // 1시간
});
return sessionId;
}
validateSession(sessionId: string, userId: string): boolean {
const sessionKey = userId + ":" + sessionId;
const session = this.sessions.get(sessionKey);
if (!session) return false;
if (session.expiresAt < Date.now()) {
this.sessions.delete(sessionKey);
return false;
}
return true;
}
}세션 ID를 사용자 ID와 결합하면, 공격자가 세션 ID를 탈취하더라도 해당 사용자의 컨텍스트에서만 동작하도록 제한할 수 있습니다.
인가는 인증된 사용자가 어떤 작업을 수행할 수 있는지를 결정합니다.
각 도구에 필요한 권한(scope)을 정의하고, 요청 시 사용자의 권한을 검증합니다.
const TOOL_PERMISSIONS: Record<string, string[]> = {
query: ["db:read"],
execute: ["db:write"],
create_issue: ["github:write"],
list_issues: ["github:read"],
delete_file: ["fs:write", "fs:delete"],
};
function checkPermission(
toolName: string,
userScopes: string[]
): boolean {
const required = TOOL_PERMISSIONS[toolName];
if (!required) return false;
return required.every((scope) => userScopes.includes(scope));
}
// 도구 핸들러에서 권한 검증
server.tool("execute", "SQL을 실행합니다", {
sql: z.string(),
}, async (params, extra) => {
const token = extractToken(extra);
const payload = validateToken(token);
if (!checkPermission("execute", payload.scope.split(" "))) {
return {
content: [{
type: "text" as const,
text: "이 작업에 대한 권한이 없습니다. 필요한 권한: db:write",
}],
isError: true,
};
}
// 쿼리 실행...
});넓은 범위의 스코프(예: admin) 대신, 도구 또는 기능 단위의 세분화된 스코프를 정의합니다.
db:read - 데이터베이스 읽기
db:write - 데이터베이스 쓰기
db:schema - 스키마 변경
github:read - GitHub 조회
github:write - GitHub 이슈/PR 생성
github:admin - GitHub 설정 변경
fs:read - 파일 읽기
fs:write - 파일 쓰기
fs:delete - 파일 삭제
MCP 서버에 전달되는 모든 입력은 잠재적으로 위험합니다. LLM이 프롬프트 주입에 의해 조작되어 악의적인 입력을 전달할 수 있기 때문입니다.
8장에서 다룬 CLI 래핑 서버처럼, 셸 명령을 실행하는 도구는 명령 주입에 특히 취약합니다.
// 위험: 사용자 입력을 셸 명령에 직접 삽입
server.tool("list_dir", "디렉토리를 조회합니다", {
path: z.string(),
}, async (params) => {
// 공격자가 path에 "; rm -rf /"를 전달하면?
const { stdout } = await execAsync("ls -la " + params.path);
return { content: [{ type: "text", text: stdout }] };
});import { execFile } from "child_process";
import { promisify } from "util";
import path from "path";
const execFileAsync = promisify(execFile);
server.tool("list_dir", "디렉토리를 조회합니다", {
path: z.string(),
}, async (params) => {
// 1. 경로 정규화 (경로 탈출 방지)
const normalizedPath = path.resolve("/allowed/base", params.path);
// 2. 허용된 경로 내인지 확인
if (!normalizedPath.startsWith("/allowed/base")) {
return {
content: [{ type: "text" as const, text: "허용되지 않은 경로입니다." }],
isError: true,
};
}
// 3. execFile 사용 (셸을 거치지 않으므로 명령 주입 불가)
const { stdout } = await execFileAsync("ls", ["-la", normalizedPath]);
return { content: [{ type: "text" as const, text: stdout }] };
});핵심 방어 전략은 세 가지입니다.
exec 대신 execFile 사용: execFile은 셸을 거치지 않으므로 명령 주입이 불가능합니다.path.resolve()로 경로를 정규화하여 ../ 탈출을 방지합니다.// 위험: 문자열 연결
const result = await pool.query(
"SELECT * FROM users WHERE id = '" + params.userId + "'"
);
// 안전: 매개변수화된 쿼리
const result = await pool.query(
"SELECT * FROM users WHERE id = $1",
[params.userId]
);function sanitizePath(basePath: string, userPath: string): string {
// 1. 경로 정규화
const resolved = path.resolve(basePath, userPath);
// 2. 기본 경로 내인지 확인
if (!resolved.startsWith(path.resolve(basePath))) {
throw new Error("허용된 경로 범위를 벗어났습니다.");
}
// 3. 심볼릭 링크 확인 (옵션)
const real = fs.realpathSync(resolved);
if (!real.startsWith(path.resolve(basePath))) {
throw new Error("심볼릭 링크가 허용된 범위를 벗어납니다.");
}
return resolved;
}프롬프트 주입(Prompt Injection)은 공격자가 LLM에 악의적인 지시를 삽입하여, LLM이 의도하지 않은 도구 호출을 수행하도록 만드는 공격입니다.
도구 수준 방어: 위험한 도구에 확인 단계를 추가합니다.
server.tool(
"delete_file",
"파일을 삭제합니다. 되돌릴 수 없는 작업입니다.",
{
path: z.string(),
confirm: z.literal(true).describe("삭제를 확인합니다. 반드시 true여야 합니다."),
},
async (params) => {
// 추가 안전장치: 보호된 파일 목록
const protectedFiles = [".env", "config.json", "database.sqlite"];
const filename = path.basename(params.path);
if (protectedFiles.includes(filename)) {
return {
content: [{
type: "text" as const,
text: "보호된 파일은 삭제할 수 없습니다: " + filename,
}],
isError: true,
};
}
await fs.promises.unlink(sanitizePath(WORKSPACE, params.path));
return {
content: [{ type: "text" as const, text: "파일이 삭제되었습니다." }],
};
}
);데이터 무결성 검증: 도구의 출력이 다시 LLM에 전달되므로, 출력에 악의적인 지시가 포함되지 않도록 합니다.
function sanitizeOutput(text: string): string {
// LLM에 대한 지시로 해석될 수 있는 패턴 제거
return text
.replace(/\[SYSTEM\].*?\[\/SYSTEM\]/gi, "[내용 제거됨]")
.replace(/\[INST\].*?\[\/INST\]/gi, "[내용 제거됨]");
}요청 속도 제한: 비정상적으로 빈번한 도구 호출을 차단합니다.
class RateLimiter {
private counts: Map<string, { count: number; resetAt: number }> = new Map();
check(userId: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const entry = this.counts.get(userId);
if (!entry || entry.resetAt < now) {
this.counts.set(userId, { count: 1, resetAt: now + windowMs });
return true;
}
if (entry.count >= limit) {
return false;
}
entry.count += 1;
return true;
}
}
const rateLimiter = new RateLimiter();
// 도구 핸들러 내에서
if (!rateLimiter.check(userId, 60, 60000)) {
return {
content: [{ type: "text" as const, text: "요청이 너무 많습니다. 잠시 후 다시 시도하세요." }],
isError: true,
};
}서버 측 요청 위조(Server-Side Request Forgery, SSRF)는 공격자가 MCP 서버를 이용하여 내부 네트워크의 리소스에 접근하는 공격입니다.
import { URL } from "url";
import dns from "dns/promises";
async function validateUrl(urlString: string): Promise<boolean> {
const url = new URL(urlString);
// 1. 허용된 프로토콜만 허용
if (!["http:", "https:"].includes(url.protocol)) {
return false;
}
// 2. 내부 IP 대역 차단
const addresses = await dns.resolve4(url.hostname);
for (const addr of addresses) {
if (
addr.startsWith("10.") ||
addr.startsWith("172.16.") ||
addr.startsWith("192.168.") ||
addr.startsWith("127.") ||
addr === "0.0.0.0" ||
addr.startsWith("169.254.") // 클라우드 메타데이터
) {
return false;
}
}
// 3. 허용된 도메인 화이트리스트
const allowedDomains = ["api.github.com", "api.example.com"];
if (!allowedDomains.includes(url.hostname)) {
return false;
}
return true;
}SSRF 공격은 클라우드 환경에서 특히 위험합니다. AWS의 메타데이터 엔드포인트(169.254.169.254)에 접근하면 인스턴스의 IAM 역할 자격 증명을 탈취할 수 있습니다. MCP 서버가 외부 URL에 요청을 보내는 경우, 반드시 내부 IP와 메타데이터 엔드포인트를 차단해야 합니다.
보안 사고를 탐지하고 사후 분석을 위해, MCP 서버의 모든 활동을 기록해야 합니다.
interface AuditLog {
timestamp: string;
userId: string;
action: string;
toolName: string;
params: Record<string, unknown>;
result: "success" | "error" | "denied";
duration: number;
ip?: string;
}
function logAudit(entry: AuditLog) {
// 민감한 정보는 마스킹
const sanitized = {
...entry,
params: maskSensitiveFields(entry.params),
};
console.error(JSON.stringify(sanitized));
// 실제 구현에서는 외부 로깅 시스템에 전송
}
function maskSensitiveFields(
params: Record<string, unknown>
): Record<string, unknown> {
const sensitiveKeys = ["password", "token", "secret", "key", "credential"];
const masked = { ...params };
for (const key of Object.keys(masked)) {
if (sensitiveKeys.some((s) => key.toLowerCase().includes(s))) {
masked[key] = "[MASKED]";
}
}
return masked;
}프로덕션 MCP 서버를 배포하기 전에 확인해야 할 항목을 정리합니다.
execFile을 사용하는가이 장에서는 MCP 서버의 보안 모범 사례를 다루었습니다.
10장에서는 지금까지 학습한 모든 내용을 종합하여 풀스택 MCP 시스템을 구축하는 실전 프로젝트를 진행합니다. 여러 MCP 서버를 조합하고, 클라이언트 애플리케이션을 구축하며, 보안과 모니터링을 적용하는 전체 과정을 단계별로 다루겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
여러 MCP 서버를 조합하고 클라이언트 애플리케이션을 구축하여, 프로덕션 수준의 풀스택 MCP 시스템을 완성하는 실전 프로젝트입니다.
데이터베이스, REST API, 레거시 시스템을 MCP 서버로 래핑하여 AI 모델이 접근할 수 있도록 만드는 실전 패턴을 다룹니다.
MCP 클라이언트를 직접 구현하여 서버에 연결하고, LLM과 통합하여 도구 호출 파이프라인을 완성하는 방법을 다룹니다.