NAVER WORKS CLI + MCP 서버 개발기 (3) — MCP 서버 보안 설계, AI가 만드는 입력을 검증해야 하는 이유

2026. 4. 14. 18:03·개발기

내가 만든 MCP 서버는 AI가 보내는 파라미터를 검증하고 있을까?

코드를 열어봤다. 하나도 안 하고 있었다.

nworks는 LINE WORKS API를 CLI와 MCP(Model Context Protocol) 서버로 감싸는 도구다.
v1.1.0에서 기능은 거의 완성됐고, 다국어 README 정리하고 Glama에 등록하면서 마무리하는 중이었다.

그 즈음 별도로 MCP 보안 프록시를 설계하고 있었다. MCP 프로토콜의 보안 구조를 조사하면서 공격 표면들을 정리하는데, 자연스럽게 의문이 들었다. 내가 만든 nworks는 괜찮은가?

괜찮지 않았다.


먼저 — AI가 secret을 받으면 안 된다

본격적인 보안 패치 전에 구조적으로 잘못된 게 하나 있었다.

v1.1.0까지 nworks_setup 도구는 clientSecret을 파라미터로 받았다.
AI 에이전트가 사용자에게 "Client Secret을 알려주세요"라고 물어보고, 받은 값을 tool 파라미터로 전달하는 구조였다.

이러면 clientSecret이 AI 대화 히스토리에 평문으로 남는다.
민감 정보가 중간에 평문으로 지나가면 어딘가에서 새는 건 시간 문제다. 중간에 AI가 끼어있는 구조라면 더더욱.

clientSecret과 privateKeyPath 파라미터를 아예 삭제했다.
이제 이 값들은 MCP 설정 파일의 env 필드로만 전달할 수 있다.

{
  "mcpServers": {
    "nworks": {
      "command": "npx",
      "args": ["-y", "nworks", "mcp"],
      "env": {
        "NWORKS_CLIENT_SECRET": "<secret>",
        "NWORKS_PRIVATE_KEY_PATH": "<path>"
      }
    }
  }
}

AI가 볼 수 없는 경로로 민감 정보가 전달된다.
환경변수가 없으면 에러를 내고, 설정 방법을 JSON 예시와 함께 안내한다.

이건 보안 "패치"라기보다 설계 수정이다. 이 구조가 잡힌 후에 나머지 보안 강화를 진행했다.


AI도 입력이다

이 글 전체를 관통하는 문제는 하나다. MCP 서버에서 AI는 신뢰할 수 있는 중간자가 아니라, 예측 불가능한 입력 생성기다.

일반적인 웹 서비스에서는 사용자가 입력을 만든다.
폼에 값을 넣고, URL을 입력하고, 파일을 업로드한다.
사용자 입력을 검증하면 끝이다.

MCP는 다르다. 사용자가 "파일 다운로드해줘"라고 말하면, AI가 tool 파라미터를 구성한다.
파일 경로, 사용자 ID, 폴더 ID — 전부 AI가 만든 값이다.

AI는 환각(hallucination)을 한다. prompt injection에 취약하다.
악의적이지 않더라도, AI가 ../../etc/passwd 같은 경로를 만들어낼 수 있다.
웹 서비스에서 사용자 입력을 외부 공격자의 입력으로 취급하듯, MCP 서버에서는 AI 입력을 같은 수준으로 취급해야 한다. 기존 보안 모델이 전제하는 "입력 주체 = 사용자"가 MCP에서는 성립하지 않는다.

현재 MCP SDK는 Zod 스키마로 파라미터 타입은 검증하지만, 경로 안전성이나 값의 의미까지는 검증하지 않는다. 다른 MCP 서버들도 사정은 비슷하다. 대부분 타입 체크만 하고 값 자체는 신뢰한다. 그건 개발자 몫이다.

기존 코드는 AI가 보내는 파라미터를 그대로 신뢰하고 있었다. 이 전제가 깨지면 어떤 일이 벌어지는지, 하나씩 확인해봤다.


1. Path Traversal — API 경로에 ../를 넣으면

