← 홈
취약점 제보

CVE-2026-62264 : pdf-reader-mcp

#취약점 제보

안녕하세요 novice-22입니다. 이번에 제보한 CVE-2026-62264 에 대해 분석글을 작성했습니다.
제보한 취약점은
pdf-reader-mcp 에서 발견한 인증 부재(무인증 접근) 취약점입니다.
코드 분석부터 실제 재현(PoC)까지, 왜 취약한지까지 쉽게 풀어서 작성하였습니다.

📋 요약

  • 대상 : SylphxAI/pdf-reader-mcp (영향 버전 2.2.0 이상 3.0.1 미만) · 전송 HTTP
  • CVE : CVE-2026-62264 | 심각도 High (CVSS 7.5) | CWE-306
  • 수정 · 3.0.1 에서 해결
  • 한 줄 요약 : HTTP 모드에서 API 키를 읽어 "인증이 켜졌다"는 로그까지 찍지만 그 키를 검사하는 코드가 없어, 포트에 닿는 누구나 키 없이 모든 도구를 부를 수 있다
  • 영향 : 서버를 띄운 계정이 읽을 수 있는 PDF가 위치와 상관없이 무인증 외부인에게 노출된다. 기본 호스트가 0.0.0.0 이라 켜는 순간 모든 인터페이스에 열린다.

1. 이 MCP는 무엇을 하는 서버인가

pdf-reader-mcp는 PDF를 AI 에이전트가 읽을 수 있게 바꿔주는 MCP 서버다. PDF는 바이너리 파일이라 AI가 그냥 읽지 못하는데, pdfjs-dist 로 파싱해 텍스트와 이미지로 바꿔줘야 비로소 읽을 수 있다. 구조화된 텍스트와 표, OCR, 시각적 증거, 페이지 단위 인용으로 로컬에서 변환해 에이전트에 넘겨준다.

전송 방식은 두 가지다. 기본값인 stdio는 내 컴퓨터에서 에이전트에 직접 물리는 방식이고, http는 포트를 열어 원격에서 접속하는 방식이다.

눈여겨볼 것은 http 모드에 붙은 보안 약속이다. README는 환경변수 표에서 MCP_API_KEY"Optional API key for authentication" 이라고 소개하고, HTTP 클라이언트 설정 예시에는 "X-API-Key": "your-api-key" 를 헤더로 넣으라고 안내한다. 소스 주석도 같은 말을 한다.

즉 이 서버는 "포트를 열어두더라도 키를 아는 쪽만 들어온다" 는 가정 위에 서 있다.

2. 취약점 발견

해당 MCP는 HTTP 모드가 존재해서 원격 공격이 가능할 수 있다는 점에서 HTTP 모드를 집중적으로 분석하게 됐다. 코드를 보면서 취약한 부분을 찾다가 MCP_API_KEY 를 발견했는데, HTTP 모드에서 MCP_API_KEY 를 읽어 "API key authentication enabled" 라는 로그까지 띄우면서도 정작 인증을 수행하는 코드는 어디에도 없었다. 결국 API 키를 등록해도 검사하는 코드가 없어 아무 일도 일어나지 않으며, HTTP 모드로 열면 키 설정 여부와 상관없이 무인증으로 열리게 된다.

💡 개념 - CWE-287이란?

"어떤 주체가 특정 신원을 주장할 때, 제품이 그 주장이 옳은지 증명하지 못하거나 불충분하게 증명하는 것."

인증이 제대로 이루어지지 않는 경우를 통칭하는 상위 개념이다. 아예 확인하지 않는 것도, 확인하는 척만 하는 것도 여기 들어간다.

💡 개념 - CWE-306이란?

"제품이 신원 증명이 필요하거나 상당한 자원을 소비하는 기능에 대해 인증을 전혀 수행하지 않는 것."

CWE-287 중에서도 검사가 틀린 게 아니라 검사 자체가 없는 경우다. 이번 건이 정확히 여기에 해당하며, read_pdf 를 비롯한 모든 도구가 아무 확인 없이 호출된다.

💡 개념 - CWE-200이란?

"제품이 해당 정보에 접근할 권한이 명시적으로 부여되지 않은 주체에게 민감한 정보를 노출하는 것."

