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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 3.8.1 — 2026-06-10

Soft security guards (ClawHub audit follow-up, non-breaking):

- SEO meta: writing a non-allowlisted (raw) postmeta key now emits a warning; set `WP_REQUIRE_ALLOWLIST=1` to refuse instead. ACF/JetEngine custom fields are unaffected (arbitrary keys are their intended API).
- create_post / update_post: interactive confirmation before `--status publish` when run on a TTY; `--yes`/`-y` bypasses. Non-interactive/agent runs are unchanged.

## 3.8.0 — 2026-06-10

Security hardening (ClawHub audit, safe-additive — no breaking changes):
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.8.0",
"version": "3.8.1",
"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
64 changes: 63 additions & 1 deletion tests/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
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
from security import SafetyError, warn_insecure_wp_url, should_confirm_publish # noqa: E402
from seo_meta import _map_meta_keys # noqa: E402


class WarnInsecureWpUrlTest(unittest.TestCase):
Expand Down Expand Up @@ -63,5 +64,66 @@ def test_wp_require_https_not_triggered_for_local(self):
self.assertEqual(buf.getvalue(), "")


class ShouldConfirmPublishTest(unittest.TestCase):
def test_interactive_publish_returns_true(self):
"""Interactive TTY + publish + no --yes → should prompt."""
self.assertIs(should_confirm_publish("publish", False, True), True)

def test_non_tty_is_silent(self):
"""Non-interactive context (agent/CI) → never prompt, even for publish."""
self.assertIs(should_confirm_publish("publish", False, False), False)

def test_yes_bypass_skips_prompt(self):
"""--yes on a TTY → no prompt."""
self.assertIs(should_confirm_publish("publish", True, True), False)

def test_draft_never_prompts(self):
"""Non-publish statuses never trigger the prompt."""
self.assertIs(should_confirm_publish("draft", False, True), False)
self.assertIs(should_confirm_publish(None, False, True), False)


class SeoMetaRawKeyTest(unittest.TestCase):
"""Unit-test _map_meta_keys directly (no HTTP) for the raw-key warning guard."""

def test_allowlisted_key_passes_through_silently(self):
"""Known friendly names are mapped without warnings."""
payload, warnings = _map_meta_keys({"title": "My Title"}, "rankmath", env={})
self.assertEqual(payload, {"rank_math_title": "My Title"})
self.assertEqual(warnings, [])

def test_raw_key_included_in_payload_and_warns(self):
"""Non-allowlisted key is still written but produces a warning entry."""
payload, warnings = _map_meta_keys({"_custom_raw_key": "val"}, "rankmath", env={})
self.assertIn("_custom_raw_key", payload)
self.assertEqual(payload["_custom_raw_key"], "val")
self.assertEqual(len(warnings), 1)
_key, msg = warnings[0]
self.assertIn("_custom_raw_key", msg)
self.assertIn("not in the rankmath allowlist", msg)

def test_raw_key_warn_message_printed_to_stderr(self):
"""_map_meta_keys itself returns warnings; set_seo_meta prints them."""
import io, contextlib
# Exercise the stderr print path via set_seo_meta with a mocked HTTP layer.
# Here we test _map_meta_keys returns the right warning text.
_payload, warnings = _map_meta_keys({"_raw": "x"}, "yoast", env={})
self.assertTrue(any("not in the yoast allowlist" in msg for _k, msg in warnings))

def test_require_allowlist_env_refuses_raw_key(self):
"""WP_REQUIRE_ALLOWLIST=1 turns the warning into a ValueError (refusal)."""
with self.assertRaises(ValueError) as ctx:
_map_meta_keys({"_raw_key": "val"}, "rankmath", env={"WP_REQUIRE_ALLOWLIST": "1"})
self.assertIn("WP_REQUIRE_ALLOWLIST=1", str(ctx.exception))

def test_require_allowlist_allows_known_keys(self):
"""WP_REQUIRE_ALLOWLIST=1 does NOT block properly allowlisted keys."""
payload, warnings = _map_meta_keys(
{"description": "desc"}, "yoast", env={"WP_REQUIRE_ALLOWLIST": "1"}
)
self.assertEqual(payload, {"_yoast_wpseo_metadesc": "desc"})
self.assertEqual(warnings, [])