드라이브 API 호출 코드가 이랬다.

const url = `${BASE_URL}/users/${userId}/drive/files/${fileId}/download`;

userId에 ../../admin이 들어오면? URL이 의도와 다른 경로로 바뀐다.
단순한 경로 오류가 아니다. 다른 사용자의 리소스에 접근하거나, API의 권한 경계를 넘을 수 있다는 뜻이다.
이건 드라이브뿐 아니라 캘린더, 메일, 태스크, 게시판 — API 경로에 파라미터가 들어가는 모든 곳에서 같은 문제였다.

위험한 문자만 골라서 막는 블랙리스트 방식은 우회 가능성이 항상 남는다. URL 인코딩된 %2F나 유니코드 정규화 같은 변형을 전부 잡으려면 끝이 없다. 그래서 화이트리스트 방향으로 갔다. 안전한 것만 통과시키고 나머지는 인코딩한다.

export function sanitizePathSegment(value: string): string {
  if (!value || typeof value !== "string") {
    throw new Error("Path segment must be a non-empty string");
  }
  if (value === "me") return value;
  if (/[/\\]/.test(value) || value.includes("..")) {
    throw new Error(`Invalid path segment: "${value}"`);
  }
  return encodeURIComponent(value);
}

슬래시, 백슬래시, ..이 있으면 거부. 나머지는 encodeURIComponent로 인코딩.
me는 LINE WORKS API에서 "현재 사용자"를 뜻하는 예약어라 그대로 통과시켰다.

이걸 API 호출 코드 전체에 적용했다. board, calendar, drive, mail, message, task — 6개 API 모듈, 23개 경로 조립 지점.


2. SSRF(Server-Side Request Forgery) — 서버가 리다이렉트하면 어디로 가나

드라이브 파일 다운로드는 2단계로 동작한다.
LINE WORKS API에 다운로드 요청을 보내면, 실제 파일이 있는 스토리지 URL로 리다이렉트한다.

const location = redirectRes.headers.get("location");
const downloadRes = await authedFetch(location, { method: "GET" }, profile);

location이 https://evil.com/steal이면?
인증 토큰이 붙은 채로 외부 서버에 요청이 나간다.
단순한 외부 요청 문제가 아니다. 서버가 내부 네트워크를 대신 때리는 구조가 될 수 있다. 클라우드 환경이라면 인스턴스 메타데이터(IMDS) 접근이나 VPC 내부 스캔까지 가능해진다.

현실적으로 LINE WORKS가 악의적인 리다이렉트를 보낼 일은 없다. 하지만 그걸 전제로 코드를 짜는 건 다른 문제다. API 서버가 침해당하거나, DNS rebinding 같은 시나리오는 가능하다. 방어 비용이 URL 검증 함수 하나니까, 안 할 이유도 없었다.

const ALLOWED_HOSTS = [
  "storage.worksmobile.com",
  "www.worksapis.com",
  "worksapis.com",
];

export function validateRedirectUrl(location: string, allowedHosts: string[]): string {
  let parsed: URL;
  try {
    parsed = new URL(location);
  } catch {
    throw new Error(`Invalid redirect URL: "${location}"`);
  }

  if (parsed.protocol !== "https:") {
    throw new Error(`Redirect URL must use HTTPS: "${location}"`);
  }

  const isAllowed = allowedHosts.some(
    (host) => parsed.hostname === host || parsed.hostname.endsWith("." + host)
  );

  if (!isAllowed) {
    throw new Error(`Redirect to untrusted host: "${parsed.hostname}"`);
  }

  return location;
}

HTTPS만 허용하고, 호스트를 화이트리스트로 검증한다. 서브도메인도 허용하는 이유는 LINE WORKS가 cdn.storage.worksmobile.com 같은 CDN 경로를 쓸 수 있기 때문이다.

그리고 하나 더. 기존 코드는 리다이렉트된 스토리지 URL에도 인증 토큰을 붙여서 요청하고 있었다. 스토리지 다운로드에는 인증이 필요 없다. 불필요한 토큰이 외부로 나가는 것 자체가 위험이다. authedFetch를 일반 fetch로 교체했다.

