298 lines
9.3 KiB
Markdown
298 lines
9.3 KiB
Markdown
# 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 |
|