MCP 서버를 보호하는 방법?
Source: Dev.to
10년 반 넘게 API 개발에 몸담아오면서 나는 한 가지 중요한 원칙을 몸에 익혔다: 보안은 사후에 추가하는 것이 아니라 기본이 되어야 한다. 파일, 데이터, 중요한 자산을 관리하는 MCP 서버라면 다층 방어가 필수다. 하지만 보안을 강화한다고 해서 복잡도가 반드시 늘어나는 것은 아니다. 이 글에서는 네 가지 핵심 보안 방안을 구현하는 방법을 소개한다:
- 악의적인 데이터를 차단하기 위한 철저한 입력 검증
- 사용자 신원을 확인하기 위한 견고한 인증
- 접근 권한을 정의하기 위한 정확한 인가
- 오용을 방지하기 위한 지능형 자원 제한
이 과정을 마치면 서버는 프로덕션 환경에 바로 투입할 수 있는 상태가 된다.
Core Principles of MCP Server Security
코드에 들어가기 전에 방어‑심층 전략을 먼저 정리하자.
1. Input Validation
원칙: 들어오는 모든 데이터는 의심하라. 모든 정보는 철저히 검증·정제·확인해야 한다.
이유: 파라미터 검증이 미흡하면 ../../etc/passwd와 같은 디렉터리 트래버설, 코드 인젝션, 서버 불안정 등 치명적인 취약점이 발생한다.
2. Authentication
원칙: 서버와 상호작용하는 모든 엔티티의 신원을 확인한다. 모든 요청은 확인된 신원과 연결돼야 한다.
이유: 인증이 없으면 도구가 완전히 무방비 상태가 되어, 잠긴 문이 없는 집처럼 무단 접근이 쉬워진다.
3. Authorization
원칙: 구체적인 접근 권한을 검증한다. 인증에 성공했더라도 사용자는 자신의 역할에 맞는 행동만 허용받아야 한다.
이유: 예를 들어 인턴이 인사 기록에 접근할 권한은 없다. 세밀한 권한 부여가 민감 데이터 보호의 핵심이다.
4. Resource Limiting
원칙: 명확한 할당량을 설정하고, 크기 제한을 두며, 연결 타임아웃을 구현한다.
이유: 이러한 조치는 악의적인 행위자나 예기치 않은 오류가 초당 10 000건과 같은 과도한 요청으로 서버를 마비시키는 것을 방지한다.
Robust Input Validation
가장 중요한 방어인 모든 입력 데이터를 검증하는 것부터 시작한다. 아래와 같이 src/security/validator.ts 파일을 만들어 구현한다:
// src/security/validator.ts
import path from 'path';
import { InputSchema } from '../mcp/protocol';
/**
* Validation error
*/
export class ValidationError extends Error {
constructor(
message: string,
public field?: string,
public expected?: string
) {
super(message);
this.name = 'ValidationError';
}
}
/**
* Parameter validator based on JSON Schema
*/
export class ParameterValidator {
/**
* Validate parameters according to schema
*/
static validate(params: any, schema: InputSchema): void {
// Check that params is an object
if (typeof params !== 'object' || params === null) {
throw new ValidationError('Parameters must be an object');
}
// Check required fields
for (const requiredField of schema.required) {
if (!(requiredField in params)) {
throw new ValidationError(
`Field '${requiredField}' is required`,
requiredField
);
}
}
// Validate each property
for (const [fieldName, fieldValue] of Object.entries(params)) {
const fieldSchema = schema.properties[fieldName];
if (!fieldSchema) {
throw new ValidationError(
`Field '${fieldName}' is not allowed`,
fieldName
);
}
this.validateField(fieldName, fieldValue, fieldSchema);
}
}
/**
* Validate a specific field
*/
private static validateField(
fieldName: string,
value: any,
schema: any
): void {
// Type validation
const actualType = typeof value;
const expectedType = schema.type;
if (expectedType === 'string' && actualType !== 'string') {
throw new ValidationError(
`Field '${fieldName}' must be a string`,
fieldName,
expectedType
);
}
if (expectedType === 'number' && actualType !== 'number') {
throw new ValidationError(
`Field '${fieldName}' must be a number`,
fieldName,
expectedType
);
}
if (expectedType === 'boolean' && actualType !== 'boolean') {
throw new ValidationError(
`Field '${fieldName}' must be a boolean`,
fieldName,
expectedType
);
}
// Enumeration validation
if (schema.enum && !schema.enum.includes(value)) {
throw new ValidationError(
`Field '${fieldName}' must be one of: ${schema.enum.join(', ')}`,
fieldName
);
}
// Length validation for strings
if (expectedType === 'string') {
if (schema.minLength && value.length < schema.minLength) {
throw new ValidationError(
`Field '${fieldName}' must be at least ${schema.minLength} characters`,
fieldName
);
}
if (schema.maxLength && value.length > schema.maxLength) {
throw new ValidationError(
`Field '${fieldName}' cannot exceed ${schema.maxLength} characters`,
fieldName
);
}
}
// Range validation for numbers
if (expectedType === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) {
throw new ValidationError(
`Field '${fieldName}' cannot be less than ${schema.minimum}`,
fieldName
);
}
if (schema.maximum !== undefined && value > schema.maximum) {
throw new ValidationError(
`Field '${fieldName}' cannot exceed ${schema.maximum}`,
fieldName
);
}
}
// Pattern validation for strings
if (expectedType === 'string' && schema.pattern) {
const regex = new RegExp(schema.pattern);
if (!regex.test(value)) {
throw new ValidationError(
`Field '${fieldName}' doesn't match expected format`,
fieldName
);
}
}
}
}
/**
* File path validator
*/
export class PathValidator {
private allowedDirectories: string[];
private blockedPaths: string[];
constructor(allowedDirectories: string[], blockedPaths: string[] = []) {
// Resolve all paths to absolute
this.allowedDirectories = allowedDirectories.map(dir => path.resolve(dir));
this.blockedPaths = blockedPaths.map(p => path.resolve(p));
}
/**
* Validate that a path is safe
*/
validatePath(filePath: string): string {
// Resolve absolute path
const absolutePath = path.resolve(filePath);
// Check path traversal (../)
if (absolutePath.includes('..')) {
throw new ValidationError(
'Paths with ".." are not allowed (path traversal)'
);
}
// Check that path is in an allowed directory
const isInAllowedDir = this.allowedDirectories.some(dir =>
absolutePath.startsWith(dir)
);
if (!isInAllowedDir) {
throw new ValidationError('Path is outside allowed directories');
}
// Optional: block explicitly blacklisted paths
if (this.blockedPaths.includes(absolutePath)) {
throw new ValidationError('Path is explicitly blocked');
}
return absolutePath;
}
}