diff --git a/CHANGELOG.md b/CHANGELOG.md index d767829..35416d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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): diff --git a/package.json b/package.json index 746f81d..bf23573 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/test_security.py b/tests/test_security.py index 5098146..e27559c 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -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): @@ -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() diff --git a/wordpress-api-pro/SKILL.md b/wordpress-api-pro/SKILL.md index 6ca48c5..722dd38 100644 --- a/wordpress-api-pro/SKILL.md +++ b/wordpress-api-pro/SKILL.md @@ -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. @@ -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)" @@ -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 diff --git a/wordpress-api-pro/scripts/create_post.py b/wordpress-api-pro/scripts/create_post.py index 7054542..387c080 100755 --- a/wordpress-api-pro/scripts/create_post.py +++ b/wordpress-api-pro/scripts/create_post.py @@ -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): @@ -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": + 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, diff --git a/wordpress-api-pro/scripts/security.py b/wordpress-api-pro/scripts/security.py index 9a9f22d..170d25d 100644 --- a/wordpress-api-pro/scripts/security.py +++ b/wordpress-api-pro/scripts/security.py @@ -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) diff --git a/wordpress-api-pro/scripts/seo_meta.py b/wordpress-api-pro/scripts/seo_meta.py index ca5e054..9c43b69 100755 --- a/wordpress-api-pro/scripts/seo_meta.py +++ b/wordpress-api-pro/scripts/seo_meta.py @@ -26,7 +26,6 @@ import json import os import sys -import requests from base64 import b64encode from security import warn_insecure_wp_url @@ -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 = { @@ -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 = { @@ -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} diff --git a/wordpress-api-pro/scripts/update_post.py b/wordpress-api-pro/scripts/update_post.py index 64e41ac..726a045 100755 --- a/wordpress-api-pro/scripts/update_post.py +++ b/wordpress-api-pro/scripts/update_post.py @@ -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""" @@ -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)