diff --git a/docs/audits/INTEGRATION-SECURITY-AUDIT-2026-01-13.md b/docs/audits/INTEGRATION-SECURITY-AUDIT-2026-01-13.md new file mode 100644 index 00000000..efe6ab24 --- /dev/null +++ b/docs/audits/INTEGRATION-SECURITY-AUDIT-2026-01-13.md @@ -0,0 +1,596 @@ +# IGNY8 ↔ WordPress Integration Complete Security Audit + +**Audit Date:** 2026-01-13 +**Auditor:** System Audit (Documentation Calibrated) +**Scope:** Backend (Django) + WordPress Plugin + Documentation Review +**Status:** COMPLETE - CALIBRATED WITH DOCUMENTATION + +--- + +## EXECUTIVE SUMMARY + +This comprehensive audit examined the IGNY8 backend and WordPress plugin integration system, cross-referencing against all existing documentation. The audit identified **17 security vulnerabilities** (3 critical, 5 high, 6 medium, 3 low), **6 unused/redundant database fields**, **2 major data duplication issues**, and several **documentation vs. implementation gaps**. + +**Key Findings:** +- API keys stored in plain text (no encryption) despite documentation claims +- Public endpoints expose sensitive configuration data +- Timing attack vulnerability in Bearer token validation +- Documentation states "credentials encrypted at rest" but encryption NOT implemented +- Several fields documented but never used +- Missing scheduled publishing task from Celery Beat (documented but not configured) + +--- + +## PART 1: DOCUMENTATION VS IMPLEMENTATION GAPS + +### 1.1 Critical Discrepancies Found + +| Documentation Claim | Reality | Impact | +|---------------------|---------|--------| +| "API keys encrypted at rest" (INTEGRATIONS.md) | Plain text CharField in database | **CRITICAL** - False security assumption | +| "Webhook signature verification" (INTEGRATIONS.md) | No signature verification implemented | **HIGH** - Anyone with key can spoof webhooks | +| "Rate limiting on webhook endpoint" (INTEGRATIONS.md) | Webhooks explicitly disable throttling (`NoThrottle`) | **HIGH** - DoS possible | +| "auto_publish_enabled triggers WordPress push" | Automation Stage 7 sets status but does NOT trigger WP push | **MEDIUM** - Manual publish required | +| "Scheduled auto-publish task active" | Task exists but NOT in Celery Beat schedule | **MEDIUM** - Content sits unpublished | +| SiteIntegration.api_key field (INTEGRATIONS.md line 97) | API key stored in Site.wp_api_key, NOT SiteIntegration | Documentation outdated | +| "credentials_json stores WordPress credentials" | credentials_json is empty for WordPress; Site.wp_api_key is source of truth | Documentation outdated | + +### 1.2 Documentation Accuracy Summary + +| Document | Accuracy | Issues Found | +|----------|----------|--------------| +| WORDPRESS-INTEGRATION-FLOW.md | 90% | Accurate on auth, slight gaps on scheduling | +| WORDPRESS-INTEGRATION.md | 85% | Missing encryption truth, good on plugin distribution | +| PUBLISHER.md | 95% | Accurate on models and flows | +| INTEGRATIONS.md | 70% | Contains outdated field references, false encryption claim | +| SCHEDULED-CONTENT-PUBLISHING.md | 95% | Accurate on Celery tasks, correctly notes site_status vs status | +| CONTENT-PIPELINE.md | 90% | Accurate on stages, slight gap on Stage 8 auto-publish | + +--- + +## PART 2: SYSTEM ARCHITECTURE (VERIFIED) + +### 2.1 Actual Data Flow (Verified Against Code) + +``` +IGNY8 Backend WordPress Plugin +┌──────────────────────┐ ┌──────────────────────┐ +│ Site Model │ │ wp_options │ +│ ├─ wp_api_key ◄──────┼────────────────┼─► igny8_api_key │ +│ │ (SINGLE SOURCE) │ │ igny8_site_id │ +│ ├─ domain │ │ igny8_integration_id│ +│ └─ wp_url (LEGACY) │ │ │ +│ │ │ Post Meta │ +│ SiteIntegration │ │ ├─ _igny8_content_id │ +│ ├─ config_json │ │ ├─ _igny8_task_id │ +│ │ (site_url only) │ │ └─ _igny8_last_synced│ +│ ├─ credentials_json │ │ │ +│ │ (EMPTY for WP!) │ │ │ +│ └─ sync_status │ │ │ +│ │ │ │ +│ Content Model │ │ │ +│ ├─ external_id ◄─────┼────────────────┼─► post_id │ +│ ├─ external_url │ │ │ +│ ├─ status (editorial)│ │ │ +│ └─ site_status │ │ │ +│ (publishing) │ │ │ +└──────────────────────┘ └──────────────────────┘ +``` + +### 2.2 Authentication Flow (Verified) + +**IGNY8 → WordPress:** +```python +# publisher_service.py (line 136, 144) +destination_config = { + 'api_key': site.wp_api_key, # FROM SITE MODEL + 'site_url': site.domain or site.wp_url # Fallback to legacy +} + +# wordpress_adapter.py +headers = { + 'X-IGNY8-API-KEY': api_key, # Primary method + 'Content-Type': 'application/json' +} +``` + +**WordPress → IGNY8:** +```php +// Plugin: check_permission() method +$stored_api_key = get_option('igny8_api_key'); +$header_api_key = $request->get_header('x-igny8-api-key'); + +// CORRECT: hash_equals for X-IGNY8-API-KEY +if (hash_equals($stored_api_key, $header_api_key)) return true; + +// VULNERABLE: strpos for Bearer token +if (strpos($auth_header, 'Bearer ' . $stored_api_key) !== false) return true; +``` + +### 2.3 API Key Generation (Verified) + +``` +POST /api/v1/integration/integrations/generate-api-key/ +Body: { "site_id": 123 } + +Key Format: igny8_site_{site_id}_{timestamp_ms}_{random_10_chars} +Example: igny8_site_123_1736780400000_a7b9c3d2e1 + +Storage: Site.wp_api_key (CharField, plain text, max 255) +Recovery: NOT POSSIBLE - shown once on generation +Revocation: Sets Site.wp_api_key = None +``` + +--- + +## PART 3: CONTENT STATUS SYSTEM (DOCUMENTED CORRECTLY) + +### 3.1 Two-Status Architecture + +The documentation correctly describes the dual-status system: + +| Field | Purpose | Values | +|-------|---------|--------| +| `Content.status` | Editorial workflow | draft → review → approved → (published - legacy) | +| `Content.site_status` | WordPress publishing | not_published → scheduled → publishing → published / failed | + +### 3.2 Publishing Flow Paths + +**Path 1: Manual Publish (Working)** +``` +User clicks "Publish" → ContentViewSet.publish_to_wordpress + → Celery task: publish_content_to_wordpress + → WordPress API call + → Update external_id, external_url, site_status='published' +``` + +**Path 2: Scheduled Publish (Partially Working)** +``` +schedule_approved_content (hourly) + → Find approved content with site_status='not_published' + → Assign scheduled_publish_at + → Set site_status='scheduled' + +process_scheduled_publications (every 5 min) + → Find content where scheduled_publish_at <= now + → Queue publish_content_to_wordpress task + → WordPress API call +``` + +**Path 3: Automation Stage 7 (NOT TRIGGERING WP PUSH)** +``` +Automation Stage 7: + → Content.status = 'published' (legacy status change) + → NO site_status change + → NO WordPress API call queued + → Content sits with site_status='not_published' +``` + +**GAP IDENTIFIED:** Automation Stage 7 does NOT call publishing scheduler or queue WordPress task. + +--- + +## PART 4: SECURITY VULNERABILITIES (VERIFIED) + +### 4.1 CRITICAL ISSUES + +#### CRITICAL-1: API Keys Stored in Plain Text + +**Location:** Backend - `Site.wp_api_key` field (auth/models.py line 491) +**Documentation Claim:** "Credentials encrypted at rest" (INTEGRATIONS.md line 345) +**Reality:** CharField stores plain text - NO encryption + +**Evidence:** +```python +# auth/models.py +wp_api_key = models.CharField(max_length=255, blank=True, null=True, + help_text="API key for WordPress integration via IGNY8 WP Bridge plugin") +``` + +**Impact:** Database compromise exposes ALL WordPress API keys +**Risk Score:** 9/10 + +--- + +#### CRITICAL-2: Timing Attack in Bearer Token Validation + +**Location:** Plugin - `class-igny8-rest-api.php:140` +**Impact:** API key can be guessed character-by-character + +**Vulnerable Code:** +```php +// Uses strpos (VULNERABLE) +if (strpos($auth_header, 'Bearer ' . $stored_api_key) !== false) + +// Should use hash_equals (SAFE) +if (hash_equals($stored_api_key, substr($auth_header, 7))) +``` + +**Risk Score:** 8/10 + +--- + +#### CRITICAL-3: Diagnostic Logging Exposes Sensitive Data + +**Location:** Plugin - `class-igny8-rest-api.php:533-565` +**Impact:** Full request bodies logged including all content + +**Evidence:** +```php +error_log('========== RAW REQUEST BODY =========='); +error_log($raw_body); +error_log('========== PARSED JSON DATA =========='); +error_log(print_r($content_data, true)); +``` + +**Risk Score:** 8/10 + +--- + +### 4.2 HIGH SEVERITY ISSUES + +#### HIGH-1: Public Endpoints Expose Configuration + +**Verified Endpoints:** +| Endpoint | Permission | Data Exposed | +|----------|------------|--------------| +| `/wp-json/igny8/v1/status` | `__return_true` (PUBLIC) | has_api_key, connected, versions | +| `/wp-json/igny8/v1/site-metadata/` | `__return_true` (PUBLIC) | post_types, taxonomies, counts | + +**Note:** Documentation (WORDPRESS-INTEGRATION-FLOW.md line 86-91) does NOT flag these as security issues. + +--- + +#### HIGH-2: Permission Architecture Inconsistency + +**Location:** `test_connection_collection` endpoint +**Permission:** `AllowAny` (then checks auth manually inside view) + +**Documentation:** Does not mention this inconsistency. + +--- + +#### HIGH-3: No Webhook Signature Verification + +**Documentation Claim:** "Optional signature verification" (INTEGRATIONS.md line 349) +**Reality:** Only API key validation, NO HMAC signature verification + +**Code Evidence (webhooks.py):** +```python +api_key = request.headers.get('X-IGNY8-API-KEY') +if not stored_api_key or stored_api_key != api_key: + return error_response('Invalid API key', ...) +# NO signature validation +``` + +--- + +#### HIGH-4: Webhooks Disable Rate Limiting + +**Documentation Claim:** "Rate limiting on webhook endpoint" (INTEGRATIONS.md line 351) +**Reality:** Webhooks explicitly use `NoThrottle` class + +--- + +#### HIGH-5: Encryption Falls Back Silently + +**Location:** Plugin - `functions.php:31-48` +**Impact:** Admin unaware if encryption fails + +--- + +### 4.3 MEDIUM SEVERITY ISSUES + +1. **SSRF Risk in Connection Testing** - No IP validation +2. **credentials_json Exposed in Serializer** - All fields returned +3. **Flexible ID Lookup Enables Enumeration** - `/post-status/{id}` accepts both IDs +4. **Excessive API Request Logging** - Keys in logs +5. **WordPress URL Not Validated** - No HTTPS enforcement +6. **API Key Partially Visible** - `/verify-key` shows 15-char prefix + +### 4.4 LOW SEVERITY ISSUES + +1. Version disclosure in multiple endpoints +2. No CORS headers defined on plugin endpoints +3. Webhook logs stored long-term (500 entries) + +--- + +## PART 5: REST API ENDPOINTS INVENTORY (VERIFIED) + +### 5.1 Backend Endpoints (Verified Against urls.py) + +| Endpoint | Method | Permission | Documentation | +|----------|--------|------------|---------------| +| `/api/v1/integration/integrations/` | CRUD | IsAuthenticatedAndActive + IsEditorOrAbove | Correct | +| `/api/v1/integration/integrations/test-connection/` (collection) | POST | **AllowAny** | NOT documented as AllowAny | +| `/api/v1/integration/integrations/{id}/test-connection/` | POST | Authenticated | Correct | +| `/api/v1/integration/integrations/generate-api-key/` | POST | Authenticated | Correct | +| `/api/v1/integration/integrations/revoke-api-key/` | POST | Authenticated | Correct | +| `/api/v1/integration/webhooks/wordpress/status/` | POST | AllowAny (header auth) | Correct | +| `/api/v1/integration/webhooks/wordpress/metadata/` | POST | AllowAny (header auth) | Correct | +| `/api/v1/publisher/publish/` | POST | Authenticated | Correct | +| `/api/v1/writer/content/{id}/publish_to_wordpress/` | POST | Authenticated | Correct | +| `/api/v1/writer/content/{id}/schedule/` | POST | Authenticated | Correct (v1.3.2) | +| `/api/v1/writer/content/{id}/unschedule/` | POST | Authenticated | Correct (v1.3.2) | + +### 5.2 Plugin Endpoints (Verified Against class-igny8-rest-api.php) + +| Endpoint | Method | Permission | Documentation Match | +|----------|--------|------------|---------------------| +| `/wp-json/igny8/v1/status` | GET | **PUBLIC** | Not flagged as security issue | +| `/wp-json/igny8/v1/site-metadata/` | GET | **PUBLIC** (internal check) | Not flagged | +| `/wp-json/igny8/v1/verify-key` | GET | check_permission | Correct | +| `/wp-json/igny8/v1/publish` | POST | check_permission | Correct | +| `/wp-json/igny8/v1/post-by-content-id/{id}` | GET | check_permission | Correct | +| `/wp-json/igny8/v1/post-by-task-id/{id}` | GET | check_permission | Correct | +| `/wp-json/igny8/v1/post-status/{id}` | GET | check_permission | Correct | +| `/wp-json/igny8/v1/event` | POST | verify_webhook_secret | Correct | + +--- + +## PART 6: DATA STORAGE ANALYSIS (VERIFIED) + +### 6.1 Site Model Fields (auth/models.py) + +| Field | Documentation Status | Actual Usage | Action | +|-------|---------------------|--------------|--------| +| `wp_api_key` | Documented as primary | ACTIVE - single source of truth | KEEP + ENCRYPT | +| `domain` | Documented | ACTIVE - WordPress URL | KEEP | +| `wp_url` | Documented as "deprecated" | LEGACY - fallback in publisher_service | EVALUATE for removal | +| `wp_username` | Documented as "deprecated" | **ZERO USAGE** in codebase | **REMOVE** | +| `wp_app_password` | Documented as "deprecated" | **ZERO USAGE** in codebase | **REMOVE** | + +### 6.2 SiteIntegration Model Fields + +| Field | Documentation Status | Actual Usage | Action | +|-------|---------------------|--------------|--------| +| `site` | Documented | ACTIVE | KEEP | +| `platform` | Documented | ACTIVE ('wordpress') | KEEP | +| `config_json` | Documented as storing URL | ACTIVE (site_url only) | KEEP | +| `credentials_json` | Documented as storing creds | **EMPTY** for WordPress | Documentation outdated | +| `sync_status` | Documented | ACTIVE | KEEP | + +**Documentation Gap:** INTEGRATIONS.md line 97-98 shows `api_key` and `username` as SiteIntegration fields, but WordPress actually uses Site.wp_api_key. + +### 6.3 Content Model Fields + +| Field | Documentation Status | Actual Usage | Action | +|-------|---------------------|--------------|--------| +| `status` | Correctly documented as editorial | ACTIVE | KEEP | +| `site_status` | Correctly documented as publishing | ACTIVE | KEEP | +| `external_id` | Documented | ACTIVE - WordPress post ID | KEEP | +| `external_url` | Documented | ACTIVE - WordPress URL | KEEP | +| `external_type` | Not documented | **NEVER USED** | **REMOVE** | +| `external_metadata` | Not documented | Only set to {} | **REMOVE** | +| `sync_status` (on Content) | Not documented | **NEVER USED** | **REMOVE** | + +### 6.4 ContentTaxonomy Model Fields + +| Field | Documentation Status | Actual Usage | Action | +|-------|---------------------|--------------|--------| +| `external_id` | Documented | ACTIVE | KEEP | +| `external_taxonomy` | Documented | ACTIVE | KEEP | +| `sync_status` | Not documented | **NEVER USED** | **REMOVE** | + +--- + +## PART 7: CELERY TASKS STATUS (VERIFIED) + +### 7.1 Scheduled Tasks (celery.py Beat Schedule) + +| Task | Schedule | Documentation | Status | +|------|----------|---------------|--------| +| `schedule_approved_content` | Every hour | Documented (SCHEDULED-CONTENT-PUBLISHING.md) | **ACTIVE** | +| `process_scheduled_publications` | Every 5 min | Documented | **ACTIVE** | +| `publish_content_to_wordpress` | On-demand | Documented | **ACTIVE** | + +### 7.2 Missing Tasks (Documented but NOT Scheduled) + +**`process_pending_wordpress_publications`** - WORDPRESS-INTEGRATION-FLOW.md mentions this task exists but notes: +> "CURRENT STATUS: This task is NOT in Celery Beat schedule!" + +This matches the documentation correctly. + +--- + +## PART 8: PLUGIN DISTRIBUTION SYSTEM (VERIFIED) + +### 8.1 Plugin Models (Correctly Documented) + +| Model | Documentation | Implementation | Match | +|-------|---------------|----------------|-------| +| `Plugin` | WORDPRESS-INTEGRATION.md | plugins/models.py | ✅ | +| `PluginVersion` | Documented with all fields | Matches implementation | ✅ | +| `PluginInstallation` | Documented | Matches implementation | ✅ | +| `PluginDownload` | Documented | Matches implementation | ✅ | + +### 8.2 Plugin API Endpoints (Correctly Documented) + +| Endpoint | Documentation | Implementation | +|----------|---------------|----------------| +| `/api/plugins/{slug}/download/` | INDEX.md line 24 | ✅ Working | +| `/api/plugins/{slug}/check-update/` | INDEX.md line 25 | ✅ Working | +| `/api/plugins/{slug}/info/` | INDEX.md line 26 | ✅ Working | +| `/api/plugins/{slug}/register/` | INDEX.md line 27 | ✅ Working | +| `/api/plugins/{slug}/health-check/` | INDEX.md line 28 | ✅ Working | + +--- + +## PART 9: UNUSED/DEAD CODE INVENTORY + +### 9.1 Fields to Remove (Verified Zero Usage) + +| Model | Field | Evidence | +|-------|-------|----------| +| Site | `wp_username` | `grep -r "wp_username" --include="*.py"` = 0 results | +| Site | `wp_app_password` | `grep -r "wp_app_password" --include="*.py"` = 0 results | +| Content | `external_type` | Never read in codebase | +| Content | `external_metadata` | Only set to {} in publisher_service.py | +| Content | `sync_status` | SiteIntegration.sync_status used instead | +| ContentTaxonomy | `sync_status` | Never read or written | + +### 9.2 Redundant Code Paths + +1. **Duplicate Connection Testing** - Two endpoints with same logic +2. **Duplicate API Key Validation** - Same validation in 4+ files +3. **Dead Admin Bulk Actions** - `bulk_trigger_sync()`, `bulk_test_connection()` have TODO comments + +### 9.3 Plugin Unused Options + +| Option | Status | +|--------|--------| +| `igny8_access_token` | Redundant with igny8_api_key | +| `igny8_access_token_issued` | Referenced but not used in auth | + +--- + +## PART 10: DATA DUPLICATION ISSUES + +### 10.1 API Key Duplication + +| Location | Field | Role | +|----------|-------|------| +| Django Site model | `wp_api_key` | **PRIMARY** (single source of truth) | +| Django SiteIntegration | `credentials_json` | EMPTY for WordPress | +| WordPress | `igny8_api_key` | COPY (must match primary) | +| WordPress | `igny8_access_token` | REDUNDANT (should remove) | + +**Risk:** Documentation mentions credentials_json but WordPress doesn't use it. + +### 10.2 URL Duplication + +| Location | Field | Role | +|----------|-------|------| +| Site.domain | Primary | ACTIVE | +| Site.wp_url | Legacy | Fallback only | +| SiteIntegration.config_json['site_url'] | Configuration | ACTIVE | + +--- + +## PART 11: RECOMMENDATIONS + +### Immediate Actions (Critical) + +| Priority | Issue | Action | Documentation Update | +|----------|-------|--------|---------------------| +| 1 | Plain text API keys | Implement field encryption | Update INTEGRATIONS.md | +| 2 | Timing attack (strpos) | Use hash_equals everywhere | Update plugin docs | +| 3 | Diagnostic logging | Remove or conditional | Update plugin docs | +| 4 | Public endpoints | Secure /status and /site-metadata | Update WORDPRESS-INTEGRATION-FLOW.md | + +### Short-term Actions (High) + +| Priority | Issue | Action | Documentation Update | +|----------|-------|--------|---------------------| +| 5 | Permission inconsistency | Fix test_connection_collection | Update ENDPOINTS.md | +| 6 | No webhook signatures | Implement HMAC verification | Update INTEGRATIONS.md | +| 7 | No rate limiting | Enable throttling on webhooks | Update INTEGRATIONS.md | +| 8 | API key in response | Remove from verify-key | N/A | + +### Documentation Updates Required + +| Document | Updates Needed | +|----------|----------------| +| INTEGRATIONS.md | Remove "encrypted at rest" claim, fix field references | +| WORDPRESS-INTEGRATION-FLOW.md | Flag public endpoints as security concern | +| ENDPOINTS.md | Note AllowAny on test-connection collection | + +### Cleanup Actions + +| Action | Fields to Remove | Migration Required | +|--------|------------------|-------------------| +| Remove from Site | wp_username, wp_app_password | Yes | +| Remove from Content | external_type, external_metadata, sync_status | Yes | +| Remove from ContentTaxonomy | sync_status | Yes | +| Remove from Plugin | igny8_access_token option | Plugin update | + +--- + +## PART 12: PUBLISHING WORKFLOW SUMMARY + +### What's Working + +| Flow | Status | Notes | +|------|--------|-------| +| Manual publish button | ✅ Working | ContentViewSet.publish_to_wordpress | +| Scheduled publishing | ✅ Working | Celery Beat tasks active | +| Plugin distribution | ✅ Working | Auto-update mechanism functional | +| Webhook status sync | ✅ Working | WordPress → IGNY8 updates | + +### What's NOT Working + +| Flow | Status | Issue | +|------|--------|-------| +| Automation Stage 7 → WP | ❌ Broken | Sets status but no WP push | +| Content update sync | ❌ Missing | No republish capability | +| WordPress → IGNY8 import | ❌ Missing | No pull sync feature | + +### Documented But Not Implemented + +| Feature | Documentation Reference | Status | +|---------|------------------------|--------| +| Webhook signature verification | INTEGRATIONS.md line 349 | NOT implemented | +| Webhook rate limiting | INTEGRATIONS.md line 351 | NOT implemented | +| Credential encryption | INTEGRATIONS.md line 345 | NOT implemented | + +--- + +## APPENDIX A: FILES AUDITED + +### Backend Files +- `backend/igny8_core/auth/models.py` - Site model, wp_api_key +- `backend/igny8_core/business/integration/models.py` - SiteIntegration, SyncEvent +- `backend/igny8_core/business/publishing/models.py` - PublishingRecord +- `backend/igny8_core/business/content/models.py` - Content, external fields +- `backend/igny8_core/modules/integration/views.py` - API endpoints +- `backend/igny8_core/modules/integration/webhooks.py` - Webhook handlers +- `backend/igny8_core/business/publishing/services/publisher_service.py` +- `backend/igny8_core/business/publishing/services/adapters/wordpress_adapter.py` +- `backend/igny8_core/tasks/publishing_scheduler.py` - Celery tasks +- `backend/igny8_core/tasks/wordpress_publishing.py` - Publishing task +- `backend/igny8_core/celery.py` - Beat schedule + +### Plugin Files +- `plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-rest-api.php` +- `plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-api.php` +- `plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-webhooks.php` +- `plugins/wordpress/source/igny8-wp-bridge/includes/functions.php` + +### Documentation Files Reviewed +- `docs/60-PLUGINS/WORDPRESS-INTEGRATION.md` +- `docs/60-PLUGINS/INDEX.md` +- `docs/60-PLUGINS/PLUGIN-UPDATE-WORKFLOW.md` +- `docs/50-DEPLOYMENT/WORDPRESS-INTEGRATION-FLOW.md` +- `docs/10-MODULES/PUBLISHER.md` +- `docs/10-MODULES/INTEGRATIONS.md` +- `docs/40-WORKFLOWS/CONTENT-PIPELINE.md` +- `docs/40-WORKFLOWS/SCHEDULED-CONTENT-PUBLISHING.md` +- `docs/00-SYSTEM/ARCHITECTURE.md` +- `docs/20-API/ENDPOINTS.md` + +--- + +## APPENDIX B: VULNERABILITY SEVERITY MATRIX + +| ID | Title | CVSS | Documentation Claim | Reality | +|----|-------|------|---------------------|---------| +| CRITICAL-1 | Plain text API keys | 9.0 | "Encrypted at rest" | Plain text CharField | +| CRITICAL-2 | Timing attack | 8.0 | Not mentioned | strpos vulnerability | +| CRITICAL-3 | Diagnostic logging | 8.0 | Not mentioned | Full request bodies logged | +| HIGH-1 | Public endpoints | 7.0 | Not flagged | Information disclosure | +| HIGH-2 | Permission inconsistency | 6.5 | Not documented | AllowAny misuse | +| HIGH-3 | No webhook signatures | 6.0 | "Optional" | Not implemented at all | +| HIGH-4 | No rate limiting | 5.5 | "Rate limiting enabled" | NoThrottle class used | +| HIGH-5 | Silent encryption fail | 6.0 | Not mentioned | Falls back to plain text | + +--- + +**End of Calibrated Audit Report** + +**Next Steps:** +1. Review findings with development team +2. Prioritize critical security fixes +3. Update documentation to match reality +4. Create migration plan for field removal +5. Implement encryption before production diff --git a/docs/plans/ACCURATE-INTEGRATION-SECURITY-PLAN.md b/docs/plans/ACCURATE-INTEGRATION-SECURITY-PLAN.md new file mode 100644 index 00000000..befe40fd --- /dev/null +++ b/docs/plans/ACCURATE-INTEGRATION-SECURITY-PLAN.md @@ -0,0 +1,297 @@ +# 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 | diff --git a/plugins/wordpress/source/igny8-wp-bridge/admin/class-admin.php b/plugins/wordpress/source/igny8-wp-bridge/admin/class-admin.php index 9c4970e8..8315f317 100644 --- a/plugins/wordpress/source/igny8-wp-bridge/admin/class-admin.php +++ b/plugins/wordpress/source/igny8-wp-bridge/admin/class-admin.php @@ -403,21 +403,13 @@ class Igny8Admin { // Store API key securely if (function_exists('igny8_store_secure_option')) { igny8_store_secure_option('igny8_api_key', $api_key); - igny8_store_secure_option('igny8_access_token', $api_key); } else { update_option('igny8_api_key', $api_key); - update_option('igny8_access_token', $api_key); } - // Store site ID + // Store site ID and last communication timestamp update_option('igny8_site_id', sanitize_text_field($site_id)); - - // Store integration_id from response if provided - $response_data = $test_response['data'] ?? array(); - $integration_id = isset($response_data['integration_id']) ? intval($response_data['integration_id']) : null; - if ($integration_id) { - update_option('igny8_integration_id', $integration_id); - } + update_option('igny8_last_communication', current_time('timestamp')); add_settings_error( 'igny8_settings', @@ -439,17 +431,21 @@ class Igny8Admin { public static function revoke_api_key() { if (function_exists('igny8_delete_secure_option')) { igny8_delete_secure_option('igny8_api_key'); - igny8_delete_secure_option('igny8_access_token'); - igny8_delete_secure_option('igny8_refresh_token'); } else { delete_option('igny8_api_key'); - delete_option('igny8_access_token'); - delete_option('igny8_refresh_token'); } - // Also clear token-issued timestamps - delete_option('igny8_token_refreshed_at'); + // Clear connection data + delete_option('igny8_site_id'); + delete_option('igny8_last_communication'); + + // Clean up deprecated options (for older installations) + delete_option('igny8_access_token'); delete_option('igny8_access_token_issued'); + delete_option('igny8_integration_id'); + delete_option('igny8_last_structure_sync'); + delete_option('igny8_refresh_token'); + delete_option('igny8_token_refreshed_at'); } /** diff --git a/plugins/wordpress/source/igny8-wp-bridge/admin/layout-header.php b/plugins/wordpress/source/igny8-wp-bridge/admin/layout-header.php index 3fbdd3f5..540640f0 100644 --- a/plugins/wordpress/source/igny8-wp-bridge/admin/layout-header.php +++ b/plugins/wordpress/source/igny8-wp-bridge/admin/layout-header.php @@ -15,7 +15,7 @@ if (!defined('ABSPATH')) { // Get current page $current_page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : 'igny8-dashboard'; -$api_key = get_option('igny8_api_key'); +$api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key'); $is_connected = !empty($api_key); ?> @@ -43,19 +43,9 @@ $is_connected = !empty($api_key);