Skip to content
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
95e00ff
Improve I18N Issue based on 2.5.3
alexclassroom Sep 15, 2025
a60017c
Merge branch 'WPDevelopers:master' into master
alexclassroom Nov 5, 2025
e6fd88a
Merge branch 'WPDevelopers:master' into master
alexclassroom Dec 3, 2025
24752f0
Merge branch 'WPDevelopers:master' into master
alexclassroom Jan 26, 2026
acbc92f
Add AI agent context, CLAUDE.md, and dev tooling exclusions
alimuzzaman Mar 29, 2026
c6714a3
Security: remove HTTP_REFERER trust from is_network_admin() #4
alimuzzaman Mar 29, 2026
e3ed4b9
Security: add capability check to disable_comments_settings() #1
alimuzzaman Mar 29, 2026
8921f5d
Security: add capability and per-blog auth checks to delete_comments_…
alimuzzaman Mar 29, 2026
854eb86
Security: escape role names to prevent DOM XSS in role exclusion UI #3
alimuzzaman Mar 29, 2026
6cd801a
Security: add capability check to get_sub_sites() to prevent subsite …
alimuzzaman Mar 29, 2026
60bd5f3
Exclude .claude/ worktrees from git tracking and distribution zips
alimuzzaman Mar 29, 2026
c9b578a
Security: derive network context from POST data, not is_network_admin()
alimuzzaman Mar 29, 2026
9e9fe6d
Exclude pnpm-lock.yaml from distribution
alimuzzaman Mar 29, 2026
6f54eea
Merge pull request #156 from alexclassroom/master
alimuzzaman Mar 29, 2026
9fa11c9
Fix esc_html__ stripping HTML tags in admin notice
alimuzzaman Mar 29, 2026
b996182
Security: gate update_site_option on network action capability check
alimuzzaman Mar 29, 2026
6e5a776
Fix is_network_admin() AJAX context and unify network action detection
alimuzzaman Mar 29, 2026
78478d0
Address Copilot review suggestions from PR #160
alimuzzaman Mar 29, 2026
2c86f7f
Fix UI error handling and double-encoding in role names
alimuzzaman Mar 29, 2026
5e6657a
Handle new server error responses in JS
alimuzzaman Mar 29, 2026
e41335c
Fix nonce failure silently returning success in delete_comments_setti…
alimuzzaman Mar 30, 2026
8693705
Fix nonce failure silently returning success in disable_comments_sett…
Copilot Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# .ai/ — Agent Context Directory

This directory stores documentation and context generated or maintained by AI agents (Claude Code, etc.) during development work.

## Purpose

- **Deep-dive architecture docs** — detail too verbose for `CLAUDE.md`
- **Investigation notes** — written while exploring complex features
- **Feature context** — background and rationale for non-obvious design decisions

This is NOT a replacement for code comments, git history, or `docs/`. It supplements `CLAUDE.md` with detail that would otherwise bloat it.

> **Agents:** When you discover context worth preserving, write it here — not into `CLAUDE.md`. Reference the file from `CLAUDE.md` with a one-line pointer.

## Structure

```text
.ai/
README.md # This file — directory overview
security/ # Vulnerability reports and fix guidance (excluded from git)
```

## Rules for Agents

- **Write here, not in `CLAUDE.md`** — keep CLAUDE.md as a concise rules + pointers file
- **One file per topic** — don't append unrelated notes to an existing file
- **This directory is excluded from distribution zips** — `.distignore` covers `.ai/` recursively
4 changes: 4 additions & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ composer.lock
Gruntfile.js
package.json
package-lock.json
pnpm-lock.yaml
prepros.config
phpunit.xml
phpunit.xml.dist
Expand All @@ -38,3 +39,6 @@ src
assets/img/card
*.map
assets/scss
.ai/
.claude/
CLAUDE.md
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ node_modules/
vendor/
.vscode/
.idea
.claude/worktrees/
package-lock.json
pnpm-lock.yaml
.ai/security/
78 changes: 78 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Disable Comments — Plugin Development Guide

WordPress plugin by WPDeveloper. Allows administrators to globally disable comments by post type, with multisite network support.

- **WordPress.org:** <https://wordpress.org/plugins/disable-comments/>
- **Current version:** 2.6.2
- **Main class:** `Disable_Comments` (singleton) in `disable-comments.php`

---

## Project Structure

```text
disable-comments.php Main plugin file (~2000 lines), single class
includes/
cli.php WP-CLI command definitions
class-plugin-usage-tracker.php
views/
settings.php Main settings page shell
comments.php Tools/delete page shell
partials/
_disable.php Disable-comments form (main settings form)
_delete.php Delete comments form
_sites.php Multisite sub-site list
_menu.php / _footer.php / _sidebar.php
assets/
js/disable-comments-settings-scripts.js Settings page JS (role exclusion UI, AJAX calls)
js/disable-comments.js
css/ scss/
tests/
test-plugin.php PHPUnit tests (Brain/Monkey mocking)
bootstrap.php
```

