Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 3.8.0 — 2026-06-10

Security hardening (ClawHub audit, safe-additive — no breaking changes):

- Warn on plaintext http:// WordPress URLs (Basic-Auth credentials would be sent in cleartext); set WP_REQUIRE_HTTPS=1 to refuse instead. Localhost/dev hosts exempt.
- SKILL.md description now discloses the no-auth site-audit / fingerprinting capability.
- Added an explicit permissions declaration (env / network / filesystem / shell).

## 3.7.1 - 2026-06-04
- ClawHub listing now publishes under the display name **WordPress API Pro** (`--name`) with a pinned slug (`--slug wordpress-api-pro`), instead of an auto-title-cased "Wordpress Api Pro".

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wordpress-api-pro",
"version": "3.7.1",
"version": "3.8.0",
"description": "WordPress REST API integration skill for OpenClaw - manage posts, pages, media, WooCommerce, Elementor, and metadata with explicit safety boundaries",
"private": true,
"main": "wordpress-api-pro/SKILL.md",
Expand Down
67 changes: 67 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import os, sys, unittest

SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "wordpress-api-pro", "scripts")
sys.path.insert(0, os.path.abspath(SCRIPTS))

from security import SafetyError, warn_insecure_wp_url # noqa: E402


class WarnInsecureWpUrlTest(unittest.TestCase):
def test_warns_on_http_nonlocal(self):
"""http:// on a public host prints a SECURITY WARNING to stderr."""
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
result = warn_insecure_wp_url("http://example.com", env={})
self.assertIn("SECURITY WARNING", buf.getvalue())
self.assertEqual(result, "http://example.com") # url returned unchanged

def test_silent_on_https(self):
"""https:// never triggers a warning."""
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
warn_insecure_wp_url("https://example.com", env={})
self.assertEqual(buf.getvalue(), "")

def test_silent_on_localhost_http(self):
"""http:// on localhost/dev hosts is exempt — no warning."""
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
warn_insecure_wp_url("http://localhost:8080", env={})
warn_insecure_wp_url("http://site.local", env={})
self.assertEqual(buf.getvalue(), "")

def test_raises_when_wp_require_https_set(self):
"""WP_REQUIRE_HTTPS=1 upgrades the warning to a SafetyError."""
with self.assertRaises(SafetyError):
warn_insecure_wp_url("http://example.com", env={"WP_REQUIRE_HTTPS": "1"})

def test_silent_on_dot_test_host(self):
"""http://*.test hosts are treated as local dev — no warning."""
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
warn_insecure_wp_url("http://mysite.test", env={})
self.assertEqual(buf.getvalue(), "")

def test_silent_on_dot_localhost_host(self):
"""http://*.localhost hosts are treated as local dev — no warning."""
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
warn_insecure_wp_url("http://app.localhost", env={})
self.assertEqual(buf.getvalue(), "")

def test_wp_require_https_not_triggered_for_local(self):
"""WP_REQUIRE_HTTPS=1 does NOT raise for localhost — local is always exempt."""
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
warn_insecure_wp_url("http://localhost", env={"WP_REQUIRE_HTTPS": "1"})
self.assertEqual(buf.getvalue(), "")


if __name__ == "__main__":
unittest.main()
14 changes: 13 additions & 1 deletion wordpress-api-pro/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
---
name: wordpress-api-pro
version: 3.7.1
version: 3.8.0
license: MIT-0
description: |
Production-grade WordPress REST API integration for managing posts, pages, media, WooCommerce products, Elementor content, SEO meta, ACF, and JetEngine fields.
Use when you need to retrieve, draft, create, or update WordPress content programmatically on sites where the user has provided explicit credentials.
For any operation that writes to a live site, get explicit user approval for the target site, post/product IDs, and final action before executing.
Prefer drafts first. Run batch operations in dry-run mode first; use --execute only after review. Remote URL media downloads and local file reads are restricted by safety boundaries.
Also includes a no-auth Tier-1 site audit (PageSpeed, SSL, security headers, CMS/PHP fingerprint, SEO basics) for cold pre-sale checks, and authenticated plugin/SEO-stack discovery.
permissions:
env:
- "WP_URL / WP_SITE_URL, WP_USERNAME / WP_USER, WP_APP_PASSWORD (auth)"
- "WP_CONFIG (optional sites.json path), WP_ALLOWED_FILE_ROOTS (file-read scope)"
- "WP_ALLOW_REMOTE_URLS, WP_REQUIRE_HTTPS, PAGESPEED_API_KEY"
network:
- "Outbound HTTPS to the configured WordPress site(s) /wp-json/ REST API"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disclose HTTP egress in permissions

When a user supplies an http:// WordPress URL, the new warn_insecure_wp_url() path only warns by default and the scripts still send the request unless WP_REQUIRE_HTTPS=1 is set, so this permission entry underreports the skill's actual network access. This matters for the newly added least-privilege disclosure because local-dev HTTP is explicitly supported and public HTTP is still allowed with a warning; the permission should include HTTP egress or say HTTPS is recommended/enforceable rather than exclusive.

