Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
12 KiB
Python
326 lines
12 KiB
Python
"""F.18.7 — Companion audit-hook receiver + per-site secret store.
|
|
|
|
The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress
|
|
action events (post transitions, user events, plugin activations, etc.)
|
|
to MCPHub as HMAC-SHA256-signed webhooks. This module provides:
|
|
|
|
1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``.
|
|
2. ``verify_companion_signature`` — constant-time HMAC verification.
|
|
3. ``handle_companion_audit_request`` — Starlette handler that validates
|
|
the signature, appends the event to the audit log, and returns 200.
|
|
|
|
The store is intentionally independent of the SQLite sites DB so the
|
|
webhook can land before a dashboard UI exists; the UI (future work) can
|
|
replace ``CompanionAuditSecretStore`` with DB-backed storage without
|
|
changing the wire format.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
logger = logging.getLogger("mcphub.companion_audit")
|
|
|
|
|
|
def _parse_timestamp(raw: Any) -> float | None:
|
|
"""Coerce the envelope ``timestamp`` field to epoch seconds.
|
|
|
|
Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the
|
|
companion plugin's current format via PHP ``gmdate``) or a numeric
|
|
epoch value (for forward compatibility with other senders).
|
|
Returns None on any parse failure so callers can emit a uniform
|
|
401 without leaking which step failed.
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, int | float):
|
|
return float(raw)
|
|
if isinstance(raw, str):
|
|
s = raw.strip()
|
|
if not s:
|
|
return None
|
|
# Python 3.11+ fromisoformat handles "Z" suffix; on older
|
|
# versions we translate it to +00:00 explicitly.
|
|
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
|
|
try:
|
|
return datetime.fromisoformat(iso).timestamp()
|
|
except ValueError:
|
|
pass
|
|
# Numeric-string fallback (e.g. "1712345678").
|
|
try:
|
|
return float(s)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
# Pre-F.20 security sweep: bound incoming payload so a misconfigured
|
|
# (or malicious) companion can't queue up unbounded memory with a
|
|
# single request. Real audit events are well under 4 KB; 64 KB is a
|
|
# generous ceiling that still fits within Starlette's default limit.
|
|
_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024)))
|
|
|
|
# Replay-protection window (seconds). Events whose ``timestamp`` field
|
|
# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401.
|
|
# Set to 0 or negative to disable (not recommended). Default 5 minutes
|
|
# balances clock-skew tolerance against replay risk.
|
|
_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300"))
|
|
|
|
|
|
def _normalise_url(url: str) -> str:
|
|
return url.rstrip("/").strip().lower()
|
|
|
|
|
|
class CompanionAuditSecretStore:
|
|
"""File-backed ``site_url -> shared_secret`` map.
|
|
|
|
The file is JSON; keys are normalised (lowercased, trailing slash
|
|
stripped). Access is serialised through a lock to keep reads and
|
|
writes atomic on a single-process server. Multi-process deployments
|
|
(gunicorn workers) can still race on the write path — that's fine
|
|
because the dashboard UI will be the only writer and operates in
|
|
the master process for this MVP.
|
|
"""
|
|
|
|
def __init__(self, path: str | Path) -> None:
|
|
self.path = Path(path)
|
|
self._lock = threading.Lock()
|
|
self._cache: dict[str, str] | None = None
|
|
|
|
def _load(self) -> dict[str, str]:
|
|
if self._cache is not None:
|
|
return self._cache
|
|
if not self.path.exists():
|
|
self._cache = {}
|
|
return self._cache
|
|
try:
|
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
logger.warning(
|
|
"companion audit secret file %s is not a JSON object; ignoring.",
|
|
self.path,
|
|
)
|
|
self._cache = {}
|
|
else:
|
|
self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v}
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
logger.warning("Could not read companion audit secret file %s: %s", self.path, exc)
|
|
self._cache = {}
|
|
return self._cache
|
|
|
|
def _save(self, data: dict[str, str]) -> None:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|
|
tmp.replace(self.path)
|
|
try:
|
|
os.chmod(self.path, 0o600)
|
|
except OSError:
|
|
pass
|
|
self._cache = dict(data)
|
|
|
|
def get(self, site_url: str) -> str | None:
|
|
with self._lock:
|
|
return self._load().get(_normalise_url(site_url))
|
|
|
|
def set(self, site_url: str, secret: str) -> None:
|
|
if not secret or len(secret) < 16:
|
|
raise ValueError("companion audit secret must be at least 16 characters")
|
|
with self._lock:
|
|
data = dict(self._load())
|
|
data[_normalise_url(site_url)] = secret
|
|
self._save(data)
|
|
|
|
def delete(self, site_url: str) -> bool:
|
|
with self._lock:
|
|
data = dict(self._load())
|
|
key = _normalise_url(site_url)
|
|
if key not in data:
|
|
return False
|
|
del data[key]
|
|
self._save(data)
|
|
return True
|
|
|
|
def list_sites(self) -> list[dict[str, Any]]:
|
|
"""List sites with secret metadata only — never returns plaintext."""
|
|
with self._lock:
|
|
return [
|
|
{
|
|
"site_url": site_url,
|
|
"secret_set": True,
|
|
"secret_last4": secret[-4:] if len(secret) >= 4 else "",
|
|
}
|
|
for site_url, secret in self._load().items()
|
|
]
|
|
|
|
|
|
_DEFAULT_STORE_PATH = Path(
|
|
os.environ.get(
|
|
"COMPANION_AUDIT_SECRETS_PATH",
|
|
"/tmp/mcphub-data/companion-audit-secrets.json",
|
|
)
|
|
)
|
|
_default_store: CompanionAuditSecretStore | None = None
|
|
|
|
|
|
def get_companion_audit_store(
|
|
path: str | Path | None = None,
|
|
) -> CompanionAuditSecretStore:
|
|
global _default_store
|
|
if path is not None:
|
|
return CompanionAuditSecretStore(path)
|
|
if _default_store is None:
|
|
_default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH)
|
|
return _default_store
|
|
|
|
|
|
def verify_companion_signature(
|
|
body_bytes: bytes, signature_header: str | None, secret: str
|
|
) -> bool:
|
|
"""Constant-time HMAC-SHA256 verification.
|
|
|
|
Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant.
|
|
Returns False on any shape error so callers can emit 401 uniformly.
|
|
"""
|
|
if not signature_header or not secret:
|
|
return False
|
|
sig = signature_header.strip()
|
|
if sig.startswith("sha256="):
|
|
sig = sig[len("sha256=") :]
|
|
if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig):
|
|
return False
|
|
expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(expected.lower(), sig.lower())
|
|
|
|
|
|
# Accepted event names — mirror the PHP side. Events outside this list
|
|
# are still accepted but tagged so operators can spot rogue sources.
|
|
_KNOWN_EVENTS = frozenset(
|
|
{
|
|
"transition_post_status",
|
|
"deleted_post",
|
|
"user_register",
|
|
"profile_update",
|
|
"deleted_user",
|
|
"activated_plugin",
|
|
"deactivated_plugin",
|
|
"switch_theme",
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_companion_audit_request(request: Request) -> JSONResponse:
|
|
"""Starlette handler for ``POST /api/companion-audit``.
|
|
|
|
Validates the HMAC signature using a per-site secret, parses the
|
|
envelope, applies a replay window on the ``timestamp`` field, and
|
|
writes an audit-log entry. Returns 200 on success, 400 on shape
|
|
errors, 401 on signature/replay failure, 413 on oversized body.
|
|
"""
|
|
# Pre-F.20 security sweep: bound the incoming body before reading the
|
|
# whole thing. Prefer the framework's Content-Length hint; fall back
|
|
# to reading up to one byte past the ceiling so we can cleanly 413.
|
|
content_length_header = request.headers.get("content-length")
|
|
if content_length_header:
|
|
try:
|
|
content_length = int(content_length_header)
|
|
except ValueError:
|
|
return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400)
|
|
if content_length > _MAX_BODY_BYTES:
|
|
return JSONResponse(
|
|
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
|
|
status_code=413,
|
|
)
|
|
|
|
# Read raw body — we need the exact bytes for HMAC verification.
|
|
body_bytes = await request.body()
|
|
if len(body_bytes) > _MAX_BODY_BYTES:
|
|
return JSONResponse(
|
|
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
|
|
status_code=413,
|
|
)
|
|
if not body_bytes:
|
|
return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400)
|
|
|
|
site_header = request.headers.get("X-Airano-MCP-Site") or ""
|
|
signature = request.headers.get("X-Airano-MCP-Signature")
|
|
|
|
try:
|
|
envelope = json.loads(body_bytes.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
|
|
|
|
if not isinstance(envelope, dict):
|
|
return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400)
|
|
|
|
site_url = str(envelope.get("site_url") or site_header or "")
|
|
if not site_url:
|
|
return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400)
|
|
|
|
store = get_companion_audit_store()
|
|
secret = store.get(site_url)
|
|
if secret is None:
|
|
# Don't leak whether the site exists; same response as a bad sig.
|
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
|
|
|
if not verify_companion_signature(body_bytes, signature, secret):
|
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
|
|
|
# Pre-F.20 security sweep: enforce a replay window on the signed
|
|
# timestamp. Captured webhooks replayed outside the window are
|
|
# rejected with 401 (same opaque response as a bad signature to
|
|
# avoid giving attackers a distinguishing oracle).
|
|
if _REPLAY_WINDOW_SECONDS > 0:
|
|
ts = _parse_timestamp(envelope.get("timestamp"))
|
|
if ts is None:
|
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
|
now = time.time()
|
|
if abs(now - ts) > _REPLAY_WINDOW_SECONDS:
|
|
logger.warning(
|
|
"companion audit replay rejected site=%s skew=%.1fs",
|
|
site_url,
|
|
now - ts,
|
|
)
|
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
|
|
|
event_name = str(envelope.get("event") or "unknown")
|
|
details = {
|
|
"site_url": site_url,
|
|
"event": event_name,
|
|
"known_event": event_name in _KNOWN_EVENTS,
|
|
"timestamp": envelope.get("timestamp"),
|
|
"wp_user_id": envelope.get("user_id"),
|
|
"plugin_version": envelope.get("plugin_version"),
|
|
"data": envelope.get("data"),
|
|
}
|
|
|
|
try:
|
|
# Deferred import — avoid pulling audit_log into the store for tests.
|
|
from core.audit_log import get_audit_logger
|
|
|
|
audit_logger = get_audit_logger()
|
|
audit_logger.log_system_event(
|
|
event=f"companion_audit:{event_name}",
|
|
details=details,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("failed to append companion audit event: %s", exc)
|
|
return JSONResponse(
|
|
{"ok": False, "error": "audit_write_failed"},
|
|
status_code=500,
|
|
)
|
|
|
|
return JSONResponse({"ok": True, "event": event_name}, status_code=200)
|