Back to skill

Security audit

TinkerClaw WordPress

Security checks for vulnerabilities and agentic risk

Overview

The skill is mostly transparent about managing a WordPress site, but it handles powerful site credentials and has under-scoped safeguards that could expose them in some local or misconfiguration scenarios.

Install only if you are comfortable giving the skill a WordPress application password. Use a dedicated least-privilege WordPress account, set WP_ALLOWED_HOSTS explicitly, avoid admin-capable credentials unless needed, use WP_READONLY for browsing-only sessions, and be cautious with --login/keychain storage and the optional curl_cffi path until those safeguards are tightened.

Vulnerability Patterns
  • Insecure DependenciesIntroduces malicious components through unsafe dependency sources
  • 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
Findings (2)

T08 · Insecure Dependencies

Warning
Location
scripts/wp.sh:190
Finding
Untrusted Python Module Loading Can Execute Attacker-Controlled Code with WordPress Credentials<![CDATA[ ## Vulnerability Details **File Location**: `scripts/wp.sh:190-199`; `scripts/wp-upload.sh:40-58`; `scripts/wp-upload.sh:86-96` **Vulnerability Type**: Untrusted dependency resolution and Python import-path hijacking **Risk Level**: Medium ### Vulnerable Code From `scripts/wp.sh:190-199`: ```bash if python3 -c "import curl_cffi" 2>/dev/null; then RESPONSE=$(WP_METHOD="$METHOD" WP_URL_FULL="$URL" WP_BODY="$BODY" \ WP_AUTH_USER="$WP_USER" WP_AUTH_PW="$WP_APP_PASSWORD" python3 - <<'PY' import os from curl_cffi import requests m=os.environ["WP_METHOD"]; url=os.environ["WP_URL_FULL"]; body=os.environ.get("WP_BODY","") auth=(os.environ["WP_AUTH_USER"], os.environ["WP_AUTH_PW"].replace(" ","")) kw=dict(impersonate="chrome", auth=auth, headers={"Content-Type":"application/json"}, timeout=60) if body and m!="GET": kw["data"]=body.encode() print(requests.request(m, url, **kw).text) PY ) ``` From `scripts/wp-upload.sh:40-58`: ```bash if python3 -c "import curl_cffi" 2>/dev/null; then RESPONSE=$(WP_FILE="$FILE_PATH" WP_NAME="$FILENAME" WP_MIME="$MIME" \ WP_URL_FULL="${WP_URL}/wp-json/wp/v2/media" \ WP_AUTH_USER="$WP_USER" WP_AUTH_PW="$WP_APP_PASSWORD" python3 - <<'PY_UP' import os from curl_cffi import requests auth = (os.environ["WP_AUTH_USER"], os.environ["WP_AUTH_PW"].replace(" ", "")) name, mime = os.environ["WP_NAME"], os.environ["WP_MIME"] r = requests.post( os.environ["WP_URL_FULL"], auth=auth, impersonate="chrome", headers={ "Content-Disposition": 'attachment; filename="%s"' % name, "Content-Type": mime, "Accept": "application/json", }, data=open(os.environ["WP_FILE"], "rb").read(), timeout=300, ) print(r.text) PY_UP ) ``` From `scripts/wp-upload.sh:86-96`: ```bash if python3 -c "import curl_cffi" 2>/dev/null; then WP_ALT="$ALT_TEXT" \ WP_URL_FULL="${WP_URL}/wp-json/wp/v2/media/${MEDIA_ID}" \ WP_AUTH_USER="$WP_USER" WP_AUTH_PW="$WP_APP_PASSWORD" python3 - <<'PY_ALT' > /dev/null impor ...[truncated 3048 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Run every Python invocation in isolated mode: ```bash python3 -I -c 'import curl_cffi' python3 -I - <<'PY' ... PY ``` 2. Remove attacker-controlled Python configuration from the subprocess environment: ```bash env -u PYTHONPATH -u PYTHONHOME -u PYTHONSTARTUP python3 -I ... ``` 3. Resolve and verify the package before using it. Refuse packages loaded from the current directory, writable temporary directories, or user-controlled project paths. 4. Pin `curl_cffi` to an audited version and installation source. Prefer a dedicated virtual environment whose directory and package files are not writable by untrusted users. 5. Avoid importing the module twice. Use one isolated Python process to perform dependency validation and the request. 6. Consider using only the existing `curl` transport. This removes the Python dependency-loading attack surface if browser impersonation is not strictly necessary. 7. Add tests that create a fake `curl_cffi.py` in the invocation directory and set a malicious `PYTHONPATH`, then verify that neither source is imported. 8. Continue using a least-privileged WordPress application-password account so that theft of the credential does not automatically grant site-administrator capabilities. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/wp-credentials.sh:90
Finding
Optional Host Allowlist Fails Open and Does Not Independently Pin the Credential Destination<![CDATA[ ## Vulnerability Details **File Location**: `scripts/wp-credentials.sh:90-97` **Vulnerability Type**: Fail-open destination validation for authenticated network requests **Risk Level**: Medium ### Vulnerable Code ```bash wp_check_allowlist() { WP_ALLOWED_HOSTS="${WP_ALLOWED_HOSTS:-$WP_HOST}" if [[ ",${WP_ALLOWED_HOSTS// /}," != *",${WP_HOST},"* ]]; then echo "❌ WP_URL host '$WP_HOST' is not in WP_ALLOWED_HOSTS. Refusing to send credentials." >&2 exit 1 fi } ``` The host is derived immediately before this function: ```bash wp_require_https() { : "${WP_URL:?Set WP_URL (env, keychain-independent) or in your env file}" case "$WP_URL" in https://*) ;; *) echo "❌ WP_URL must be https:// (got: $WP_URL). Refusing to send credentials." >&2; exit 1 ;; esac WP_HOST="${WP_URL#https://}"; WP_HOST="${WP_HOST%%/*}"; WP_HOST="${WP_HOST%%:*}" export WP_HOST } ``` ### Technical Analysis When `WP_ALLOWED_HOSTS` is absent, the function sets it to the hostname extracted from `WP_URL`. The configured destination therefore automatically authorizes itself. As a result, the allowlist does not provide an independent trust boundary unless the user explicitly configures it. HTTPS ensures transport encryption but does not establish that the selected host is the intended WordPress server. Any attacker-controlled HTTPS server with a valid certificate can receive the Basic Authentication credentials if `WP_URL` is redirected to it. This contradicts the stronger documentation claims that every request is protected by a host allowlist and that credentials are sent only to an allowlisted destination. The documentation presents `WP_ALLOWED_HOSTS` as recommended rather than mandatory, so the default configuration remains fail-open. The manual URL parsing also has limitations compared with standards-compliant URL parsing. It does not independently validate all URL components, normalize internationalized hostnames, or clearly reject embedded user inf ...[truncated 1782 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Require `WP_ALLOWED_HOSTS` and fail closed when it is absent: ```bash wp_check_allowlist() { : "${WP_ALLOWED_HOSTS:?Set WP_ALLOWED_HOSTS to the trusted WordPress hostname}" # Perform normalized exact-host comparison here. } ``` 2. Make `WP_ALLOWED_HOSTS` a required environment variable in `SKILL.md` and the Skill metadata rather than describing it as optional or merely recommended. 3. Parse `WP_URL` with a standards-compliant URL parser, such as Python's `urllib.parse.urlsplit`, and reject: - User information in the authority. - Missing or malformed hostnames. - Unexpected schemes. - Fragments. - Ambiguous encoded host forms. - Hostnames that do not exactly match a normalized allowlist entry. 4. Normalize hostnames before comparison, including lowercase conversion, trailing-dot handling, and explicit internationalized-domain-name treatment. 5. Compare exact host entries rather than relying on shell substring patterns. 6. Consider pinning both the hostname and expected port. Where appropriate, support certificate or public-key pinning for especially sensitive deployments. 7. Add tests proving that: - An absent allowlist causes refusal. - A changed `WP_URL` is rejected. - Similar suffixes and prefixes do not match. - Hostnames containing user information or malformed authority components are rejected. 8. Use a dedicated, least-privileged WordPress application password and revoke it immediately if destination redirection is suspected. ]]>
Vulnerability Patterns
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
Findings (3)

Credential Access

High
Category
Privilege Escalation
Content
openclaw:
    emoji: "📝"
    notes:
      security: "Full-access WordPress REST client: with a valid application password it can reach any wp/v2 endpoint your user can, including posts, pages, media, comments, users, settings, plugins and themes. Installing or activating a plugin is arbitrary code execution on your site. Credentials are read at runtime from WP_ENV_FILE or the skill's own .env (no parent-directory search) and sent only to WP_URL over HTTPS; nothing is written to disk and no third party is contacted. BOTH env-file paths get the identical symlink/ownership/0600 check, because both scripts now share one loader (scripts/wp-credentials.sh) instead of carrying drifted copies. The password is never placed on a command line: the curl fallback authenticates through a trap-cleaned 0600 netrc file, so it is not visible in `ps`. Four controls are enforced in scripts/wp.sh, not just documented: new posts/pages are forced to draft; going live needs WP_ALLOW_PUBLISH=1; plugin/theme/user/settings writes need WP_ALLOW_ADMIN=1; permanent delete (force=true) is blocked outright. Off switch: WP_READONLY=1 blocks every non-GET call. See the Permissions, Data Flow & Consent section."
    requires:
      bins: ["curl", "jq", "bash", "python3"]
      env: ["WP_URL", "WP_USER", "WP_APP_PASSWORD"]
Confidence
89% confidence
Finding
The skill is explicitly designed to read sensitive WordPress credentials from an env file and use them to authenticate a full-access REST client. Even though this is expected functionality and the document describes mitigations, compromise or misuse of those credentials can allow broad access to posts, media, comments, users, settings, and potentially plugin/theme administration depending on the account and flags used.

Credential Access

High
Category
Privilege Escalation
Content
- **The two scripts now share one credential loader** (`scripts/wp-credentials.sh`). They used to carry separate copies that had drifted: `WP_ENV_FILE` was validated, but the implicit `<skill>/.env` got **no symlink, ownership or mode check at all**. Both paths now get the identical check, so a `chmod 644` or symlinked `.env` is refused instead of read.
- **Route gates are bypass-proof.** `//plugins`, `%70lugins`, `PLUGINS` and `posts/../plugins` all used to slip past `WP_ALLOW_ADMIN` while the server still routed them to `plugins`. The endpoint is canonicalised before matching, and the canonical form is what gets sent.
- **No password on argv.** `wp-upload.sh` used `curl -u "$WP_USER:$WP_APP_PASSWORD"`, which is visible in `ps` to every account on the machine. Both scripts now use a trap-cleaned 0600 netrc file.
- **`--login` / `--logout` exist.** The loader pointed at them; they were never implemented. `wp.sh --login` reads the password from stdin into your OS keychain.

Ownership/mode checks also work on macOS now — they used `stat -c` only, so a valid `WP_ENV_FILE` was rejected outright on a Mac.
Confidence
82% confidence
Finding
Storing or handling the application password via the OS keychain still constitutes credential access and expands the trust boundary to local secret storage APIs. While safer than argv exposure, it creates a persistence path for credentials that could be abused by other local processes, misconfigured keychain ACLs, or users who assume the skill is fully non-persistent.

Credential Access

High
Category
Privilege Escalation
Content
case "$(wp_keychain_kind)" in
    secret-tool) secret-tool store --label="WordPress app password (${WP_HOST})" \
                   service "$WP_KEYCHAIN_SERVICE" host "$WP_HOST" account "$WP_USER" ;;
    security)    security add-generic-password -U -s "${WP_KEYCHAIN_SERVICE}:${WP_HOST}" -a "$WP_USER" -w ;;
    *)           return 1 ;;
  esac
}
Confidence
80% confidence
Finding
On the macOS branch, `security add-generic-password ... -w` is invoked without supplying the password via stdin or a value argument, despite the comment claiming the password arrives on stdin. Depending on how callers use this function, this can cause unsafe prompting behavior, broken credential storage, or pressure to modify the code to pass the secret as a command-line argument, which would expose it to local process inspection.

Static analysis

No suspicious patterns detected.