# Security Policy

## Supported Versions

| Version | Supported          |
| ------- | ------------------ |
| 7.7.0   | :white_check_mark: |
| < 7.7.0 | :x:                |

## Reporting a Vulnerability

If you discover a security vulnerability within wpDiscuz, please send an email to info@gvectors.com. All security vulnerabilities will be promptly addressed.

## Security Fixes

### Stored Cross-Site Scripting via Image URL Conversion in Comments (Fixed in 7.7.0)

**Severity:** High
**Type:** Stored Cross-Site Scripting (XSS)
**Affected Component:** Render-time "Image URL Conversion" (`WpdiscuzHelper::makeClickable()` in `utils/class.WpdiscuzHelper.php`), setting Comments > Settings > Content > "Convert links to images" (`enableImageConversion`, on by default)
**Affected Versions:** All released versions that include the Image URL Conversion feature, up to and including 7.6.75. Fixed in 7.7.0 before its public release.

**Description:**
When a comment is rendered, `makeClickable()` runs a regular expression over the already-sanitized comment HTML to turn links that end in an image extension into `<img>` tags. The expression could match a link past the boundary of the attribute value it was written in, and the conversion's replacement then left the surrounding markup in a state where text from elsewhere in the comment parsed as live attributes, including an event handler, on a rendered element. The conversion runs after sanitization and its output is not re-filtered, so the result reached the browser intact.

The comment could be submitted by an unauthenticated guest. The script ran without interaction for moderators and administrators viewing the comment while it was pending, and for every visitor once it was approved.

**Fix Applied:**
1. The URL body of the image-conversion expression is now bounded by `[^\s\"'<>]+`, so a matched link can never cross a quote or an angle bracket and can never consume another tag's delimiters.
2. The conversion no longer runs inside HTML tags at all. A lookahead (`(?![^<>]*>)`) skips any candidate URL that sits before a tag's closing `>`, so a link written inside an attribute value, whether double-quoted, single-quoted or unquoted, is left untouched instead of having the replacement markup injected into the attribute.
3. The leading character class, which consumed and silently deleted the character before every converted link, was replaced with a lookbehind (`(?<![\"'])`). The character before a converted link is now preserved, and a link immediately preceded by a quote is still not converted.
4. Legitimate bare image links in comment text still convert: a plain URL ending in `.jpg`, `.jpeg`, `.png`, `.gif` or `.bmp` becomes an image as before. Two intentional behavior changes: a URL containing a literal apostrophe no longer converts (it stays a plain link), and a URL followed later on the same text run by a bare `>` is skipped; both degrade to showing the link as text.

**Recommended Action:**
Update to 7.7.0. No settings change or data migration is needed: existing comments are rendered safely by the corrected conversion. Sites that ran an affected version may want to review pending and approved comments that contain a link ending in an image extension inside markup.

**Files Modified:**
- `utils/class.WpdiscuzHelper.php` - Bounded the URL body in the image-conversion regular expression in `makeClickable()`

**Credit:**
Reported by an external security researcher.

### Comment, User and Metadata Cache Files Readable by Unauthenticated Visitors (Fixed in 7.6.75)

**Severity:** High
**Type:** Sensitive Information Disclosure
**Affected Component:** Built-in comment cache (`utils/class.WpdiscuzCache.php`), setting Comments > Settings > General > "Enable caching" (on by default)
**Affected Versions:** Up to and including 7.6.74.

**Description:**
The comment cache was written to `wp-content/uploads/wpdiscuz/cache/comments/<post_id>/<name>`, the comment author cache to `wp-content/uploads/wpdiscuz/cache/users/<name>`, and the extra cache to `wp-content/uploads/wpdiscuz/cache/comments/<post_id>/extra/<name>`. These examples assume the standard uploads directory; the cache follows each site's uploads directory on multisite or with custom upload settings. The only access control was an `.htaccess` file containing `Deny from all`. Servers serving these files directly without reading `.htaccess`, such as nginx or Apache with `AllowOverride None`, could expose them. Apache 2.4 without `mod_access_compat`, when reading this rule, returned a server error rather than serving the file.