if __name__ == "__main__":
unittest.main()
6 changes: 4 additions & 2 deletions wordpress-api-pro/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: wordpress-api-pro
version: 3.8.0
version: 3.8.1
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.
Expand All @@ -12,7 +12,7 @@ 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"
- "WP_ALLOW_REMOTE_URLS, WP_REQUIRE_HTTPS, WP_REQUIRE_ALLOWLIST, PAGESPEED_API_KEY"
network:
- "Outbound HTTPS to the configured WordPress site(s) /wp-json/ REST API"
- "https://www.googleapis.com/pagespeedonline (site_audit only)"
Expand Down Expand Up @@ -44,6 +44,8 @@ This skill runs the `scripts/*.py` directly. From the skill directory (`~/.claud
- **Targeting every site is blocked by default.** Add `--allow-all` only when the user explicitly approved all configured sites.
- **Local file reads are restricted.** `--content-file` and media uploads can read only from the current working directory by default. Set `WP_ALLOWED_FILE_ROOTS` to opt into another safe directory.
- **Remote media URLs are opt-in.** `upload_media.py` requires `--allow-remote-url` or `WP_ALLOW_REMOTE_URLS=1`, allows HTTPS only, and blocks private/local network hosts.
- **Raw SEO meta keys warn by default.** `seo_meta.py` emits a stderr WARNING when writing a key not in the Rank Math / Yoast allowlist. Set `WP_REQUIRE_ALLOWLIST=1` to refuse instead. ACF/JetEngine custom-field keys are unaffected — arbitrary keys are their intended API.
- **Interactive publish confirmation on TTY.** `create_post.py` and `update_post.py` prompt for confirmation before `--status publish` when run interactively. Pass `--yes` / `-y` to bypass. Non-interactive/agent runs are unchanged.

## Authentication

Expand Down
8 changes: 7 additions & 1 deletion wordpress-api-pro/scripts/create_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +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
from security import warn_insecure_wp_url, should_confirm_publish


def _auth(username, password):
Expand Down Expand Up @@ -90,11 +90,17 @@ def main():
p.add_argument('--post-type', default='post')
p.add_argument('--featured-media', type=int)
p.add_argument('--terms', help='JSON {"taxonomy": ["Name or id", ...]}')
p.add_argument('--yes', '-y', action='store_true', help='Skip the interactive publish confirmation.')
a = p.parse_args()
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)
if should_confirm_publish(a.status, a.yes, sys.stdin.isatty()):
print("About to PUBLISH live content to %s. Type 'PUBLISH' to confirm:" % a.url, file=sys.stderr)
if input("> ").strip() != "PUBLISH":

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 Keep publish prompt off JSON stdout

When stdin is a TTY but stdout is being consumed by another tool or redirected (for example, running this interactively with > result.json), input("> ") writes the prompt to stdout before the JSON response. These scripts otherwise reserve stdout for machine-readable JSON, so confirming a publish can corrupt the caller's output; the same pattern was added in update_post.py, and the prompt should be emitted on stderr while reading from stdin.

Useful? React with 👍 / 👎.

print("Aborted: publish not confirmed.", file=sys.stderr)
sys.exit(1)
try:
result = create_post(a.url, a.username, a.app_password, a.title, a.content,
a.status, post_type=a.post_type,
Expand Down
7 changes: 7 additions & 0 deletions wordpress-api-pro/scripts/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ def warn_insecure_wp_url(url, env=None):
return url


def should_confirm_publish(status, assume_yes, is_tty):
"""True only when we should interactively prompt before a live publish:
going to 'publish', not pre-approved with --yes, and attached to a TTY.
Non-interactive (agent/CI) contexts return False -> behavior unchanged."""
return status == "publish" and not assume_yes and bool(is_tty)


def die_safety(error: Exception) -> None:
print(f"Safety error: {error}", file=sys.stderr)
sys.exit(2)
70 changes: 47 additions & 23 deletions wordpress-api-pro/scripts/seo_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import json
import os
import sys
import requests
from base64 import b64encode
from security import warn_insecure_wp_url

Expand All @@ -51,7 +50,7 @@

def detect_seo_plugin(url, username, password, post_id):
"""Detect which SEO plugin is active"""

import requests
credentials = f"{username}:{password}"
auth_header = 'Basic ' + b64encode(credentials.encode()).decode()
headers = {
Expand Down Expand Up @@ -84,7 +83,7 @@ def detect_seo_plugin(url, username, password, post_id):

def get_seo_meta(url, username, password, post_id, plugin=None):
"""Get SEO meta fields"""

import requests
credentials = f"{username}:{password}"
auth_header = 'Basic ' + b64encode(credentials.encode()).decode()
headers = {
Expand Down Expand Up @@ -130,37 +129,62 @@ def get_seo_meta(url, username, password, post_id, plugin=None):
except requests.exceptions.RequestException as e:
return {"error": str(e)}

def set_seo_meta(url, username, password, post_id, meta_dict, plugin='rankmath'):
"""Set SEO meta fields"""

credentials = f"{username}:{password}"
auth_header = 'Basic ' + b64encode(credentials.encode()).decode()
headers = {
'Authorization': auth_header,
'Content-Type': 'application/json'
}

base_url = url.rstrip('/')

# Map friendly names to actual meta keys
def _map_meta_keys(meta_dict, plugin, env=None):
"""Map friendly SEO key names to actual postmeta keys.

Returns (payload_dict, raw_warnings) where payload_dict is ready to POST
and raw_warnings is a list of (key, message) tuples for non-allowlisted keys.
With WP_REQUIRE_ALLOWLIST=1, raises ValueError for the first raw key found.
"""
if env is None:
env = os.environ
keys_map = RANKMATH_KEYS if plugin == 'rankmath' else YOAST_KEYS

meta_payload = {}
payload = {}
raw_warnings = []
for friendly_name, value in meta_dict.items():
if friendly_name in keys_map:
meta_key = keys_map[friendly_name]
# Validate schema if it's Rank Math schema
if friendly_name == 'schema' and plugin == 'rankmath':
try:
# Ensure it's valid JSON
if isinstance(value, str):
json.loads(value)
except json.JSONDecodeError:
return {"error": f"Invalid JSON for schema field"}
meta_payload[meta_key] = value
raise ValueError("Invalid JSON for schema field")
payload[meta_key] = value
else:
# Allow raw meta keys too
meta_payload[friendly_name] = value
# Raw (non-allowlisted) meta key. Allowed by default for flexibility,
# but surfaced so it's never silent. WP_REQUIRE_ALLOWLIST=1 refuses.
msg = (
"SEO meta: '%s' is not in the %s allowlist — writing it as a raw "
"postmeta key." % (friendly_name, plugin)
)
if env.get("WP_REQUIRE_ALLOWLIST") == "1":
raise ValueError(msg + " (WP_REQUIRE_ALLOWLIST=1 set — refusing.)")
raw_warnings.append((friendly_name, msg))
payload[friendly_name] = value
return payload, raw_warnings


def set_seo_meta(url, username, password, post_id, meta_dict, plugin='rankmath'):
"""Set SEO meta fields"""
import requests
credentials = f"{username}:{password}"
auth_header = 'Basic ' + b64encode(credentials.encode()).decode()
headers = {
'Authorization': auth_header,
'Content-Type': 'application/json'
}

base_url = url.rstrip('/')

try:
meta_payload, raw_warnings = _map_meta_keys(meta_dict, plugin)
except ValueError as exc:
return {"error": str(exc)}

for _key, msg in raw_warnings:
print("WARNING: " + msg, file=sys.stderr)

try:
payload = {'meta': meta_payload}
Expand Down
12 changes: 9 additions & 3 deletions 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, warn_insecure_wp_url
from security import SafetyError, TEXT_MAX_BYTES, die_safety, validate_local_file, warn_insecure_wp_url, should_confirm_publish

def update_post(url, username, app_credential, post_id, **updates):
"""Update WordPress post via REST API"""
Expand Down Expand Up @@ -85,14 +85,20 @@ def main():
parser.add_argument('--status', choices=['publish', 'draft', 'pending', 'private'], help='Post status')
parser.add_argument('--featured-media', type=int, help='Featured image ID')
parser.add_argument('--content-file', help='Read content from file')

parser.add_argument('--yes', '-y', action='store_true', help='Skip the interactive publish confirmation.')

args = parser.parse_args()

# Validate required args
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 should_confirm_publish(args.status, args.yes, sys.stdin.isatty()):
print("About to PUBLISH live content to %s. Type 'PUBLISH' to confirm:" % args.url, file=sys.stderr)
if input("> ").strip() != "PUBLISH":
print("Aborted: publish not confirmed.", file=sys.stderr)
sys.exit(1)
if not args.username:
print(json.dumps({"error": "Username required (--username or WP_USERNAME)"}), file=sys.stderr)
sys.exit(1)
Expand Down
Loading