앞의 둘이 원인이라면 이쪽은 결과다. 인증이 없으니 포트에 닿는 누구나 서버가 열 수 있는 PDF를 가져간다.

3. 취약점 분석

MCP_API_KEY 가 어디서 선언되고 어떻게 쓰이는지를, 실제 소스를 하나씩 따라가며 본다.

먼저 아래의 코드부터 시작한다. 환경변수 다섯 개를 주석으로 설명하고 설정하는 코드이다.

// src/index.ts (16~26행)
// Transport configuration via environment variables
// MCP_TRANSPORT: 'stdio' (default) | 'http'
// MCP_HTTP_PORT: HTTP port (default: 8080)
// MCP_HTTP_HOST: HTTP hostname (default: '0.0.0.0')
// MCP_API_KEY: Optional API key for authentication (X-API-Key header)
// MCP_CORS_ORIGIN: CORS allowed origin (e.g. 'https://myapp.example.com'). Not set by default (no cross-origin access).
const transportType = process.env['MCP_TRANSPORT'] ?? 'stdio';
const httpPort = Number.parseInt(process.env['MCP_HTTP_PORT'] ?? '8080', 10);
const httpHost = process.env['MCP_HTTP_HOST'] ?? '0.0.0.0';
const apiKey = process.env['MCP_API_KEY'];
const corsOrigin = process.env['MCP_CORS_ORIGIN'];

MCP_API_KEY 가 소스에서 처음 등장하는 곳이 src/index.ts의 20행 주석이다. 그 주석에는 MCP_API_KEY 가 "Optional API key for authentication (X-API-Key header)" 라고 적혀 있다. 인증용이고 X-API-Key 헤더로 받는다는 뜻이다. 그리고 25행에서 apiKey 변수에 담긴다. 선언 모양이 함께 읽히는 다른 환경변수 네 개와 다르지 않아서, 여기까지만 보면 나머지와 똑같이 쓰일 값으로 읽힌다.

// src/index.ts (31~40행)
function createTransport() {
  if (transportType === 'http') {
    return http({
      port: httpPort,
      hostname: httpHost,
      ...(corsOrigin ? { cors: corsOrigin } : {}),
    });
  }
  return stdio();
}

앞에서 읽은 다섯 개 환경변수 중 MCP_API_KEY 를 제외한 넷이 createTransport() 로 들어온다. transportType 은 http와 stdio 중 무엇을 쓸지 고르는 조건이 되고 httpPorthttpHost 는 그대로 넘어가며 corsOrigin 도 값이 있을 때 cors 로 전달되는데, apiKey 만은 createTransport() 어디에도 등장하지 않는다. 조건부로라도 넘어가는 corsOrigin 과 달리 전달 자체가 없으므로, 키가 전송 계층에 닿을 통로는 여기에서 끊긴다. 참고로 http 모드가 아니면 39행의 return stdio() 로 빠져 stdio 모드로 동작한다.

// node_modules/@sylphx/mcp-server-sdk/dist/index.d.ts (1105~1116행)
interface HttpOptions {
	/** Port to listen on (default: 3000) */
	readonly port?: number;
	/** Hostname to bind to (default: localhost) */
	readonly hostname?: string;
	/** Path prefix for MCP endpoints (default: /mcp) */
	readonly basePath?: string;
	/** CORS origin (set to "*" for all, or specific origin) */
	readonly cors?: string;
	/** Error handler */
	readonly onError?: (error: Error) => void;
}

앞에서 호출한 http() 는 이 저장소가 아니라 외부 패키지 @sylphx/mcp-server-sdk 에서 가져온 것이다. 4행의 import 가 그렇게 적혀 있다. 그래서 http() 가 무엇을 받을 수 있는지는 그 패키지의 타입 정의가 정한다. 받을 수 있는 것이 다섯 개인데, 각 줄에 용도까지 주석으로 적혀 있다. 포트, 호스트, 경로 접두사, CORS, 오류 핸들러. 인증에 해당하는 항목은 없다. 실제로 SDK 배포본 전체를 대소문자 구분 없이 찾아봐도 authorizationauthenticate 는 한 번도 나오지 않고, apiKey 는 사용자에게 값을 되묻는 기능을 설명하는 예시 주석에만 나온다. 들어온 요청을 인증하는 코드는 없다.