Useful? React with 👍 / 👎.

- "https://www.googleapis.com/pagespeedonline (site_audit only)"
filesystem:
- "Read-only, scoped to WP_ALLOWED_FILE_ROOTS (default: cwd)"
shell: "none (Python only; no shell-out)"
---

# WordPress API Pro
Expand Down
10 changes: 6 additions & 4 deletions wordpress-api-pro/scripts/acf_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import sys
import requests
from base64 import b64encode
from security import warn_insecure_wp_url

def get_acf_fields(url, username, password, post_id, field_name=None):
"""Get ACF fields via REST API (with postmeta fallback)"""
Expand Down Expand Up @@ -131,18 +132,19 @@ def main():

# Validate required args
if not args.url:
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.username:
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.app_password:
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
file=sys.stderr)
sys.exit(1)

warn_insecure_wp_url(args.url)

try:
# Set operation
if args.set_json:
Expand Down
9 changes: 8 additions & 1 deletion wordpress-api-pro/scripts/batch_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
import urllib.error
from base64 import b64encode

# security.py lives alongside this script; insert its directory on the path if needed.
import importlib.util as _ilu, pathlib as _pl
if not _ilu.find_spec("security"):
sys.path.insert(0, str(_pl.Path(__file__).parent))
from security import warn_insecure_wp_url

def load_config(config_path=None):
"""Load sites configuration"""
if config_path is None:
Expand All @@ -44,7 +50,8 @@ def update_post(site, post_id, updates, dry_run=False):
if dry_run:
print(f" [DRY RUN] Would update post {post_id}: {updates}")
return True


warn_insecure_wp_url(site['url'])
credentials = f"{site['username']}:{site['app_password']}".encode('utf-8')
auth_header = b64encode(credentials).decode('ascii')

Expand Down
2 changes: 2 additions & 0 deletions wordpress-api-pro/scripts/create_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""Create a WordPress post or CPT entry via REST API (with taxonomy support)."""
import argparse, json, os, sys, urllib.request, urllib.parse
from base64 import b64encode
from security import warn_insecure_wp_url


def _auth(username, password):
Expand Down Expand Up @@ -93,6 +94,7 @@ def main():
if not all([a.url, a.username, a.app_password]):
print(json.dumps({"error": "Missing required credentials"}), file=sys.stderr)
sys.exit(1)
warn_insecure_wp_url(a.url)
try:
result = create_post(a.url, a.username, a.app_password, a.title, a.content,
a.status, post_type=a.post_type,
Expand Down
10 changes: 6 additions & 4 deletions wordpress-api-pro/scripts/detect_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import sys
import requests
from base64 import b64encode
from security import warn_insecure_wp_url

def detect_plugins(url, username, password, verbose=False):
"""Detect WordPress plugins via REST API"""
Expand Down Expand Up @@ -127,18 +128,19 @@ def main():

# Validate required args
if not args.url:
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.username:
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.app_password:
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
file=sys.stderr)
sys.exit(1)

warn_insecure_wp_url(args.url)

try:
plugins = detect_plugins(args.url, args.username, args.app_password, args.verbose)
print(json.dumps(plugins, indent=2))
Expand Down
2 changes: 2 additions & 0 deletions wordpress-api-pro/scripts/elementor_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""Manage Elementor page content via REST API"""
import argparse, json, os, sys, urllib.request
from base64 import b64encode
from security import warn_insecure_wp_url

def get_elementor_data(url, username, password, post_id):
"""Get Elementor data for a page"""
Expand Down Expand Up @@ -127,6 +128,7 @@ def update_widget_content(elements, widget_id, content):
if not all([args.url, args.username, args.app_password]):
print(json.dumps({"error": "Missing credentials"}), file=sys.stderr)
sys.exit(1)
warn_insecure_wp_url(args.url)

if args.action == 'get':
result = get_elementor_data(args.url, args.username, args.app_password, args.post_id)
Expand Down
2 changes: 2 additions & 0 deletions wordpress-api-pro/scripts/get_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""Get WordPress post via REST API"""
import argparse, json, os, sys, urllib.request
from base64 import b64encode
from security import warn_insecure_wp_url

parser = argparse.ArgumentParser(description='Get WordPress post')
parser.add_argument('--url', default=os.getenv('WP_URL'))
Expand All @@ -13,6 +14,7 @@
if not all([args.url, args.username, args.app_password]):
print(json.dumps({"error": "Missing credentials"}), file=sys.stderr)
sys.exit(1)
warn_insecure_wp_url(args.url)

api_url = f"{args.url.rstrip('/')}/wp-json/wp/v2/posts/{args.post_id}"
credentials = f"{args.username}:{args.app_password}".encode('utf-8')
Expand Down
10 changes: 6 additions & 4 deletions wordpress-api-pro/scripts/jetengine_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import sys
import requests
from base64 import b64encode
from security import warn_insecure_wp_url

def get_jetengine_fields(url, username, password, post_id, field_name=None):
"""Get JetEngine fields (stored as postmeta)"""
Expand Down Expand Up @@ -111,18 +112,19 @@ def main():

# Validate required args
if not args.url:
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.username:
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.app_password:
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
file=sys.stderr)
sys.exit(1)

warn_insecure_wp_url(args.url)

try:
# Set operation
if args.set_json:
Expand Down
2 changes: 2 additions & 0 deletions wordpress-api-pro/scripts/list_posts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""List WordPress posts via REST API"""
import argparse, json, os, sys, urllib.request, urllib.parse
from base64 import b64encode
from security import warn_insecure_wp_url