The comment and extra cache file names were derived with `md5()` from values a visitor already knows (the viewer's role set, the sort order, the page number and the parent comment ID), so for a guest with default settings the name was the same on every site. The comment cache held serialized `WP_Comment` objects, including email addresses, IP addresses and user agents for cached comments. The extra cache held comment metadata, including metadata stored by other plugins.

The author cache held serialized `WP_User` objects, including usernames, email addresses and `user_activation_key` data. Its file name was derived from the user ID, comment email address and comment author name, all available in the comment cache. With avatars enabled in both wpDiscuz and WordPress (the defaults), the existing avatar code removed `user_pass` before caching, but also placed the comment object, including its IP address, in the author cache. If avatars were disabled in either setting, password hashes were cached as well. Exposure was limited to data present in generated cache files; it does not establish that a particular site was accessed or exploited.

**Fix Applied:**
1. Cache files are now stored under `wp-content/uploads/wpdiscuz/cache/<key>/`, where `<key>` is 32 hex characters derived with HMAC-SHA256 from the site's authentication salt. The path can no longer be guessed, and it changes when the salts are rotated.
2. Cache file contents are encrypted with AES-256-CBC under a key derived from the same salt, with a random IV and an HMAC-SHA256 tag that is verified before the data is unserialized. A file obtained over the web, from an uploads-only backup or from an offloaded uploads bucket cannot be read without the site's salts, and a tampered file is treated as a cache miss. A full-site backup may contain those salts and must be protected separately.
3. The password hash and activation-key data are removed from both user references in the author cache, including the avatar arguments.
4. The parent `.htaccess` file supports both Apache 2.4 (`Require all denied`) and Apache 2.2 (`Deny from all`) and is verified before any in-place deletion at a predictable legacy path. These rules require Apache to permit the corresponding overrides. Empty `index.html` files in `cache/` and `cache/<key>/` suppress directory listings where the server's directory-index configuration includes that file. Encryption protects new cache contents independently of these server settings.
5. Each request that initializes wpDiscuz checks for the old `cache/comments/` and `cache/users/` directories, even when caching is disabled or the installed version is already current. Each tree is renamed to a `cache/legacy-comments-<random>/` or `cache/legacy-users-<random>/` directory with 128 bits of randomness, immediately removing its predictable URL without traversing its contents. Its own access rules move with it. Cleanup writes protection only in the existing cache parent, without creating the live cache-key directory under a command-line user's ownership.
6. WordPress cron deletes retired trees in batches limited to 250 entries and a two-second time budget, scheduling further batches while files remain. Retired trees can be deleted even when the parent access rule is unwritable; each tree's own `.htaccess` is retained until its other contents have been removed. If a rename fails, the job retries it and can remove the original tree in the same bounded batches, only after verifying the parent access rule. Failed frontend attempts are throttled to once per hour and show an administrator notice while predictable paths remain. An hourly check of retired directory names recovers missing jobs after reactivation or a restore. Its next scan time is kept in an autoloaded option to avoid a permanent transient lookup query, and cache contents are never walked during this check.

**Recommended Action:**
Update to 7.6.75 and make a request that loads WordPress on each affected site; a response served entirely from a page cache does not run cleanup. No caching setting change is needed. Keep WordPress cron running so files moved out of their predictable locations are deleted in the background. If wpDiscuz reports that old files could not be removed, ask the hosting provider to remove the legacy directories from that site's uploads directory. Also remove any publicly accessible legacy copies in offloaded storage or a CDN.

On sites that served the cache files publicly, cached commenters' email addresses, IP addresses, user agents, registered usernames and comment metadata may have been exposed. If avatars were disabled in either wpDiscuz or WordPress while those caches were generated, password hashes of cached registered commenters may also have been exposed; consider asking those users to change their passwords. This report does not establish that any particular site's files were downloaded.

On nginx, also deny web access to the cache directory in the server configuration. For a site using the standard uploads URL:

```
location ^~ /wp-content/uploads/wpdiscuz/cache/ { deny all; }
```

Replace the URL prefix with the actual public uploads path. Multisite subsites commonly use `/wp-content/uploads/sites/<site_id>/wpdiscuz/cache/`; add an appropriate block for each uploads location. The rule must be applied to whichever server or storage service actually serves the files.

**Files Modified:**
- `utils/class.WpdiscuzCache.php` - Salt-derived cache directory, encrypted and authenticated cache files, sanitized user references, hardened `.htaccess` and `index.html`, legacy quarantine and bounded background cleanup
- `includes/interface.WpDiscuzConstants.php` - Constants for cache sub-directories, cleanup retry transient, scan option and background action
- `class.WpdiscuzCore.php` - Legacy retirement during initialization, background job registration and an administrator notice on failure
- `languages/wpdiscuz.pot` - Translation template entry for the cleanup notice

**Credit:**
Reported by an external security researcher.

### Stored Cross-Site Scripting via Template Placeholders in the Comment Author Name (Fixed in 7.6.74)

**Severity:** High (CVSS 7.1)
**Type:** Stored Cross-Site Scripting (XSS)
**Affected Component:** Front-end comment rendering (`themes/default/class.WpdiscuzWalker.php`) and the guest Name field
**Affected Versions:** Up to and including 7.6.73.

**Description:**
The comment renderer collected all template placeholders (`{AVATAR}`, `{AUTHOR}`, `{TEXT}`, ...) and their values into two arrays and resolved them with a single array `str_replace()`. PHP applies such replacements one pair after another over the whole string, so a placeholder that appeared inside an already inserted value was expanded again. The avatar markup, whose `alt` attribute holds the escaped author name, was inserted before the comment body. An author name containing the literal text `{TEXT}` therefore had the raw comment HTML expanded into the `alt` attribute after escaping had already run. A body consisting of a straight quote and event-handler text inside a `<code>` element passes `wp_kses` and is not touched by `wptexturize`, so the quote closed the attribute and `onerror`/`onload` became live attributes on the avatar `<img>`.

The name could be supplied by an unauthenticated guest through the Name field, or by any registered user through the display name of their profile. The script ran without interaction for moderators and administrators viewing the comment while it was pending, and for every visitor once it was approved.

**Fix Applied:**
1. The renderer now resolves placeholders with `strtr()` and a single map, which performs one pass and never rescans inserted values. The comment templates themselves are unchanged, so custom layouts in child themes keep working.
2. Added `WpdiscuzHelper::neutralizeTemplateBraces()`, which encodes `{` and `}` as HTML entities. The guest Name field passes through it before the name is stored.
3. The name shown in the "last edited by" note, the only user value concatenated into the template before the final pass, goes through the same helper.
4. The parent author name in the "Reply to" block is now escaped with `esc_html()`, matching the author name in the comment header.

**Recommended Action:**
Update to 7.6.74. No settings change or data migration is needed: comments whose author name already contains braces are rendered safely by the new renderer. Sites that were on an affected version may want to review pending and approved comments whose author name contains `{` or `}`.

**Files Modified:**
- `themes/default/class.WpdiscuzWalker.php` - Single-pass placeholder substitution; escaped the parent author name and the "last edited by" name
- `utils/class.WpdiscuzHelper.php` - Added `neutralizeTemplateBraces()`
- `forms/wpdFormAttr/Field/DefaultField/Name.php` - Braces in the guest name are encoded before the name is stored

**Credit:**
Reported through Patchstack.

### Facebook Login Authentication Bypass (Fixed in 7.6.73)

**Severity:** High
**Type:** Authentication Bypass / Account Takeover
**Affected Setting:** Comments > Settings > Social Login and Share > Facebook (legacy flow, "Use Facebook OAuth2" disabled — the default)
**Affected Versions:** Up to and including 7.6.72.

**Description:**
The legacy client-side Facebook login flow passed a Facebook user ID received with the request straight into a Facebook Graph lookup, without confirming that the ID belonged to the owner of the access token the request arrived with. When Facebook returned no email for that profile, the account's email address was synthesized from the profile ID, and an existing account was then matched by that email. Because a Facebook account's ID is public and was used to derive both the user name and the fallback email, a request could be signed in as another visitor's account. Where the matched account was an administrator, this led to full site compromise.

The server-side OAuth2 flow was never affected: it reads the account from the token's own owner via the Graph `/me` node.

**Fix Applied:**
1. Removed the legacy client-side Facebook flow entirely; Facebook login now always uses the server-side OAuth2 flow (`facebookLoginPHP()` / `facebookLoginPHPCallBack()`), which reads the profile of the access token's owner.
2. Removed the `facebookLogin()` method that trusted the client-supplied user ID.
3. Social accounts are now matched by their verified provider user ID (stored in user meta) first, falling back to email only for a real provider email.
4. Removed the "Use Facebook OAuth2" setting; the server-side flow is the only path. Stored option values are untouched and no reconfiguration of wpDiscuz settings is required.

**Recommended Action:**
Sites that used the legacy flow must add their site's OAuth redirect URI, `wp-admin/admin-ajax.php?action=wpd_login_callback&provider=facebook`, to the "Valid OAuth Redirect URIs" of their Facebook app for the Facebook button to keep working. Administrators should review user accounts that originated from Facebook login for any that were not created by their legitimate owner.

**Files Modified:**
- `forms/wpdFormAttr/Login/SocialLogin.php` - Removed `facebookLogin()`; Facebook login always uses the OAuth2 flow
- `forms/wpdFormAttr/Login/Utils.php` - Match social accounts by verified provider user ID, always store the social user ID
- `options/class.WpdiscuzOptions.php` and `options/options-layouts/html-social.php` - Removed the `fbUseOAuth2` option and its setting
- `assets/js/wpdiscuz-social.js`, `assets/js/wpdiscuz-social.min.js`, `assets/js/wpdiscuz-combo.min.js`, `assets/js/wpdiscuz-combo-no_quill.min.js` - Removed the client-side Facebook login flow (Facebook share is unchanged)

### Google Client Secret Exposure (Fixed in 7.6.68)

**Severity:** Medium
**Type:** Sensitive Data Exposure
**Affected Setting:** Comments > Settings > Social Login and Share > Google Client Secret
**Affected Versions:** Up to and including 7.6.67. The exposure was present continuously from June 2019, when the Google Login options were added, until 7.6.68.

**Description:**
The Google Client Secret and Client ID were added to the `$jsArgs` array in `WpdiscuzOptions`, which is localized to the front end as `wpdiscuzAjaxObj`. On any site that had those settings filled in, both values were readable in the HTML source of every front-end page, by any visitor.

Neither value was used by the front-end JavaScript. The Google OAuth flow runs entirely server-side in `forms/wpdFormAttr/Login/SocialLogin.php`, which reads the options directly. The Client ID is public by design and carries no risk on its own; the Client Secret is not meant to leave the server.

**Fix Applied:**
1. Removed `googleClientSecret` from the `$jsArgs` array
2. Removed the unused `googleClientID` from the same array
3. Stored option values are untouched, and Google Login continues to work with no reconfiguration

**Recommended Action:**
Sites that had Google Login configured on an affected version should generate a new Client Secret in the Google Cloud Console and save it under Comments > Settings > Social Login and Share. The redirect URIs registered for the Google app limit what a third party can do with the old secret, but it should no longer be treated as private.

**Files Modified:**
- `options/class.WpdiscuzOptions.php` - Removed `googleClientSecret` and `googleClientID` from `$jsArgs`

### CVE-2025-68997 - IDOR Vulnerability (Fixed in 7.6.44)

**Severity:** Medium
**Type:** Insecure Direct Object Reference (IDOR)
**Affected Actions:** `wpdVoteOnComment`, `wpdUserRate`, `wpdFollowUser`, `wpdAddSubscription`

**Description:**
AJAX actions exposed via `admin-ajax.php` were vulnerable to:
1. Authorization bypass - voting on comments from private/restricted posts
2. Mass abuse through direct HTTP requests bypassing frontend protections

**Fix Applied:**
1. **Authorization Check** (IDOR fix):
   - Added post access validation to `voteOnComment()`
   - Verifies post exists and is published
   - Checks user has permission for private posts
   - Blocks access to password-protected post comments for guests
   - Uses `$comment->comment_post_ID` (actual post from DB) for authorization, not user-supplied `postId` parameter - prevents bypass via parameter manipulation

2. **Rate Limiting** (Abuse prevention):
   - Server-side rate limiting on all sensitive AJAX actions
   - Rate limits: vote (20/min), rate (10/min), follow (15/min), subscribe (10/min)
   - Enhanced client fingerprinting (IP + User-Agent + Accept-Language)
   - Rate limiting executes BEFORE nonce validation for maximum protection

**Files Modified:**
- `utils/class.WpdiscuzHelper.php` - Added `checkRateLimit()` and `getClientFingerprint()`
- `utils/class.WpdiscuzHelperAjax.php` - Authorization check + rate limiting on `voteOnComment()`, `userRate()`, `followUser()`
- `utils/class.WpdiscuzHelperEmail.php` - Rate limiting on `addSubscription()`
- `options/class.WpdiscuzOptions.php` - Added `wc_rate_limit_exceeded` phrase

**Verification:**
Security fix can be verified by checking for `@security-fix CVE-2025-68997` annotations in the source code.

## Security Best Practices

1. Always keep wpDiscuz updated to the latest version
2. Use HTTPS on your website
3. Keep WordPress core and other plugins updated
4. Use strong passwords for admin accounts
5. Consider using a Web Application Firewall (WAF)