그래서 앞에서 apiKey 를 넘기는 코드를 빠뜨린 것이 아니라 넘길 자리 자체가 없었다. 넣으려 해도 HttpOptions 에 없는 항목이라 TypeScript가 거부한다.

공개된 문서는 반대로 말한다. 2.6.0 의 README 는 환경변수 표에 MCP_API_KEY"Optional API key for authentication" 이라고 적어두고, HTTP 클라이언트 설정 예시에는 아예 "X-API-Key": "your-api-key" 를 헤더로 넣으라고 안내한다. 그대로 따라한 사용자는 헤더를 붙여 보내지만, 받는 쪽에는 그 헤더를 읽는 코드가 없다.

// src/index.ts (63~71행)
  if (transportType === 'http') {
    console.log(`[PDF Reader MCP] Server running on http://${httpHost}:${httpPort}/mcp`);
    console.log(`[PDF Reader MCP] Health check: http://${httpHost}:${httpPort}/mcp/health`);
    if (apiKey) {
      console.log('[PDF Reader MCP] API key authentication enabled (X-API-Key header)');
    }
    if (corsOrigin) {
      console.log(`[PDF Reader MCP] CORS allowed origin: ${corsOrigin}`);
    }

그래서 apiKey 에게 남는 일은 마지막으로 67행의 로그 한 줄뿐이다. if (apiKey) 는 값이 비어 있지 않은지만 확인하고 "API key authentication enabled (X-API-Key header)" 한 줄을 출력할 뿐 내용을 읽지도 어딘가에 넘기지도 않는다. 바로 아래 corsOrigin 도 겉모습이 똑같은 두 줄인데, corsOrigin 은 앞에서 실제로 전달된 뒤에 찍는 로그라 뒷받침이 있고, apiKey 는 로그가 전부다. 운영자가 보는 화면에서는 둘 다 똑같이 "켜졌다"로 읽힌다.

선언부터 로그까지 다 따라와도 X-API-Key 로 들어온 값과 MCP_API_KEY 를 대조하는 줄이 없다. 키를 설정하면 달라지는 것은 서버의 동작이 아니라 운영자가 보는 로그 한 줄뿐이고, 그래서 이 결함은 인증이 뚫리는 문제가 아니라 처음부터 인증이 존재한 적이 없는 문제다.

코드를 떼고 보면 결국 이런 결함이다. 이 서버는 API 키로 접근을 막을 수 있다고 문서와 로그로 말하지만, 키를 받아 적어두기만 할 뿐 들어온 요청과 대조하지 않는다. 자물쇠를 달아둔 것처럼 보이지만 걸쇠가 없는 셈이다.

그래서 HTTP 모드로 켜는 순간, 키를 설정했든 안 했든 결과가 같다. 기본 호스트가 0.0.0.0 이라 포트에 닿을 수 있는 누구나 X-API-Key 없이 접속해 모든 도구를 그대로 부를 수 있고, 서버가 읽을 수 있는 PDF는 전부 가져갈 수 있다.

왜 이런 결함이 생겼나

  • ① 왜 취약한가 : 키를 읽는 자리와 요청을 받는 자리가 갈라져 있다. MCP_API_KEYindex.ts 에서 읽히지만 요청을 실제로 받는 것은 SDK의 HTTP 전송이고, 그 사이를 잇는 줄이 없다. 검사에 쓸 값이 검사할 수 있는 자리에 도착하지 못한다.
  • ② 왜 이렇게 짰나 : 이 서버는 HTTP 서버를 직접 만들지 않는다. http() 한 줄로 SDK에 맡긴다. 함께 읽은 나머지 네 개는 전부 SDK가 받아주는 값이라, 같은 자리에 MCP_API_KEY 를 한 줄 더 적어둔 것만으로 배선이 끝난 것처럼 보인다. 게다가 기동할 때마다 "authentication enabled" 로그가 나와 켜졌다는 확인까지 돌려준다. 확인해 주는 쪽이 자기 자신이라 어긋남이 드러나지 않는다.
  • ③ 신뢰경계가 어디서 깨지나 : 1번에서 인용한 약속, "포트를 열어두더라도 키를 아는 쪽만 들어온다"가 무너진다. 실제로 남는 경계는 포트에 닿을 수 있는가 하나뿐이고, 기본 호스트가 0.0.0.0 이라 그 하나마저 사실상 열려 있다.

by-design 반박

의도된 설계로 보기는 어렵다.

첫째, 인증을 안 하기로 한 서버라면 애초에 MCP_API_KEY 라는 환경변수를 만들지도, README에 인증 헤더를 안내하지도, 기동할 때 "authentication enabled" 로그를 찍지도 않았을 것이다. 막으려는 의도가 분명하다.

둘째, 같은 파일 안에 이미 올바른 배선이 있다. 바로 옆에서 읽힌 corsOrigin 은 값이 있을 때 http() 로 넘어가고, 요청마다 실제로 쓰인다.

...(corsOrigin ? { cors: corsOrigin } : {}),

한 줄이면 되는 일이고, 그 한 줄을 쓸 줄 알았다는 뜻이다. apiKey 에서만 그 줄이 없다.

셋째, 메인테이너 본인도 같은 판단을 했다. 수정된 3.0.1 에서는 앞서 본 그 로그 줄 위에 주석이 하나 붙었다.

// src/index.ts (3.0.1, 66~67행)
      // Truthful only because the transport now enforces this key (see src/mcp.ts).
      console.log('[PDF Reader MCP] API key authentication enabled (X-API-Key header)');

이제서야 저 로그가 사실이 되었다는 뜻이고, 무엇을 고쳤는지보다 그전까지는 사실이 아니었다는 말이 먼저 읽힌다.

4. PoC

이제 실제로 돌려서 앞의 분석이 맞는지 확인한다. 도커로 서버와 공격자를 서로 다른 호스트로 띄운 뒤, 인증 없이 PDF를 읽어낼 수 있는지 본다.

# 피해자 — pdf-reader-mcp 2.6.0 을 HTTP 모드로 띄우는 서버
FROM node:22-slim

RUN npm i -g @sylphx/pdf-reader-mcp@2.6.0 --ignore-scripts \
 && apt-get update && apt-get install -y --no-install-recommends curl iproute2 \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app/project

# 카나리 PDF 를 서로 다른 위치에 심는다. 마지막 하나는 서버 계정 권한 밖이다.
COPY mkpdf.mjs /opt/
RUN mkdir -p /srv/private \
 && node /opt/mkpdf.mjs /app/project/public.pdf          PUBLIC-DOC \
 && node /opt/mkpdf.mjs /srv/private/invoice.pdf         CANARY-PDFREADER-2026-62264 \
 && node /opt/mkpdf.mjs /home/node/tax-return-2025.pdf   HOME-DIR-TAX-RETURN \
 && node /opt/mkpdf.mjs /tmp/scan.pdf                    TMP-SCANNED-ID \
 && node /opt/mkpdf.mjs /root/root-only.pdf              ROOT-ONLY-SECRET \
 && chmod 600 /root/root-only.pdf

# 실제 배치에서는 프로젝트 폴더의 소유자가 서버를 띄우는 계정이다
RUN chown -R node:node /app/project /srv/private /home/node
USER node

공격자 쪽은 훨씬 단순하다. pdf-reader-mcp 도, 카나리 PDF 도 넣지 않는다.

# 공격자 — 별도 호스트. 가진 것은 네트워크 경로 하나뿐이다.
FROM node:22-slim

RUN apt-get update && apt-get install -y --no-install-recommends curl iproute2 \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /home/node
USER node

프로젝트 루트는 /app/project 다. 서버를 켜면 로그에 Project root 로 나오는 경로다. 나중에 그 바깥의 PDF가 읽히는 장면과 짝이 된다.

두 컨테이너 다 root가 아닌 node 계정으로 돌린다. 이 결함은 권한을 올려주지 않는다. 서버를 띄운 계정이 읽을 수 있는 것만 읽어가므로, root로 확인하면 과장된다.

docker build -f Dockerfile.victim   -t pdfreader-victim   .
docker build -f Dockerfile.attacker -t pdfreader-attacker .

docker network create --internal poc-net

docker run -d --name victim --network poc-net --network-alias victim \
  -e MCP_TRANSPORT=http -e MCP_API_KEY=super-secret-key-9x \
  pdfreader-victim bash -c "pdf-reader-mcp > /tmp/server.log 2>&1"

docker run -d --name attacker --network poc-net pdfreader-attacker sleep infinity

--internal 은 인터넷이 차단된 사설망을 만든다. 두 컨테이너끼리만 통신한다. 서버에 준 환경변수는 두 개뿐이고 호스트와 포트는 주지 않았다. 기본값으로 어디에 열리는지 보려는 것이다.

페이로드는 무해한 표식 문자열뿐이다. 카나리라고 부른다. 다른 데서는 나올 수 없는 값을 PDF에 넣어두고, 그 값이 응답에 나오면 그 파일을 읽어냈다는 증거가 된다.

⓪ 환경 확인

설치된 버전은 2.6.0, 서버는 node 계정으로 돌고 외부 인터넷은 끊겨 있다.

배포본에서 X-API-Key 관련 줄은 세 줄뿐이다. 읽고, 조건 걸고, 로그를 찍는다. 대조하는 줄은 없다. SDK의 HttpOptions 에도 인증 항목이 없다.

① 키를 켜고 서버를 띄운다

MCP_TRANSPORT=httpMCP_API_KEY 만 설정해 서버를 띄웠다. 로그에 API key authentication enabled 가 나왔다.

실제로 열린 포트를 보면 0.0.0.0:8080 이다. 따로 지정하지 않아도 기본값인 0.0.0.0 으로 서버가 실행된 것이다.

② 공격자 시점

공격자는 172.19.0.3, 피해자는 172.19.0.2 로 서로 다른 호스트다. 공격자 쪽에는 pdf-reader-mcp 도 PDF 도 없고, 피해자로 가는 네트워크 경로만 있다.

두 쪽 다 실행 계정은 node 다. root가 아닌 일반 계정이라, 뒤에서 읽히는 것도 그 계정이 볼 수 있는 범위 안이다.

공격자가 피해자 서버로 curl 을 보내면 /mcp/health 가 응답한다. 키를 붙이지 않았는데 돌아왔고, 묻지도 않은 버전까지 알려준다.

③ 원격 무인증 호출

이제 실제로 도구를 부른다. 요청 네 개를 보내는 동안 X-API-Key 는 한 번도 붙이지 않았다.

[1] initialize 로 핸드쉐이크를 열어본다. 서버가 자기를 pdf-reader-mcp 2.6.0 이라고 밝히며 세션을 내준다.

[2] notifications/initialized 로 핸드셰이크를 마무리한다. 답을 받지 않는 통보라 응답 본문이 없다.

[3] tools/list 를 부르면 도구 일곱 개가 그대로 나온다. 여기까지 정상 클라이언트와 똑같은 순서다.

[4] read_pdf/srv/private/invoice.pdf 를 넘긴다. 응답의 full_text 에 카나리가 담겨 돌아왔다. 인증을 한 번도 거치지 않았다.

④ 대조군

같은 요청을 헤더만 바꿔 세 번 보낸다. 키를 안 보낸 경우, 서버에 설정된 값과 다른 키를 보낸 경우, 서버 설정과 같은 키를 보낸 경우다.

세 경우 모두 initialize 가 200으로 돌아오고, 도구 일곱 개가 나오고, 카나리까지 나왔다. 결과가 완전히 같다.

틀린 키가 걸리지 않았다는 것이 핵심이다. 인증이 우회된 것이 아니라 대조하는 코드가 아예 없다는 뜻이다.

⑤ 어디까지 읽히나

여러 위치의 PDF를 같은 방식으로 요청했다. 프로젝트 루트 안과 밖, 서버 계정의 홈 디렉터리, 임시 폴더, 상대경로까지 전부 읽혔다.

반면 root 소유에 권한이 600 인 파일과 PDF가 아닌 /etc/passwd 는 실패했다.

경계는 폴더가 아니라 서버를 띄운 계정의 권한이다. 그 계정이 읽을 수 있는 PDF면 위치를 가리지 않는다.

⑥ 셸에서 직접 확인

앞의 결과는 전부 스크립트가 출력한 것이라, curl 로 같은 요청을 다시 보냈다. 키 없이 세션이 열리고 full_text 에 카나리가 그대로 담겨 돌아온다.

정리하면 이렇다. 운영자가 한 일은 http 모드로 켜고 키를 설정한 것뿐이고, 서버는 인증이 켜졌다고 로그로 알렸다. 그런데 다른 호스트에서 키 없이 붙었고, 틀린 키를 붙여도 결과가 같았고, 서버 계정이 읽을 수 있는 PDF는 위치를 가리지 않고 넘어왔다. 키를 설정해서 달라진 것은 로그 한 줄뿐이었다.

5. 영향

공격 경로

MCP 서버는 대개 stdio 전용이라 공격자가 도구를 직접 부를 방법이 없고, 에이전트가 읽는 콘텐츠에 조작된 값을 심는 간접 프롬프트 인젝션을 거쳐야 한다. 이번은 그런 경유가 필요 없다.

운영자가 http 모드를 켜는 순간 포트에 닿을 수 있는 누구나 그대로 공격자다. 에이전트도, 사용자의 클릭도 필요 없다.

게다가 기본 호스트가 0.0.0.0 이라 운영자가 외부에 열 생각이 없었어도 모든 인터페이스에 열린다. 사내망이나 VPC 안이라면 그 망에 들어온 누구나 해당된다.

무엇을 할 수 있나

도구 일곱 개가 전부 열린다. 그중 read_pdf 로 서버를 띄운 계정이 읽을 수 있는 PDF를 위치 상관없이 가져간다. 계약서, 청구서, 스캔해둔 신분증 같은 것들이 그 계정 아래에 있다면 피해는 더 커지게 된다.

/mcp/health 는 인증 없이 버전까지 알려준다. 공격자는 정찰을 공짜로 끝낸다.

다만 권한은 올라가지 않고, 읽기만 되며, PDF로 파싱되는 파일만 나온다. /etc/passwd 같은 평문은 읽히지 않고, URL 소스를 통한 SSRF도 막혀 있다. 그리고 http 모드를 운영자가 켜야 성립한다. 기본값은 stdio다.

심각도

CVSS 3.1 기준 7.5 (High) 로 평가했다. http 모드를 켜둔 구성을 기준으로 봤다.

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
항목이유
AVN (네트워크)다른 호스트에서 HTTP로 바로 닿는다
ACL (낮음)평범한 요청 하나면 된다. 조건도 타이밍도 없다
PRN (권한 불필요)키를 설정해도 대조하지 않는다
UIN (개입 불필요)에이전트도 사용자 동작도 필요 없다
SU (범위 불변)서버를 띄운 계정의 권한을 넘지 않는다
C / I / AH / N / N그 계정이 읽을 수 있는 PDF가 전부 노출된다. 읽기만 되고 쓰기나 중단은 없다

6. 패치 비교 분석

이 결함은 3.0.1 에서 수정됐다. 무엇이 달라졌고 그 수정으로 정말 막히는지, 같은 환경에 3.0.1 을 올려 직접 확인해 본다.

무엇이 달라졌나

// src/index.ts
-import { createServer, http, stdio } from '@sylphx/mcp-server-sdk';
+import { createServer, http, stdio } from './mcp.js';

-const httpHost = process.env['MCP_HTTP_HOST'] ?? '0.0.0.0';
+const httpHost = process.env['MCP_HTTP_HOST'] ?? '127.0.0.1';

     return http({
       port: httpPort,
       hostname: httpHost,
       ...(corsOrigin ? { cors: corsOrigin } : {}),
+      ...(apiKey ? { apiKey } : {}),
     });

세 가지가 바뀌었다.

첫째, 전송 계층을 외부 SDK에서 저장소 안으로 옮겼다. 3번에서 본 "넘길 자리 자체가 없다"는 문제가 여기서 풀린다. 직접 쓴 http() 라 인증 항목을 만들 수 있기 때문이다.

둘째, apiKey 가 실제로 전달된다. corsOrigin 과 똑같은 모양의 한 줄이다. 3번에서 "이 한 줄이 apiKey 에서만 없다"고 했는데, 그 줄이 그대로 추가됐다.

셋째, 기본 호스트가 0.0.0.0 에서 127.0.0.1 로 바뀌었다. 이제 아무것도 설정하지 않으면 밖에서 닿지 않는다.

왜 이게 근본 해결인가

패치에 이유가 주석으로 함께 들어갔다. 환경변수 설명부터 달라졌다.

MCP_API_KEY: Optional API key. When set, every /mcp request must present a matching X-API-Key header; requests without it are rejected with 401.

이 결함은 검사가 틀리던 것이 아니라 검사 자체가 없었던 것이라, 목록을 늘리는 식의 수정이 애초에 성립하지 않는다. 패치는 값이 지나갈 통로를 만들고 그 끝에 검사를 놓았다. 그래서 무엇을 빠뜨렸는지 따질 목록 자체가 없다.

우회가 가능한가

4번에서 통했던 요청을 패치본에 그대로 넣어 본 결과다.

보낸 헤더2.6.03.0.1
키 없음카나리 획득401
서버와 다른 키카나리 획득401
빈 값카나리 획득401
키 앞부분만카나리 획득401
쿼리스트링으로 전달카나리 획득401
헤더 두 번 (틀린 값 + 맞는 값)카나리 획득401

2.6.0 은 헤더를 아예 보지 않으므로 위의 어느 것을 보내든 결과가 같다. 3.0.1 버전은 전부 401이고 응답 본문도 Unauthorized: missing or invalid X-API-Key header 로 명확하다.

회귀 확인에서 그치지 않고 새 코드를 상대로도 시도해 봤다.

  • 헤더 이름을 소문자로 (x-api-key) → 맞는 키면 통과, 틀린 키면 401. HTTP 헤더 이름은 원래 대소문자를 구분하지 않으므로 정상 동작이다
  • 키 앞뒤에 공백 → 통과. HTTP 규격이 헤더 값 앞뒤 공백을 버리도록 정하고 있어 이것도 정상이다. 값 자체가 다르면 여전히 401이다
  • 값 일부만, 쿼리스트링, 헤더 중복 → 전부 401. 헤더로 정확한 값을 보내는 경우 외에는 통하지 않았다

부작용은 없나

막는 데만 집중해서 기능이 죽는 경우가 있는데, 이 패치는 그렇지 않다.

  • 맞는 키를 보내면 그대로 동작한다. read_pdf 로 카나리가 정상 획득됐다
  • /mcp/health 는 여전히 인증 없이 열려 있지만 응답이 {"status":"ok"} 로 줄어 버전이 사라졌다
  • 기본이 루프백이라 원격에서 쓰려면 MCP_HTTP_HOST 를 직접 줘야 한다. 그러면서 키가 없으면 기동 시 경고가 뜨도록 했다

7. 교훈

이번 건에서 가장 크게 남은 것은, 서버가 인증이 켜졌다고 알려주는 로그를 그대로 믿으면 안 된다는 점이다. 그 로그는 서버가 자기 상태를 스스로 말한 것일 뿐 확인이 아니다. MCP 서버를 HTTP 모드로 볼 때는 설정을 읽기 전에 키 없이 한 번 불러보는 편이 빠르다.

코드에서는 환경변수 이름으로 검색해 나오는 줄을 세어 보면 된다. 값을 읽는 자리와 실제로 쓰는 자리는 다르다. 검색 결과가 선언과 조건, 로그뿐이라면 그 기능은 없는 것이다. 이번 건은 배포본에서 세 줄이 전부였다.

전송이나 인증을 외부 라이브러리에 맡긴 구조라면 그 라이브러리가 무엇을 받는지까지 확인해야 한다. 상위 코드에 값이 적혀 있어도 라이브러리가 받지 않으면 전달되지 않는다. HttpOptions 의 항목이 다섯 개이고 그중 인증이 없다는 것만 봤어도 같은 결론에 닿았다.

기본값도 함께 본다. 이번 건은 결함보다 기본값이 피해 크기를 정했다. 호스트가 0.0.0.0 이고 경로 제한이 꺼져 있어서, 인증이 없다는 문제 하나가 계정 전체의 PDF 노출로 커졌다.