고치는 데 5분이었다. 발견하는 데가 더 오래 걸렸다.


여기까지는 외부에서 들어오는 값에 대한 방어였다. 다음은 좀 다른 문제다. 서버가 밖으로 내보내는 값.

3. MCP 응답이 AI의 context에 남는다

"방금 설정한 내용 요약해줘."

nworks_setup 직후에 이렇게 물어보면 어떻게 될까. 기존 코드에서는 clientId가 응답에 평문으로 들어갔다. nworks_doctor를 호출하면 privateKeyPath 전체 경로가 나왔다. 이 정보는 AI의 context window에 남는다. context가 길어지면 AI가 이 정보를 다른 맥락에서 참조하거나, 요약에 포함시킬 수 있다.

const mask = (s: string) =>
  s.length <= 4 ? "****" : `****${s.slice(-Math.min(4, Math.floor(s.length / 3)))}`;

// nworks_setup 응답
clientId: mask(clientId),

// nworks_doctor 응답
if (r.check === "privateKey" && r.status === "OK") {
  return { ...r, detail: "OK (path hidden)" };
}

에러 응답도 문제였다. stack trace가 MCP 응답에 그대로 나가고 있었다. 내부 경로, 라이브러리 버전, 파일 구조가 다 드러난다.

// Before — stack이 AI context에 노출
content: [{ type: "text", text: `${mcpErrorHint(err, "drive.upload")}${detail}` }]

// After — stack은 서버 로그로, AI에게는 힌트만
if (process.env["NWORKS_VERBOSE"] === "1") {
  console.error(`[nworks] drive upload error: ${(err as Error).stack}`);
}
content: [{ type: "text", text: mcpErrorHint(err, "drive.upload") }]

웹 서비스에서 에러 페이지에 stack trace를 노출하지 않는 것과 같은 원리다. MCP에서는 AI가 "에러 페이지"를 읽는다.


4. OAuth — Math.random()과 토큰 revoke

OAuth state 파라미터를 이렇게 만들고 있었다.

const state = Math.random().toString(36).substring(2);

Math.random()은 암호학적으로 안전하지 않다. 48비트 시드에서 나오는 값이라 예측 가능하다. 예측 가능한 state는 CSRF 공격에 취약하고, OAuth 스펙(RFC 6749)에서도 state는 추측 불가능해야 한다고 명시한다. MCP 환경에서는 AI가 OAuth flow를 자동으로 트리거하기 때문에, state 검증이 빠지면 자동화된 공격 시나리오가 열린다. crypto.randomBytes(32)로 교체하면 256비트. 엔트로피 차이가 크다.

콜백 서버에서 state 일치 여부를 검증하도록 추가했다. 이전에는 state를 보내기만 하고 돌아오는 값은 확인하지 않았다.

// state 생성: Math.random() → crypto.randomBytes
export function generateSecureState(): string {
  return randomBytes(32).toString("hex");
}

// 콜백에서 state 검증 추가
if (state !== expectedState) {
  reject(new AuthError("OAuth state mismatch — possible CSRF attack."));
  return;
}

로그아웃도 손봤다. 기존 로직은 로컬 토큰 파일만 삭제했다. 서버에 토큰이 살아있으면, 토큰이 탈취된 경우 공격자가 만료 전까지 계속 사용할 수 있다. 로그아웃 시 서버측 revoke를 먼저 호출하고, 실패해도 로컬 삭제는 진행하는 best-effort 방식으로 처리했다.


패치 결과

전체 변경: 25개 파일, 1,478줄 추가, 312줄 삭제.
테스트: sanitize 유틸리티에 21개 테스트 추가, 전체 31개 통과.

npm run test

 ✓ tests/mcp/tools.test.ts (2 tests)
 ✓ tests/utils/sanitize.test.ts (21 tests)
 ✓ tests/api/message.test.ts (6 tests)
 ✓ tests/auth/jwt.test.ts (2 tests)

 Test Files  4 passed (4)
      Tests  31 passed (31)