---

## Key AJAX Handlers

All three AJAX handlers are registered in `__construct()` (~line 49):

| Action | Handler | Line |
| ------ | ------- | ---- |
| `disable_comments_save_settings` | `disable_comments_settings()` | ~1217 |
| `disable_comments_delete_comments` | `delete_comments_settings()` | ~1324 |
| `get_sub_sites` | `get_sub_sites()` | ~1157 |

**Nonce:** All handlers verify nonce `disable_comments_save_settings`. The nonce is created in `admin_enqueue_scripts()` (~line 799) and exposed to JS as `disableCommentsObj._nonce`.

**POST data parsing:** `get_form_array_escaped()` (~line 1202) reads `$_POST['data']` as a URL-encoded string, parses with `wp_parse_args()`, and sanitizes all values with `map_deep(..., 'sanitize_text_field')`.

**Network admin flag:** `$formArray['is_network_admin']` comes from POST data and controls network-wide operations — always verify server-side capability before acting on it.

---

## Development

```bash
npm install # Install JS build deps
npm run build # Compile JS/CSS via Grunt + Babel
npm run release # Build + generate .pot + package release
```

```bash
composer install # Install PHP dev deps (Brain/Monkey for tests)
./vendor/bin/phpunit # Run tests
```

**Linting:** `phpcs.ruleset.xml` is configured for WordPress Coding Standards.

---

## Architecture Notes

