# Protected Content Widget

> Standalone password-protected content widget. Renders an inline password form; on correct submission, reveals the protected content. Per-widget nonce-protected POST handling.

**Class file:** `includes/Elements/Protected_Content.php` (1,172 lines)
**Slug:** `protected-content` (widget id `eael-protected-content`)
**Public docs:** <https://essential-addons.com/elementor/docs/protected-content/>
**Pro-shared:** Pro-only. Sibling of `content-protection` Pro extension (different mechanic).

## Overview

Renders inline password gate. Until user enters the correct password and submits, the protected content is hidden. On submit, page reloads (or POST handler runs inline) and content reveals. Different from [`content-protection`](../extensions/content-protection.md) **extension** — extension wraps any element; this **widget** is a standalone container.

## Pro vs Lite

Pro-only widget. Pro also ships a `Content_Protection` **extension** that wraps any element. Two different surfaces for similar feature.

## File Map

| File | Role |
| --- | --- |
| `includes/Elements/Protected_Content.php` | Widget class (1,172 lines) |
| `src/css/view/protected-content.scss` → `assets/front-end/css/view/protected-content.min.css` | Form + content styling (shared with extension via Lite via `EAEL_PRO_PLUGIN_PATH` reference) |
| `config.php` entry `'protected-content'` | Self CSS only (no JS) |

## Architecture

- **Composes Pro's `Helper`** (line 12).
- **Nonce-protected POST** — `eael_protected_content_nonce_{widget_id}` (line 1074, 1106). Nonce field name includes widget ID for per-widget isolation.
- **`wp_verify_nonce` server-side** — line 1074 verifies `'eael_protected_nonce'` action.
- **Password comparison** — line 1118: `$settings['protection_password'] !== wp_unslash($_POST[...])`. **Plain-string comparison** (not `hash_equals`) — timing-attack vulnerable. Practical impact low (passwords stored plaintext anyway), but cryptographic best practice violated.
- **Password stored in widget settings (post meta)** — same plaintext-in-`_elementor_data` risk as Content_Protection extension. Treat as obscurity, not security.
- **No JS** — pure form POST + server-render branch.

## Render Output

```html
<div class="eael-protected-content-wrap">
  <!-- If not authenticated: form -->
  [?] <form method="POST" class="eael-protected-content-form">
    <input type="hidden" name="eael_protected_content_nonce_{widget_id}"
           value="{nonce}">
    <input type="password" name="protection_password_{widget_id}"
           placeholder="{password_placeholder}">
    <button type="submit">{submit_button_text}</button>
    [?] <div class="eael-protected-error">Incorrect password</div>
  </form>

  <!-- If authenticated: content -->
  [?] <div class="eael-protected-content-revealed">
    {WYSIWYG content}
    OR
    {rendered Elementor template}
  </div>
</div>
```

## Controls Reference

| Control id | Tab → Section | Type | Purpose |
| --- | --- | --- | --- |
| `protection_password` | Content → Protection | TEXT | The password (stored plaintext in post meta) |
| Form placeholder | Content → Form | TEXT | Input placeholder |
| Submit button text | Content → Form | TEXT | Button label |
| Error message | Content → Form | TEXT | Wrong-password message |
| Content type | Content → Protected | SELECT | `inline` / `template` |
| Inline content | Content → Protected | WYSIWYG | Rich content |
| Template ID | Content → Protected | SELECT | Saved Elementor template |
| Per-region styling | Style → ... | various | Form / input / button / content styling |

## Conditional Dependencies

```text
content_type = 'inline' → WYSIWYG visible
content_type = 'template' → template SELECT visible
```

## JavaScript Lifecycle

N/A — pure server-rendered form. No JS.

## Hooks & Filters

Standard widget render. No Pro-emitted hooks.

## Common Issues

| Symptom | Cause | Fix |
| --- | --- | --- |
| Form re-renders even after correct password | POST not detected on re-render | Check `$_POST` keys; verify nonce passes |
| Multiple widgets on same page interfere | Nonce / field keyed by widget ID — should isolate | Verify each widget has unique `{widget_id}` |
| Password visible in HTML source | If echoed back as form value | Never echo password value back; only echo the nonce |
| Brute force possible | No rate limiting on password attempts | Add server-side throttle (out of widget scope) |
| Caching plugins serve content without form | Page cache caches the form HTML | Add cache-bypass header / Vary on cookie |

## Known Limitations

- **Password stored plaintext** in `_elementor_data` post meta — anyone with editor cap can read it
- **Plain-string comparison** (line 1118) — not `hash_equals` → timing-attack vulnerable in principle; practical impact low because of plaintext storage anyway
- **No CSRF beyond nonce** — and the nonce is rendered in the form, so accessible to any logged-out visitor
- **No rate limiting** on password attempts — brute-force possible
- **Page cache breaks the form** — caching plugins must bypass this widget's page
- **Two surfaces for same feature** — this widget AND the `content-protection` extension. Consolidate in future refactor
- **No password reset / "forgot password"** — by design but limits UX
- **Cookie-based "remember" not implemented** — user re-enters password every visit

## Cross-References

- Pro extension: [`content-protection.md`](../extensions/content-protection.md) — alternative surface
- Shared patterns: [`_patterns.md`](_patterns.md)
- Skill: [`/nopriv-ajax-hardening`](../../.claude/skills/nopriv-ajax-hardening/SKILL.md) — relevant for form-POST hardening review