parser = argparse.ArgumentParser(description='List WordPress posts')
parser.add_argument('--url', default=os.getenv('WP_URL'))
Expand All @@ -16,6 +17,7 @@
if not all([args.url, args.username, args.app_password]):
print(json.dumps({"error": "Missing credentials"}), file=sys.stderr)
sys.exit(1)
warn_insecure_wp_url(args.url)

params = {'per_page': args.per_page, 'page': args.page, 'status': args.status}
if args.author:
Expand Down
25 changes: 25 additions & 0 deletions wordpress-api-pro/scripts/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,31 @@ def fetch_https_media(url: str, *, timeout: int = 20, max_bytes: int = DEFAULT_M
return response, body


def warn_insecure_wp_url(url, env=None):
"""Warn when a WordPress API URL is plaintext http:// on a non-local host.
Basic-Auth credentials would travel unencrypted. Localhost/dev hosts are exempt.
With WP_REQUIRE_HTTPS=1 this raises SafetyError instead of warning.
Returns the url unchanged (never mutates it)."""
env = env if env is not None else os.environ
parsed = urllib.parse.urlparse(url if "://" in str(url) else "https://" + str(url))
host = (parsed.hostname or "").lower()
is_local = (
host in ("localhost", "127.0.0.1", "0.0.0.0", "::1")
or host.endswith(".local")
or host.endswith(".test")
or host.endswith(".localhost")
)
if parsed.scheme == "http" and not is_local:
msg = (
"SECURITY WARNING: WordPress URL '%s' uses plaintext http:// — "
"Basic-Auth credentials will be sent unencrypted. Use https:// in production." % url
)
if env.get("WP_REQUIRE_HTTPS") == "1":
raise SafetyError(msg + " (WP_REQUIRE_HTTPS=1 set — refusing.)")
print(msg, file=sys.stderr)
return url


def die_safety(error: Exception) -> None:
print(f"Safety error: {error}", file=sys.stderr)
sys.exit(2)
2 changes: 2 additions & 0 deletions wordpress-api-pro/scripts/seed_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Env: WP_URL/WP_SITE_URL, WP_USERNAME/WP_USER, WP_APP_PASSWORD
"""
import argparse, json, os, sys
from security import warn_insecure_wp_url

# NB: the write-path modules (acf_fields/jetengine_fields) import `requests`, and
# the image path needs upload_media. They are imported lazily inside seed() so the
Expand Down Expand Up @@ -98,6 +99,7 @@ def main():

if not all([a.url, a.username, a.app_password]):
print(json.dumps({"error": "Missing required credentials"}), file=sys.stderr); sys.exit(1)
warn_insecure_wp_url(a.url)
result = seed(a.url, a.username, a.app_password, dataset, allow_remote=a.allow_remote_url)
print(json.dumps(result, indent=2))
if result['failed']:
Expand Down
10 changes: 6 additions & 4 deletions wordpress-api-pro/scripts/seo_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import sys
import requests
from base64 import b64encode
from security import warn_insecure_wp_url

# Meta key mappings
RANKMATH_KEYS = {
Expand Down Expand Up @@ -194,18 +195,19 @@ def main():

# Validate required args
if not args.url:
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.username:
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}),
file=sys.stderr)
sys.exit(1)
if not args.app_password:
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}),
file=sys.stderr)
sys.exit(1)

warn_insecure_wp_url(args.url)

try:
# Detect only
if args.detect:
Expand Down
3 changes: 2 additions & 1 deletion wordpress-api-pro/scripts/update_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import urllib.error
from base64 import b64encode

from security import SafetyError, TEXT_MAX_BYTES, die_safety, validate_local_file
from security import SafetyError, TEXT_MAX_BYTES, die_safety, validate_local_file, warn_insecure_wp_url

def update_post(url, username, app_credential, post_id, **updates):
"""Update WordPress post via REST API"""
Expand Down Expand Up @@ -92,6 +92,7 @@ def main():
if not args.url:
print(json.dumps({"error": "WordPress URL required (--url or WP_URL)"}), file=sys.stderr)
sys.exit(1)
warn_insecure_wp_url(args.url)
if not args.username:
print(json.dumps({"error": "Username required (--username or WP_USERNAME)"}), file=sys.stderr)
sys.exit(1)
Expand Down
Loading
Loading