- **Singleton pattern:** Always access via `Disable_Comments::get_instance()`.
- **CLI support:** `includes/cli.php` calls the same handler methods with `$_args` to bypass nonce (expected for WP-CLI context; nonce bypass is gated on `$this->is_CLI`).
- **Multisite vs single-site:** Plugin behaviour branches heavily on `$this->networkactive` (set in constructor) and `$this->sitewide_settings`.
- **Database queries:** Use `$wpdb->prepare()` throughout `delete_comments()`. Safe against SQL injection.
- **Input sanitization:** `get_form_array_escaped()` uses `wp_parse_args()` + `map_deep(sanitize_text_field)`.
10 changes: 8 additions & 2 deletions assets/js/disable-comments-settings-scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,10 @@ jQuery(document).ready(function ($) {
}).map(function(val, index){
return val.id;
});
var text = "<b>" + _selectedOptions.join("</b>, <b>") + "</b>";
var escapedOptions = _selectedOptions.map(function(label) {
return $('<span>').text(label).html();
});
var text = "<b>" + escapedOptions.join("</b>, <b>") + "</b>";
excludedRoles.html(sprintf(__("Comments are visible to %s and <b>Logged out users</b>.", "disable-comments"), text));
includedRoles.text(__("No comments will be visible to other roles.", "disable-comments"));
}
Expand All @@ -441,7 +444,10 @@ jQuery(document).ready(function ($) {
var selectedOptionsLabels = selectedOptions.map(function(val, index){
return val.text;
});
var text = "<b>" + selectedOptionsLabels.join("</b>, <b>") + "</b>";
var escapedLabels = selectedOptionsLabels.map(function(label) {
return $('<span>').text(label).html();
});
var text = "<b>" + escapedLabels.join("</b>, <b>") + "</b>";
excludedRoles.html(sprintf(__("Comments are visible to %s.", "disable-comments"), text));
includedRoles.text(__("Other roles and logged out users won't see any comments.", "disable-comments"));
}
Expand Down
36 changes: 26 additions & 10 deletions disable-comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,7 @@ function __construct() {
}

public function is_network_admin() {
$sanitized_referer = isset($_SERVER['HTTP_REFERER']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_REFERER'])) : '';
if (is_network_admin() || !empty($sanitized_referer) && defined('DOING_AJAX') && DOING_AJAX && is_multisite() && preg_match('#^' . network_admin_url() . '#i', $sanitized_referer)) {
return true;
}
return false;
return is_network_admin();

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_network_admin() previously treated multisite AJAX requests originating from Network Admin as “network” by checking the request referer, which ensured network-initiated AJAX calls loaded/saved site options correctly. After simplifying to return is_network_admin();, network admin AJAX requests routed through admin-ajax.php will likely report false, which can cause the constructor to load per-site options during network actions (e.g., affecting $old_options usage when saving network settings). Consider restoring an AJAX-safe network-context detection (e.g., checking for network admin AJAX endpoint or a verified server-side flag) or explicitly loading site options inside the network action handlers when is_network_action is true.

Suggested change
return is_network_admin();
// First, rely on WordPress' native network admin detection.
if (is_network_admin()) {
return true;
}
// For AJAX requests routed through admin-ajax.php, is_network_admin()
// will often return false even when the request originated from the
// Network Admin. In that case, fall back to checking the referer.
if (defined('DOING_AJAX') && DOING_AJAX) {
$referer = wp_get_referer();
if ($referer && false !== strpos($referer, network_admin_url())) {
return true;
}
}
return false;

Copilot uses AI. Check for mistakes.
}
/**
* Enable CLI
Expand Down Expand Up @@ -829,7 +825,7 @@ public function discussion_notice() {
}

// translators: %s: disabled post types.
echo '<div class="notice notice-warning"><p>' . sprintf(esc_html__('Note: The <em>Disable Comments</em> plugin is currently active, and comments are completely disabled on: %s. Many of the settings below will not be applicable for those post types.', 'disable-comments'), implode(esc_html__(', ', 'disable-comments'), $names_escaped)) . '</p></div>';
echo '<div class="notice notice-warning"><p>' . sprintf(__('Note: The <em>Disable Comments</em> plugin is currently active, and comments are completely disabled on: %s. Many of the settings below will not be applicable for those post types.', 'disable-comments'), implode(__(', ', 'disable-comments'), $names_escaped)) . '</p></div>';

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussion_notice() now uses __() and echoes the translated string directly while allowing embedded HTML (<em>). Since this output isn’t passed through wp_kses_post() (or otherwise escaped), a compromised/hostile translation could inject unwanted markup. Consider keeping the translation string plain text (move emphasis tags outside the translatable string) and escape with esc_html__(), or wrap the final formatted string in wp_kses_post() while ensuring dynamic pieces remain escaped.

Copilot uses AI. Check for mistakes.

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This notice now outputs a raw translated string containing HTML via __() and echoes it without any escaping/sanitization. To avoid translator-introduced XSS while still allowing intended markup (e.g., <em>), sanitize the final HTML with wp_kses_post() (or wp_kses() with an explicit allowlist) and keep dynamic pieces escaped as they already are.

Copilot uses AI. Check for mistakes.
}
}

Expand Down Expand Up @@ -1119,7 +1115,7 @@ public function get_roles($selected) {
foreach ($editable_roles as $role => $details) {
$roles[] = [
"id" => esc_attr($role),
"text" => translate_user_role($details['name']),
"text" => esc_html(translate_user_role($details['name'])),
"selected" => in_array($role, (array) $selected),
];
}
Expand Down Expand Up @@ -1162,6 +1158,10 @@ public function get_sub_sites() {
wp_send_json(['data' => [], 'totalNumber' => 0]);
}

if (!current_user_can('manage_network_plugins') && !current_user_can('manage_options')) {
wp_send_json(['data' => [], 'totalNumber' => 0]);

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_sub_sites() currently allows either manage_network_plugins OR manage_options. In multisite setups where site admins can access the plugin settings page (e.g., when site-wide settings are enabled), this would let non-network admins enumerate all site IDs and blog names via AJAX. Consider restricting this endpoint to network admins/capability (manage_network_plugins) when is_multisite() (or otherwise gating it so it only returns data for authorized network-level users), and return a proper JSON error/HTTP status for unauthorized requests instead of an empty dataset.

Suggested change
wp_send_json(['data' => [], 'totalNumber' => 0]);
}
if (!current_user_can('manage_network_plugins') && !current_user_can('manage_options')) {
wp_send_json(['data' => [], 'totalNumber' => 0]);
wp_send_json_error(
[
'message' => __('Invalid request. Please refresh the page and try again.', 'disable-comments'),
'data' => [],
'totalNumber' => 0,
],
400
);
}
// Restrict access more strictly on multisite: only network admins may enumerate subsites.
if (is_multisite()) {
if (!current_user_can('manage_network_plugins')) {
wp_send_json_error(
[
'message' => __('Sorry, you are not allowed to access this resource.', 'disable-comments'),
'data' => [],
'totalNumber' => 0,
],
403
);
}
} else {
if (!current_user_can('manage_options')) {
wp_send_json_error(
[
'message' => __('Sorry, you are not allowed to access this resource.', 'disable-comments'),
'data' => [],
'totalNumber' => 0,
],
403
);
}

Copilot uses AI. Check for mistakes.
}

$_sub_sites = [];
$type = isset($_GET['type']) ? sanitize_text_field(wp_unslash($_GET['type'])) : 'disabled';
$search = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
Expand Down Expand Up @@ -1219,6 +1219,11 @@ public function disable_comments_settings($_args = array()) {
if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) {

$formArray = $this->get_form_array_escaped($_args);
$is_network_action = !empty($formArray['is_network_admin']) && $formArray['is_network_admin'] == '1';
$required_cap = $is_network_action ? 'manage_network_plugins' : 'manage_options';
if (!$this->is_CLI && !current_user_can($required_cap)) {
wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')]);

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wp_send_json_error() here doesn’t set an HTTP status code. Since this error is an authorization failure, returning a 403 status (and ensuring the JS handles non-2xx responses appropriately) makes the behavior consistent with get_sub_sites() and prevents UI states getting stuck on an error response.

Suggested change
wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')]);
wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')], 403);

Copilot uses AI. Check for mistakes.
}

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New capability gating was added to the AJAX settings handlers, but there are no unit/integration tests covering the “network action requires manage_network_plugins vs. site action requires manage_options” behavior. Given this is security-sensitive logic, consider adding PHPUnit coverage to ensure unauthorized users receive an error and authorized users can still save settings.

Copilot uses AI. Check for mistakes.

$old_options = $this->options;
$this->options = [];
Expand Down Expand Up @@ -1256,12 +1261,12 @@ public function disable_comments_settings($_args = array()) {
$this->options['extra_post_types'] = array_diff($extra_post_types, array_keys($post_types)); // Make sure we don't double up builtins.
}

if (isset($formArray['sitewide_settings'])) {
if ($is_network_action && isset($formArray['sitewide_settings'])) {
update_site_option('disable_comments_sitewide_settings', $formArray['sitewide_settings']);
}

if (isset($formArray['disable_avatar'])) {
if ($this->is_network_admin()) {
if ($is_network_action && current_user_can('manage_network_plugins')) {
if ($formArray['disable_avatar'] == '0' || $formArray['disable_avatar'] == '1') {
$sites = get_sites([
'number' => 0,
Expand Down Expand Up @@ -1329,6 +1334,14 @@ public function delete_comments_settings($_args = array()) {
if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
$formArray = $this->get_form_array_escaped($_args);

if (!$this->is_CLI) {
$is_network_action = !empty($formArray['is_network_admin']) && $formArray['is_network_admin'] == '1';
$required_cap = $is_network_action ? 'manage_network_plugins' : 'manage_options';
if (!current_user_can($required_cap)) {
wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')]);

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: this permission failure returns JSON without an HTTP 403 status. Using a 403 (and handling it client-side) improves correctness and aligns with the other AJAX handler (get_sub_sites) which already returns a 403 on insufficient permissions.

Suggested change
wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')]);
wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')], 403);

Copilot uses AI. Check for mistakes.
}
}
Comment on lines 1350 to +1371

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete_comments_settings() also returns wp_send_json_success() even when the nonce check fails, because the success response is sent unconditionally after the guarded block. This can report a successful deletion (often with a meaningless “.” message) for invalid/expired nonces. Send a JSON error (HTTP 403) and exit when nonce verification fails.

Copilot uses AI. Check for mistakes.

if (!empty($formArray['is_network_admin']) && function_exists('get_sites') && class_exists('WP_Site_Query')) {
$sites = get_sites([
'number' => 0,
Expand All @@ -1337,6 +1350,9 @@ public function delete_comments_settings($_args = array()) {
foreach ($sites as $blog_id) {
// $formArray['disabled_sites'] ids don't include "site_" prefix.
if (!empty($formArray['disabled_sites']) && !empty($formArray['disabled_sites']["site_$blog_id"])) {
if (!is_super_admin() && !is_user_member_of_blog(get_current_user_id(), $blog_id)) {
continue; // Skip sites the user doesn't belong to
}
Comment on lines +1361 to +1383

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new permission logic and multisite membership filtering in delete_comments_settings() isn’t covered by tests. Add PHPUnit tests to assert 403s for insufficient capability and nonce failure, plus a multisite test where a non-super-admin cannot delete comments on a site they’re not a member of (ensuring those sites are skipped/blocked as intended).

Copilot uses AI. Check for mistakes.
switch_to_blog($blog_id);
$log = $this->delete_comments($_args);
restore_current_blog();
Expand Down Expand Up @@ -1850,7 +1866,7 @@ public function add_site_health_info($debug_info) {
),
'disabled_post_type_count' => array(
'label' => __('Disabled Post Types Count', 'disable-comments'),
'value' => sprintf('%d of %d', count($data['disabled_post_types']), $data['total_post_types']),
'value' => sprintf(__('%1$d of %2$d', 'disable-comments'), count($data['disabled_post_types']), $data['total_post_types']),

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses __() inside sprintf() for a value intended to be plain text in Site Health. Using an escaping i18n helper (e.g., esc_html__()) prevents translations from injecting markup into the debug output.

Suggested change
'value' => sprintf(__('%1$d of %2$d', 'disable-comments'), count($data['disabled_post_types']), $data['total_post_types']),
'value' => sprintf(esc_html__('%1$d of %2$d', 'disable-comments'), count($data['disabled_post_types']), $data['total_post_types']),

Copilot uses AI. Check for mistakes.
),
'disabled_post_types' => array(
'label' => __('Disabled Post Types', 'disable-comments'),
Expand Down
Loading