# External API Integrations

Every outbound HTTP call the Pro plugin makes, plus the per-integration auth pattern, allowlist, secrets storage, and known gaps. This is the SSRF + secrets layer — every change here is security-sensitive.

## Context

Pro integrates with many third-party services to power its widgets and extensions:

- Google: OAuth (id_token verification, social login), Sheets, Maps, Business Profile (OAuth + multiple APIs)
- Facebook: Graph API (social login token verification)
- Instagram: Graph API (Instagram Feed widget)
- Twitter, Mailchimp (widget data sources)
- Figma: Figma API (Figma_To_Elementor widget, FigmaImageHandler)
- ipinfo.io (Conditional_Display extension geo gating)
- WordPress.org plugin info API (Library trait)
- EA library backend (`app.essential-addons.com` / `essential-addons.com`) — license-gated template/asset library

Earlier documentation claimed Pro depends on `google/apiclient` via Composer. That is **incorrect today** — `composer.json` has only dev dependencies. All Google API access is direct `wp_remote_*` calls against documented endpoints. Re-introducing the SDK should be a deliberate ADR-backed decision.

## Verified facts

### Inventory — all outbound HTTP from Pro code

| Subsystem                                          | Files                                                        | Hosts                                                                                                                                                                                                          |
| -------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Google OAuth id_token verification**             | `Traits/Extender.php:3485`                                   | `https://oauth2.googleapis.com/tokeninfo`                                                                                                                                                                      |
| **Google Business Profile OAuth + APIs**           | `Traits/Business_Reviews_Extender.php` (`:229`, `:362`, `:418`, `:475`, `:717`) | `https://accounts.google.com/o/oauth2/v2/auth`, `https://oauth2.googleapis.com/token`, `https://mybusinessaccountmanagement.googleapis.com/v1/`, `https://mybusinessbusinessinformation.googleapis.com/v1/`, `https://mybusiness.googleapis.com` |
| **Google Sheets (Advanced Data Table)**            | `Traits/Extender.php:1767`                                   | `https://sheets.googleapis.com/v4/spreadsheets`                                                                                                                                                                |
| **Google Sheets (Fancy Chart widget)**             | `Elements/Fancy_Chart.php:2925`                              | `https://sheets.googleapis.com/v4/spreadsheets`                                                                                                                                                                |
| **Google Maps (Google_Map widget)**                | `Elements/Google_Map.php` (verify in code)                   | `https://maps.googleapis.com`                                                                                                                                                                                  |
| **Facebook Graph (Login_Register social)**         | `Traits/Extender.php:3530`, `:3570`                          | `https://graph.facebook.com/oauth/access_token`, `https://graph.facebook.com/debug_token`, `https://graph.facebook.com/{user_id}`                                                                              |
| **Instagram Graph (Instagram_Feed)**               | `Traits/Instagram_Feed.php:68`, `:88`, `:93`, `:102`         | `https://graph.instagram.com`                                                                                                                                                                                  |
| **Twitter (Twitter_Feed_Carousel widget)**         | `Elements/Twitter_Feed_Carousel.php` (verify endpoints)      | `https://api.twitter.com/2`                                                                                                                                                                                    |
| **Mailchimp (Mailchimp widget)**                   | `Elements/Mailchimp.php` (verify endpoints)                  | `https://<dc>.api.mailchimp.com/3.0/`                                                                                                                                                                          |
| **Figma image fetch**                              | `Classes/FigmaImageHandler.php:119`                          | Figma CDN URLs (varies by Figma asset)                                                                                                                                                                         |
| **IP geolocation (Conditional_Display)**           | `Extensions/Conditional_Display.php:576`                     | `https://ipinfo.io/{ip}/json`                                                                                                                                                                                  |
| **WordPress.org plugin info (Library trait)**      | `Traits/Library.php:50`                                      | `http://api.wordpress.org/plugins/info/1.0/` ⚠ HTTP                                                                                                                                                            |
| **EA library backend**                             | (various traits using EA's app)                              | `https://app.essential-addons.com`, `https://essential-addons.com`                                                                                                                                             |
| **Generic fallback (Extender legacy)**             | `Traits/Extender.php:5790-5791`                              | Arbitrary URLs (used as cURL fallback)                                                                                                                                                                         |

### Google id_token verification — proper validation present

`Traits/Extender.php:3484-3510` (`lr_verify_google_user_data`):

```php
$response = wp_remote_get( 'https://oauth2.googleapis.com/tokeninfo?id_token=' . $id_token );
// ...
if ( $verified_data['aud'] !== $client_id ) { return false; }
$issuers = [ 'accounts.google.com', 'https://accounts.google.com' ];
if ( ! in_array( $verified_data['iss'], $issuers ) ) { return false; }
return $verified_data;
```

**This is correct.** Both audience (`aud`) and issuer (`iss`) are validated. The endpoint `oauth2.googleapis.com/tokeninfo` is Google's canonical id_token verifier — we trust Google's signature check happening server-side rather than verifying JWT against Google's JWKS locally.

Gaps:
- No timeout on this call (defaults to WP's 5s). For a login flow on a slow connection this is borderline.
- `$id_token` is concatenated into URL with no escape — `id_token` strings are base64url so the risk of breaking URL parsing is low, but `urlencode()` would be safer.
- No nonce-based replay protection at this layer (relies on the upstream login flow's nonce).

### Google Business Profile — OAuth dance

`Traits/Business_Reviews_Extender.php`:

1. **Auth URL** (line 159): `https://accounts.google.com/o/oauth2/v2/auth` with:
   - `client_id` (from `eael_br_business_profile_client_id` option)
   - `redirect_uri` = `admin_url( 'admin.php?page=eael-settings&eael_business_profile_auth=1' )`
   - `scope` = `https://www.googleapis.com/auth/business.manage`
   - `response_type` = `code`
   - `access_type` = `offline` (returns a refresh_token)
   - `prompt` = `consent` (forces re-consent so refresh_token is always issued)
2. **Token exchange** (line 229): `wp_remote_post` to `https://oauth2.googleapis.com/token` with auth code + client credentials → returns access_token + refresh_token.
3. **Refresh token rotation** (line 362): Same `/token` endpoint with `grant_type=refresh_token` when access_token expires.
4. **Accounts list** (line 418): `wp_remote_get` to `https://mybusinessaccountmanagement.googleapis.com/v1/accounts` with `Authorization: Bearer <access_token>`.
5. **Locations list** (line 475): `wp_remote_get` to `https://mybusinessbusinessinformation.googleapis.com/v1/{account_name}/locations`.
6. **Reviews fetch** (line 717): `wp_remote_get` to `https://mybusiness.googleapis.com/v4/accounts/{account_name}/locations/{location_name}/reviews` (verify exact path in code).

Secrets storage (WP options):

| Option key                                       | Stores                                  |
| ------------------------------------------------ | --------------------------------------- |
| `eael_br_business_profile_client_id`             | OAuth client ID                         |
| `eael_br_business_profile_client_secret`         | OAuth client secret ⚠                   |
| `eael_br_business_profile_access_token`          | Current access_token ⚠                  |
| `eael_br_business_profile_refresh_token`         | Refresh token ⚠ (verify name in code)   |
| `eael_br_business_profile_last_error`            | Last OAuth/API error message            |

**Gaps:**
- Secrets stored as plain options. WordPress options are world-readable by any code running with `manage_options` cap. No encryption at rest.
- No automatic refresh-token rotation handling visible — if Google rotates the refresh_token (which it does on certain triggers), the stored token becomes invalid. Verify rotation handling in `:362`.
- `client_secret` in a plain WP option is a serious posture issue but standard for WordPress plugins. Mitigation: scope OAuth client to a single redirect URI and minimum scope.

### Google Sheets

Two call-sites with the same pattern, both unauthenticated except for an API key:

- `Traits/Extender.php:1767` (Advanced Data Table integration)
- `Elements/Fancy_Chart.php:2925`

```php
$connection = wp_remote_get(
    "https://sheets.googleapis.com/v4/spreadsheets/{$sheet_id}/?key={$api_key}&ranges={$range}&includeGridData=true",
    [ 'timeout' => 70 ]
);
```

**Gaps:**
- `timeout => 70`. 70 seconds is far too long for a synchronous request — if Google's endpoint stalls, every editor preview/page render waits 70s. Halve to 10–15s and treat slow responses as failures.
- API key concatenated directly into URL with no `urlencode()`. The user-supplied `$sheet_id` and `$range` are also concatenated. If a malicious editor enters a value containing `?` or `&`, they can rewrite the query string. Use `add_query_arg()`.
- Cache layer missing — every render hits the API.

### Facebook Graph (social login verification)

`Traits/Extender.php:3528-3578`:

1. App access_token fetch: `https://graph.facebook.com/oauth/access_token?client_id=..&client_secret=..&grant_type=client_credentials`
2. User token debug: `https://graph.facebook.com/debug_token?input_token=..&access_token=<app_token>`
3. User email fetch: `https://graph.facebook.com/{user_id}?fields=email&access_token=..`

Args built with `add_query_arg()` — proper. No timeouts specified — defaults to WP's 5s. Error handling returns `false` on `wp_remote_get` failures (consistent).

**Gap:** Step 2 verifies the user's token is valid for the app, but the response is not checked for `is_valid === true` in the snippet — verify in code.

### Instagram Graph

`Traits/Instagram_Feed.php` — five `wp_remote_get` calls (lines 68, 88, 93, 102, and the pagination follow-up). All use `https://graph.instagram.com`. Auth via `access_token` query param.

**Gap:** No timeouts. Tokens in URL (Instagram's own design, can't change client side, but means the token can leak via referer headers if the response triggers any onward navigation — unlikely here).

### IP geolocation — Conditional_Display

`Extensions/Conditional_Display.php:570-595`:

```php
if ( empty( $ip ) || $ip === '127.0.0.1' || $ip === '::1' ) {
    return false;
}
$response = wp_remote_get( "https://ipinfo.io/{$ip}/json", [
    'timeout' => 5,
    'headers' => [ 'Accept' => 'application/json' ],
] );
```

**Strong points:**
- Bails on loopback IPs (no useless API call)
- 5s timeout set explicitly
- Header set correctly

**Gaps:**
- `$ip` from `get_user_ip()` — verify that function sanitizes properly. If it trusts `X-Forwarded-For` without validation, an attacker can spoof and rate-limit-burn the IP cache.
- No caching layer — every gated page-load on a unique IP triggers an external call. `ipinfo.io` free tier is rate-limited; high-traffic sites will get throttled.
- IP geolocation is **PII under GDPR**. The Conditional_Display feature should be paired with a notice about the third-party lookup in the privacy policy.

### Figma image fetch

`Classes/FigmaImageHandler.php:119`:

```php
$response = wp_remote_get( $figma_url );
```

**Gaps (in this snippet):**
- No timeout
- `$figma_url` host is not validated against an allowlist visible in this snippet (verify upstream caller validates host is `*.figma.com`)
- `actual_mime` is checked after download (line 123) which is good, but the call itself is the SSRF surface — host validation must happen before the call, not after

### WordPress.org plugin info — HTTP + `unserialize`

`Traits/Library.php:48-62`:

```php
$response = wp_remote_post(
    'http://api.wordpress.org/plugins/info/1.0/',
    [ 'body' => [
        'action'  => 'plugin_information',
        'request' => serialize( (object) $args ),
    ] ]
);
// ...
$response = unserialize( wp_remote_retrieve_body( $response ) );
```

**Two serious issues:**
1. **HTTP, not HTTPS.** The wp.org plugin info v1.0 endpoint historically supports HTTPS too. Use it.
2. **`unserialize()` on an HTTP response is an RCE vector.** If the endpoint is ever MITM'd (HTTP), an attacker can return a crafted serialized payload that triggers gadget chains via `__destruct`/`__wakeup`. Migrate to the JSON endpoint at `/plugins/info/2.0/` and `json_decode`.

This is a v1.0 EDD-derived pattern. Modernize when next touched.

### EA library backend

The Library trait talks to EA's own backend at `app.essential-addons.com` / `essential-addons.com` for template/asset library. Authentication piggybacks on the license key. Treat library calls as license-gated:

```php
// (paraphrased pattern)
if ( LicenseManager::get_instance()->get_store()->get_status() !== 'valid' ) {
    return new WP_Error( 'license_required', __( '…', 'essential-addons-elementor' ) );
}
$response = wp_remote_get( 'https://app.essential-addons.com/...' );
```

**Gap:** Verify all library entry points check status before making the call. A library call leaking on an invalid license isn't a security issue per se but is paid-feature leakage.

### Other endpoints in the codebase

Found via `grep -rohE "https?://"` but not in a primary call path:

- `https://snazzymaps.com`, `https://mapstyle.withgoogle.com` — likely UI documentation links in widget controls, not runtime fetches
- `https://www.blobmaker.app`, `https://bennettfeely.com` — same
- `https://vincentgarreau.com`, `https://en.wikipedia.org`, `https://wiki.lexisnexis.com` — likely demo URLs or doc links
- `https://your-link.com`, `https://your-webhook.com` — placeholder URLs in control defaults; never actually called

These should NOT be hit at runtime. If they are, that's a bug.

## What's missing

1. **No central HTTP wrapper.** Every integration calls `wp_remote_*` directly. A shared wrapper could enforce:
   - Default timeout (10s)
   - Host allowlist check
   - Automatic `is_wp_error` + status-code handling
   - Centralized error logging (sanitized)
   - Transient-based cache with per-endpoint TTL
2. **No host allowlist enforcement.** SSRF risk in `FigmaImageHandler` and any future "fetch this URL" feature. Pro should ship an allowlist constant and require all `wp_remote_get( $user_supplied )` to validate against it.
3. **`unserialize()` on HTTP response in `Library::get_plugin_data`.** RCE vector. Migration to JSON 2.0 endpoint is the fix.
4. **Google Sheets timeout 70s is dangerous.** Front-end blocking on a synchronous Google call for over a minute is an availability risk. Cap at 15s and treat as failure.
5. **No `urlencode()` on user values concatenated into URLs.** Multiple call-sites use string interpolation; `add_query_arg()` should be the standard.
6. **OAuth client_secret stored as plaintext WP option.** Standard for WordPress plugins but worth a note in the security model — sites with compromised admin sessions leak the credential.
7. **No automatic rate-limit handling.** Most APIs return 429 with `Retry-After` — Pro doesn't honour it. A burst-mode site hits the limit and stays broken until the upstream window expires.
8. **No telemetry on API failures.** Failures fall back silently in most paths. A dashboard tile showing "Mailchimp API: 3 failures in the last 24h" would surface integration breakages faster.
9. **Twitter / Mailchimp endpoints undocumented here.** Verify in `Elements/Twitter_Feed_Carousel.php` and `Elements/Mailchimp.php` and add to the inventory.
10. **Composer SDK question.** `google/apiclient` is not currently a dep. Decision needed: keep direct HTTP (simpler, no transitive deps) or add SDK (consistent auth, refresh-token handling, but ~50 packages of transitive deps). Either way, document in an ADR.
11. **`access_token` storage for Business Profile.** Plain WP option, no encryption, no rotation tracking. Acceptable risk per current WP norms but should be documented.

## Acceptance

This doc accurately reflects:

- Google id_token verification is correctly implemented with `aud` + `iss` checks ✓
- Business Profile OAuth uses `access_type=offline` + `prompt=consent` for refresh-token issuance ✓
- Business Profile scope is `https://www.googleapis.com/auth/business.manage` ✓
- Google Sheets uses 70s timeout (flagged as gap) ✓
- IP geolocation bails on loopback IPs and uses 5s timeout ✓
- `Library::get_plugin_data` uses HTTP + `unserialize` (flagged as gap) ✓
- No Composer SDK dep today; all direct HTTP ✓

If endpoints change in code, update this doc and the inventory table in the same PR.

## Pairs with

- `.claude/rules/external-api-integrations.md` — actionable rules derived from this architecture doc
- `license-system.md` — `api.wpdeveloper.com` is one of the outbound endpoints, subject to additional license-specific rules
- `pro-lite-bridge.md` — many external calls happen inside Extender callbacks; coordinate hook + outbound auditing

## Related

- WordPress.org Plugins Handbook on outbound HTTP — `wp_safe_remote_*` vs `wp_remote_*` and when to use which
- OWASP API Security Top 10 (esp. API8: Server-Side Request Forgery, API3: Broken Object Property-Level Authorization)

## Out of scope

- Per-API endpoint semantics — that's API vendor documentation
- The mechanics of Google OAuth consent screens — Google product, not ours
- Securing the wp.org plugin info endpoint at the source (out of our control)
- The Composer SDK migration ADR (referenced as a follow-up; not part of this doc)
