# IGNY8 ↔ WordPress Integration Security Plan **Date:** 2026-01-13 **Status:** Audit Complete - Plan Ready --- ## PART 1: ACTUAL ARCHITECTURE (VERIFIED) ### 1.1 Publishing Flow (WORKING - No Changes Needed) ``` Frontend (Review/Approved pages) │ ▼ POST /v1/publisher/publish/ │ ▼ PublisherService._publish_to_destination() │ ├── Gets API key: content.site.wp_api_key (SINGLE SOURCE OF TRUTH) ├── Gets URL: content.site.domain or content.site.url │ ▼ WordPressAdapter._publish_via_api_key() │ ▼ POST {site_url}/wp-json/igny8/v1/publish Headers: X-IGNY8-API-KEY: {api_key} │ ▼ Plugin: check_permission() validates header against stored igny8_api_key │ ▼ Plugin creates/updates WordPress post ``` **Key Finding:** Publishing does NOT use SiteIntegration model. It uses: - `Site.wp_api_key` - for authentication - `Site.domain` - for WordPress URL ### 1.2 Data Storage Locations | Location | Field | Purpose | Used By | |----------|-------|---------|---------| | **Django Site model** | `wp_api_key` | API key (source of truth) | Publishing, test-connection | | **Django Site model** | `domain` | WordPress URL | Publishing | | **WordPress wp_options** | `igny8_api_key` | Stored copy of API key | Plugin auth check | | **WordPress wp_options** | `igny8_site_id` | Site ID from key | Plugin display | | **WordPress wp_options** | `igny8_integration_id` | Integration ID | Plugin (currently unused) | | **WordPress wp_options** | `igny8_last_structure_sync` | Last sync timestamp | Connection status UI | ### 1.3 SiteIntegration Model Status **Table exists:** `igny8_site_integrations` **Records:** 0 (empty) **Used by:** - `publishing_scheduler.py` - scheduled publishing (requires record) - `wordpress_publishing.py` - Celery task path (requires record) - `sync_metadata_service.py` - metadata sync (requires record) - `ContentViewSet.publish` action (requires record) **NOT used by:** - `PublisherService` (main UI publishing path) - uses Site directly - `WordPressAdapter` - uses Site.wp_api_key directly --- ## PART 2: SECURITY ISSUES FOUND ### 2.1 Plugin Public Endpoints (SECURITY RISK) | Endpoint | Current Permission | Risk | Data Exposed | |----------|-------------------|------|--------------| | `/igny8/v1/status` | `__return_true` (PUBLIC) | Medium | Plugin installed, has_api_key, plugin version | | `/igny8/v1/site-metadata/` | `__return_true` (PUBLIC*) | Low | Post types, taxonomies (auth checked inside) | *Note: `/site-metadata/` checks permission inside callback but still registers as public ### 2.2 Backend test-connection Flow The `test_connection_collection` endpoint: 1. Uses `AllowAny` permission class 2. BUT validates authentication inside the handler 3. Calls public `/status` endpoint to check if plugin installed 4. Then calls authenticated `/verify-key` endpoint **Issue:** Relies on public `/status` endpoint to detect plugin presence. --- ## PART 3: RECOMMENDED CHANGES ### 3.1 Plugin Changes (REQUIRED) #### 3.1.1 Make `/status` Endpoint Authenticated **File:** `plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-rest-api.php` **Current (lines 83-87):** ```php register_rest_route('igny8/v1', '/status', array( 'methods' => 'GET', 'callback' => array($this, 'get_status'), 'permission_callback' => '__return_true', // Public endpoint for health checks )); ``` **Change to:** ```php register_rest_route('igny8/v1', '/status', array( 'methods' => 'GET', 'callback' => array($this, 'get_status'), 'permission_callback' => array($this, 'check_api_key_auth'), )); ``` #### 3.1.2 Add Lightweight Auth Check Method Add new method to allow API key OR no key if not configured yet: ```php /** * Check API key authentication for status-type endpoints * Returns true if: no API key stored yet, OR valid API key provided */ public function check_api_key_auth($request) { $stored_api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key'); // If no API key configured yet, allow access (plugin not connected) if (empty($stored_api_key)) { return true; } // If API key is configured, require valid key in header $header_api_key = $request->get_header('x-igny8-api-key'); if ($header_api_key && hash_equals($stored_api_key, $header_api_key)) { return true; } return new WP_Error('rest_forbidden', 'Invalid API key', array('status' => 401)); } ``` #### 3.1.3 Fix `/site-metadata/` Route Registration **Current (lines 74-79):** ```php register_rest_route('igny8/v1', '/site-metadata/', array( 'methods' => 'GET', 'callback' => array($this, 'get_site_metadata'), 'permission_callback' => '__return_true', )); ``` **Change to:** ```php register_rest_route('igny8/v1', '/site-metadata/', array( 'methods' => 'GET', 'callback' => array($this, 'get_site_metadata'), 'permission_callback' => array($this, 'check_permission'), )); ``` Then remove the internal permission check from `get_site_metadata()` method. ### 3.2 Backend Changes (REQUIRED) #### 3.2.1 Update test-connection to Not Rely on Public Endpoint **File:** `backend/igny8_core/modules/integration/views.py` **Current approach:** 1. Call `/status` (public) to check plugin installed 2. Call `/verify-key` (authenticated) to verify key **New approach:** 1. Call `/verify-key` directly 2. If 200 → plugin installed AND key valid 3. If 404 → plugin not installed (route doesn't exist) 4. If 401/403 → plugin installed but key mismatch **Change Check 2 section (approximately lines 218-237):** ```python # Check 2 & 3 Combined: Plugin installed + API key verification # Use /verify-key endpoint - if it succeeds, both are confirmed try: verify_response = http_requests.get( f"{site_url.rstrip('/')}/wp-json/igny8/v1/verify-key", headers={ 'X-IGNY8-API-KEY': stored_api_key, 'Content-Type': 'application/json' }, timeout=10 ) if verify_response.status_code == 200: health_checks['plugin_installed'] = True health_checks['plugin_has_api_key'] = True health_checks['api_key_verified'] = True elif verify_response.status_code == 404: # Route not found = plugin not installed issues.append("IGNY8 plugin not installed on WordPress site") elif verify_response.status_code in [401, 403]: # Auth failed = plugin installed but key doesn't match health_checks['plugin_installed'] = True issues.append("API key mismatch - copy the API key from IGNY8 to WordPress plugin settings") else: issues.append(f"Unexpected response from plugin: HTTP {verify_response.status_code}") except http_requests.exceptions.ConnectionError: issues.append("Cannot connect to WordPress site") except Exception as e: issues.append(f"Connection error: {str(e)}") ``` ### 3.3 SiteIntegration Decision (OPTIONAL) The SiteIntegration model is used by: - Scheduled publishing task - ContentViewSet.publish action - Metadata sync service **Options:** **Option A: Remove SiteIntegration entirely** - Modify scheduled publishing to use Site directly - Modify sync service to use Site directly - Remove model and migrations - **Effort:** High, risk of breaking things **Option B: Keep but don't require it** - Current main publishing path works without it - Scheduled publishing would need records created - **Effort:** Low, minimal changes **Recommendation:** Option B - Keep as-is. The main UI publishing flow works. If scheduled publishing is needed, run: ```bash docker exec igny8_backend python manage.py sync_wordpress_api_keys ``` --- ## PART 4: IMPLEMENTATION ORDER ### Phase 1: Plugin Security (30 min) 1. Add `check_api_key_auth()` method to plugin 2. Change `/status` endpoint to use `check_api_key_auth` 3. Change `/site-metadata/` to use `check_permission` 4. Remove internal permission check from `get_site_metadata()` 5. Build and deploy plugin ### Phase 2: Backend Update (15 min) 1. Update `test_connection_collection` to use only `/verify-key` 2. Remove `/status` endpoint call 3. Test connection flow ### Phase 3: Testing (15 min) 1. Test plugin uninstalled scenario (404 from /verify-key) 2. Test plugin installed, no key (401 from /verify-key) 3. Test plugin installed, wrong key (401 from /verify-key) 4. Test plugin installed, correct key (200 from /verify-key) 5. Test publishing still works --- ## PART 5: FILES TO MODIFY ### Plugin Files | File | Changes | |------|---------| | `includes/class-igny8-rest-api.php` | Add `check_api_key_auth()`, update route permissions | ### Backend Files | File | Changes | |------|---------| | `modules/integration/views.py` | Update `test_connection_collection` logic | --- ## PART 6: WHAT STAYS THE SAME ✅ **Publishing flow** - No changes needed ✅ **Site.wp_api_key** - Single source of truth ✅ **Site.domain** - WordPress URL source ✅ **WordPressAdapter** - Works correctly ✅ **Plugin /publish endpoint** - Already authenticated ✅ **Plugin /verify-key endpoint** - Already authenticated ✅ **SiteIntegration model** - Keep for scheduled publishing (optional use) --- ## Document History | Date | Author | Changes | |------|--------|---------| | 2026-01-13 | AI | Created after proper code audit |