Back to skill

Security audit

Model Switchboard

Security checks for vulnerabilities and agentic risk

Overview

The skill is a coherent OpenClaw model-management tool, but its local dashboard exposes unauthenticated credential and configuration-changing APIs that any browser origin can call while the server is running.

Review before installing. The CLI workflow appears purpose-aligned, but avoid running the dashboard server unless it has authentication and trusted-origin checks added. Remove legacy backup UI/server files from installed artifacts, restrict .env writes to known provider keys, and rotate provider or Telegram tokens if you ran this server while browsing untrusted sites.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (2)

T09 · Insecure Skill Coding Practices

Error
Location
ui/server.py:846
Finding
Unauthenticated cross-origin access to credential and configuration APIs<![CDATA[ ## Vulnerability Details **File Location**: `ui/server.py:846-927, 1139-1177, 1400-1427` **Vulnerability Type**: Unauthenticated privileged localhost API with wildcard CORS **Risk Level**: High The dashboard backend exposes credential-management and OpenClaw configuration endpoints without authentication. It also permits requests from every browser origin. ### Vulnerable Code ```python def do_POST(self): parsed = urllib.parse.urlparse(self.path) payload = self._read_json_body() if payload is None: return routes = { "/api/key": self._route_save_key, "/api/key/delete": self._route_delete_key, "/api/config/set-role": self._route_set_role, "/api/config/add-fallback": self._route_add_fallback, "/api/config/add-image-fallback": self._route_add_image_fallback, "/api/config/remove-fallback": self._route_remove_fallback, "/api/config/add-allowlist": self._route_add_allowlist, "/api/config/remove-allowlist": self._route_remove_allowlist, "/api/config/set-primary": self._route_set_primary, "/api/config/set-image-model": self._route_set_image, "/api/config/auto-fix": self._route_auto_fix, "/api/config/backup": self._route_backup, "/api/config/rollback": self._route_rollback, "/api/config/validate": self._route_validate, "/api/channels/telegram": self._route_set_telegram, "/api/channels/telegram/add-user": self._route_tg_add_user, "/api/channels/telegram/remove-user": self._route_tg_remove_user, } handler = routes.get(parsed.path) if not handler: self._json({"ok": False, "error": "Route not found"}, 404) return try: res = handler(payload) except ValueError as exc: self._json({"ok": False, "error": str(exc)}, 400) return except Exception as exc: self._json({"ok": False, "error": f"Internal error: {exc}"}, 500) return s ...[truncated 5148 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. **Remove wildcard CORS** - Do not emit `Access-Control-Allow-Origin: *`. - If cross-origin operation is necessary, maintain an exact allowlist of trusted origins. - Return no CORS headers to all other origins. 2. **Require a per-launch authentication capability** - Generate a cryptographically random token when the server starts. - Deliver it only to the locally launched dashboard. - Require it in a custom header on every API request. - Compare tokens using a constant-time comparison. 3. **Validate browser request context** - Reject state-changing requests whose `Origin` is absent or not explicitly trusted. - Validate the `Host` header against the expected loopback host and port. - Add CSRF protection for every POST endpoint. - Use strict response headers, including an appropriate Content Security Policy. 4. **Restrict credential operations** - Replace the generic environment-variable API with provider-specific operations. - Permit only names listed in the registry's `authEnv` fields and explicitly supported keys such as `TELEGRAM_BOT_TOKEN`. - Reject all other environment-variable names. - Consider separating Switchboard-managed credentials from the shared workspace `.env`. 5. **Reduce exposed functionality** - Separate read-only status endpoints from privileged mutation endpoints. - Require explicit reauthentication or confirmation for credential deletion, Telegram policy changes, and rollback. - Avoid returning unnecessary filesystem paths and backup metadata. 6. **Harden request processing** - Enforce a small maximum `Content-Length` before reading request bodies. - Reject unexpected content types. - Add rate limiting and security event logging. - Preserve atomic writes and ensure all credential and backup directories remain owner-only. 7. **Prefer a stronger local transport** - Where supported, use a Unix-domain socket with filesystem permissions. ...[truncated 122 chars]

T09 · Insecure Skill Coding Practices

Error
Location
ui/server_v1_backup.py:429
Finding
Legacy backup server retains unauthenticated cross-origin credential mutation<![CDATA[ ## Vulnerability Details **File Location**: `ui/server_v1_backup.py:429-527` **Vulnerability Type**: Vulnerable legacy administrative server shipped in the release artifact **Risk Level**: High The legacy backup server is independently executable and exposes the same credential and configuration mutation capability without authentication while enabling wildcard CORS. ### Vulnerable Code ```python def do_POST(self): parsed = urllib.parse.urlparse(self.path) content_len = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_len) if content_len else b"{}" try: data = json.loads(body) except json.JSONDecodeError: self._json_response({"error": "Invalid JSON"}, 400) return routes = { "/api/key": self._handle_save_key, "/api/key/delete": self._handle_delete_key, "/api/config/add-fallback": lambda d: self._json_response(config_add_fallback(d.get("model", ""))), "/api/config/add-image-fallback": lambda d: self._json_response(config_add_image_fallback(d.get("model", ""))), "/api/config/remove-fallback": lambda d: self._json_response(config_remove_fallback(d.get("model", ""), d.get("image", False))), "/api/config/add-allowlist": lambda d: self._json_response(config_add_to_allowlist(d.get("model", ""))), "/api/config/remove-allowlist": lambda d: self._json_response(config_remove_from_allowlist(d.get("model", ""))), "/api/config/set-primary": lambda d: self._json_response(config_set_primary(d.get("model", ""))), "/api/config/set-image-model": lambda d: self._json_response(config_set_image_model(d.get("model", ""))), "/api/config/auto-fix": lambda d: self._json_response(config_auto_fix(d.get("issue", ""))), "/api/config/backup": lambda d: self._json_response({"ok": True, "path": backup_config()}), } handler = routes.get(parsed.path) if handler: handler(data) else: self._json ...[truncated 3945 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Remove `ui/server_v1_backup.py` and `ui/index_v1_backup.html` from production and release artifacts. 2. Preserve historical versions in source-control history rather than as executable files in the installed Skill. 3. If the legacy server must remain, apply the same authentication, trusted-origin validation, CSRF protection, key allowlisting, request-size limits, and transport hardening required for the current server. 4. Disable generic `.env` mutation and expose only narrowly scoped provider credential operations. 5. Replace direct configuration writes with atomic temporary-file writes followed by full schema and role validation. 6. Add automated tests that verify: - Requests from untrusted origins are rejected. - Requests without a valid per-launch token are rejected. - Unregistered environment-variable names cannot be written or deleted. - Legacy or backup server files cannot be executed as production entry points. ]]>
Vulnerability Patterns
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Tool MisuseTool Parameter Abuse, Chaining Abuse, Unsafe Defaults
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
Findings (48)

Chaining Abuse

High
Category
Tool Misuse
Content
**File:** `switchboard.sh` lines ~130-140
**Severity:** MEDIUM

The backup pruning logic uses `ls -t | tail | xargs rm -f`. If two switchboard commands run concurrently (e.g., two agents, or rapid CLI calls), both could backup and prune simultaneously, potentially deleting each other's backups or counting wrong.

**Fix:** Use a lockfile for backup operations:
```bash
Confidence
75% confidence
Finding
Tool calls are chained to bypass individual safety checks or escalate capabilities beyond what any single tool call would allow.

Credential Access

High
Category
Privilege Escalation
Content
4. **No direct JSON editing** — All config changes go through `openclaw models set` CLI. The skill never does raw JSON manipulation of model fields (except import, which validates first).

5. **Model format regex** — `validate_model_ref()` uses strict regex that blocks shell metacharacters, path traversal (`../`), and null bytes. A malicious model name like `anthropic/../../etc/passwd` is rejected by the regex.

6. **Provider diversity enforcement** — redundancy.py correctly ensures fallback chains don't stack the same provider.
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Ae1

High
Category
analysis-evasion
Content
The Canvas UI at `ui/index.html` shows:
Confidence
100% confidence
Finding
Referenced artifact was not completely inspected

Chaining Abuse

High
Category
Tool Misuse
Content
local count
        count=$(ls -1 "$BACKUP_DIR"/openclaw-*.json 2>/dev/null | wc -l | tr -d ' ')
        if [ "$count" -gt "$MAX_BACKUPS" ]; then
            ls -t "$BACKUP_DIR"/openclaw-*.json | tail -n +"$((MAX_BACKUPS + 1))" | xargs rm -f 2>/dev/null || true
            log_dim "Pruned old backups (keeping last $MAX_BACKUPS)"
        fi
    ) 9>"$LOCKFILE"
Confidence
89% confidence
Finding
The prune pipeline uses ls | tail | xargs rm without null-delimited handling, which is unsafe for filenames containing whitespace, newlines, or leading dash characters. Although backup filenames created by this script are predictable, the directory is user-writable and a crafted matching file or symlink placed there could cause unintended deletions during cleanup.

Hidden Instructions

High
Category
Prompt Injection
Content
</div>
</div>

<!-- Model Picker Modal (for fix actions) -->
<div class="picker-overlay" id="pickerOverlay">
  <div class="picker">
    <h2 id="pickerTitle">Select Model</h2>
Confidence
70% confidence
Finding
Hidden instructions were detected in comments or invisible text. These could contain malicious directives. Manual review is recommended.

Credential Access

High
Category
Privilege Escalation
Content
api('POST', '/api/key', { key: envVar, value: input.value.trim() })
    .then(function(res) {
      if (res.ok) {
        showModalStatus('success', '✓ Saved ' + envVar + ' to .env');
        toast('✓ ' + envVar + ' saved to .env', 'success');
        setTimeout(function() {
          closeModal();
Confidence
81% confidence
Finding
This UI sends raw API keys to a backend endpoint that persists them to a .env file, creating a concentrated secret store and increasing exposure if the local app, filesystem, backups, logs, or repo handling are weak. In skill context, this is more dangerous because the page is explicitly an admin-like credential manager, so compromise of the surrounding service could directly yield provider secrets for multiple AI services.

Credential Access

High
Category
Privilege Escalation
Content
.then(function(res) {
      if (res.ok) {
        showModalStatus('success', '✓ Saved ' + envVar + ' to .env');
        toast('✓ ' + envVar + ' saved to .env', 'success');
        setTimeout(function() {
          closeModal();
          loadStatus();
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
.then(function(res) {
      if (res.ok) {
        showModalStatus('success', '✓ Saved ' + envVar + ' to .env');
        toast('✓ ' + envVar + ' saved to .env', 'success');
        setTimeout(function() {
          closeModal();
          loadStatus();
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
.then(function(res) {
      if (res.ok) {
        showModalStatus('success', '✓ Saved ' + envVar + ' to .env');
        toast('✓ ' + envVar + ' saved to .env', 'success');
        setTimeout(function() {
          closeModal();
          loadStatus();
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
from pathlib import Path

PORT = int(os.environ.get("SWITCHBOARD_PORT", "8770"))
ENV_FILE = os.environ.get("SWITCHBOARD_ENV", os.path.expanduser("~/.openclaw/workspace/.env"))
REGISTRY_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "model-registry.json")
CONFIG_FILE = os.path.expanduser("~/.openclaw/openclaw.json")
BACKUP_DIR = os.path.expanduser("~/.openclaw/backups/switchboard")
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
from pathlib import Path

PORT = int(os.environ.get("SWITCHBOARD_PORT", "8770"))
ENV_FILE = os.environ.get("SWITCHBOARD_ENV", os.path.expanduser("~/.openclaw/workspace/.env"))
REGISTRY_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "model-registry.json")
CONFIG_FILE = os.path.expanduser("~/.openclaw/openclaw.json")
BACKUP_DIR = os.path.expanduser("~/.openclaw/backups/switchboard")
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
if __name__ == "__main__":
    print(f"🔀 Model Switchboard server on http://127.0.0.1:{PORT}")
    print(f"   .env file: {ENV_FILE}")
    print(f"   Registry:  {REGISTRY_FILE}")
    print(f"   Config:    {CONFIG_FILE}")
    server = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
if __name__ == "__main__":
    print(f"🔀 Model Switchboard server on http://127.0.0.1:{PORT}")
    print(f"   .env file: {ENV_FILE}")
    print(f"   Registry:  {REGISTRY_FILE}")
    print(f"   Config:    {CONFIG_FILE}")
    server = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Unsafe Defaults

Medium
Category
Tool Misuse
Content
### L-4: Backup permissions — umask not set
**File:** `switchboard.sh` line ~125
`chmod 600 "$backup_file"` is good, but the `mkdir -p "$BACKUP_DIR"` doesn't set directory permissions. On a multi-user system, the backup directory could be world-readable.
**Fix:** Add `chmod 700 "$BACKUP_DIR"` after mkdir.

### L-5: model-registry.json — `openai/dall-e-3` has empty safeRoles
Confidence
70% confidence
Finding
Tool defaults are unsafe or overly permissive (e.g. disabled TLS verification, no authentication, world-writable permissions). Unsafe defaults widen the attack surface.

Unsafe Defaults

Medium
Category
Tool Misuse
Content
for pid, pinfo in registry.get("providers", {}).items():
        auth_envs = pinfo.get("authEnv", [])
        has_auth = False
        auth_via = None

        # Check environment variables
Confidence
75% confidence
Finding
Tool defaults are unsafe or overly permissive (e.g. disabled TLS verification, no authentication, world-writable permissions). Unsafe defaults widen the attack surface.

Intent-Code Divergence

Medium
Confidence
95% confidence
Finding
The introductory comments present a strong safety guarantee that the tool will never crash the gateway. In practice, functions such as set_primary, set_image, import_config, restore_backup, and redundancy_apply modify the active OpenClaw configuration, and the script merely checks status afterward or advises a restart, so the documentation overstates the absence of disruptive side effects.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
# Ensure backup/log directory exists early
mkdir -p "$BACKUP_DIR" 2>/dev/null
chmod 700 "$BACKUP_DIR" 2>/dev/null

# ── Colors & Output ────────────────────────────────────────────
RED='\033[0;31m'
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
# Ensure backup/log directory exists early
mkdir -p "$BACKUP_DIR" 2>/dev/null
chmod 700 "$BACKUP_DIR" 2>/dev/null

# ── Colors & Output ────────────────────────────────────────────
RED='\033[0;31m'
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
# Ensure backup/log directory exists early
mkdir -p "$BACKUP_DIR" 2>/dev/null
chmod 700 "$BACKUP_DIR" 2>/dev/null

# ── Colors & Output ────────────────────────────────────────────
RED='\033[0;31m'
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
# Ensure backup/log directory exists early
mkdir -p "$BACKUP_DIR" 2>/dev/null
chmod 700 "$BACKUP_DIR" 2>/dev/null

# ── Colors & Output ────────────────────────────────────────────
RED='\033[0;31m'
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
fi

    cp "$OPENCLAW_CONFIG" "$backup_file"
    chmod 600 "$backup_file"
    log_ok "Config backed up: ${DIM}$backup_file${NC}"

    # Prune old backups (keep MAX_BACKUPS) with lockfile to prevent races
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
fi

    cp "$OPENCLAW_CONFIG" "$backup_file"
    chmod 600 "$backup_file"
    log_ok "Config backed up: ${DIM}$backup_file${NC}"

    # Prune old backups (keep MAX_BACKUPS) with lockfile to prevent races
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
fi

    cp "$OPENCLAW_CONFIG" "$backup_file"
    chmod 600 "$backup_file"
    log_ok "Config backed up: ${DIM}$backup_file${NC}"

    # Prune old backups (keep MAX_BACKUPS) with lockfile to prevent races
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
fi

    cp "$OPENCLAW_CONFIG" "$backup_file"
    chmod 600 "$backup_file"
    log_ok "Config backed up: ${DIM}$backup_file${NC}"

    # Prune old backups (keep MAX_BACKUPS) with lockfile to prevent races
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Dynamic import via __import__()

Medium
Category
Dangerous Code Execution
Content
"backupCount": backup_count,
        "issues": issues,
        "registryModels": list(registry.get("models", {}).keys()),
        "timestamp": __import__("datetime").datetime.now().isoformat()
    }
Confidence
75% confidence
Finding
Dynamic __import__() can load arbitrary modules at runtime, bypassing static analysis and potentially importing malicious code.

Static analysis

Detected: suspicious.install_untrusted_source

Install source points to URL shortener or raw IP.

Warn
Code
suspicious.install_untrusted_source
Location
model-registry.json:719