보안 검증은 수동으로도 돌렸다. sanitize 함수에 공격 페이로드를 직접 넣어서 차단되는지 확인하는 스크립트.

전부 차단. 기존 정상 동작도 영향 없음을 CLI와 MCP 양쪽에서 확인했다.

정리하면 이번 패치의 핵심은 하나다. AI가 보내는 파라미터를 사용자 입력이 아니라, 외부 공격자의 입력과 동일하게 취급하는 것. 이 전제만 바꾸면 나머지는 기존 보안 패턴을 그대로 적용할 수 있다.


돌아보면

MCP 서버를 만들면서 기능에만 집중했다. 26개 도구가 돌아가고, CLI도 잘 되고, Glama에서 AAA도 받았다.
하지만 "AI가 만든 파라미터를 검증해야 한다"는 관점은 빠져 있었다.

웹 서비스에서는 사용자 입력 검증이 기본이다. 누구나 안다.
MCP에서는 AI 입력 검증이 기본이어야 한다. 그런데 이걸 놓치기 쉽다.
AI가 보내는 값은 대부분 정상적이니까. 문제는 대부분이 아니라 가끔이다.

이 경험이 이후 MCP 보안 프록시를 만드는 데도 이어졌다. 개별 서버에서 일일이 방어하는 것도 중요하지만, 프로토콜 레벨에서 공통으로 잡아야 할 것들이 있다.

MCP 서버를 만들고 있다면, 이 질문을 해보면 좋겠다.
내 서버는 AI가 ../../etc/passwd를 보내면 어떻게 되는가.

이번 패치가 반영된 코드는 nworks GitHub 레포에서 볼 수 있다.

'개발기' 카테고리의 다른 글

MCP AI 에이전트 보안 프록시 개발기 (5) — 시크릿 마스킹과 HMAC 해시 체인  (0) 2026.04.14
MCP AI 에이전트 보안 프록시 개발기 (4) — YAML 정책 엔진과 SARIF 감사 로깅  (0) 2026.04.14
MCP AI 에이전트 보안 프록시 개발기 (3) — 유니코드 호모글리프와 ReDoS로 보안 감사하기  (0) 2026.04.14
MCP AI 에이전트 보안 프록시 개발기 (2) — regex로 프롬프트 인젝션 탐지하기  (1) 2026.04.14
MCP AI 에이전트 보안 프록시 개발기 (1) — MCP 서버 응답은 검증 없이 AI에 전달된다  (0) 2026.04.14
'개발기' 카테고리의 다른 글
  • MCP AI 에이전트 보안 프록시 개발기 (5) — 시크릿 마스킹과 HMAC 해시 체인
  • MCP AI 에이전트 보안 프록시 개발기 (4) — YAML 정책 엔진과 SARIF 감사 로깅
  • MCP AI 에이전트 보안 프록시 개발기 (3) — 유니코드 호모글리프와 ReDoS로 보안 감사하기
  • MCP AI 에이전트 보안 프록시 개발기 (2) — regex로 프롬프트 인젝션 탐지하기
João Jin
João Jin
모바일 · 보안 · AI 기록
  • João Jin
    João Jin - 모바일 · 보안 · AI
    João Jin
  • 전체
    오늘
    어제
    • 분류 전체보기 (30)
      • 프로젝트 (3)
      • 개발기 (8)
      • 모바일 (8)
      • 보안 (2)
      • AI (8)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
    • X
  • 공지사항

  • 인기 글

  • 태그

    JNI
    ndk
    머신러닝
    MCP 보안
    Docker Compose
    model context protocol
    MLFlow
    AI
    안드로이드 NDK
    MLOps
    Android
    LLM 보안
    FastAPI
    AI 에이전트 보안
    Native
    LINE WORKS
    온디바이스AI
    Docker
    MCP
    mcp-fence
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
João Jin
NAVER WORKS CLI + MCP 서버 개발기 (3) — MCP 서버 보안 설계, AI가 만드는 입력을 검증해야 하는 이유
상단으로

티스토리툴바