Repository navigation
docs(plugin-dev): add Plugin Team security directives - #129
dhruvang21 wants to merge 1 commit into
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. Props Bot commandsYou can trigger Props Bot actions by commenting on this PR:
|
|
@dhruvang21 - Happy to work with you on this skill, it's an important and nuanced one, and will likely need iteration. I have a package that generates WordPress plugins from specifications, and it almost 100% of the time creates plugins that pass security guidelines for the Plugin Check Plugin (and wp.org/plugins submission.) Working up some notes, I can get them to you later today for review. One way you could smoke test this, is to use the agent skill to scaffold a plugin that has intentional security issues in WordPress Studio, and then use the PCP to see if your plugin passes security checks. Studio Code / Claude prompt
The last line can be useful for creating test cases to run against the PCP CLI. |
|
Thanks @flexseth! Happy to iterate on this together, and I’d welcome your feedback. The smoke test is a good idea. One adjustment, though: I don’t think a strict 1:1 mapping between checklist items and PCP failures will hold. Many directives in this PR are context-dependent, and static checks can’t reliably catch issues like whether a I’d suggest splitting the test into two parts:
Also, if you could tag a few folks from the plugin teams to review and test this, that would be really helpful. Their practical experience would help validate the approach, catch gaps we might miss, and ensure we’re heading in the right direction. |
|
75a7638 is one very large change set and difficult to review. Do you think you can restructure the PR as a series of commits that addresses the checklist items? workflowPlan mode Manual mode consumable commits providing context more on plan mode.. Delivery: Detailed commit messages and more commits will make the PR easier to consume and review. EvalsRegarding:
Could you elaborate on this? I don't see any evals added with the PR... |
| ```php | ||
| // Unsafe: sanitize_key() keeps the name well-formed, but the request still | ||
| // chooses which option is overwritten (users_can_register, default_role, ...). | ||
| $name = sanitize_key( wp_unslash( $_POST['option'] ?? '' ) ); | ||
| update_option( $name, sanitize_text_field( wp_unslash( $_POST['value'] ?? '' ) ) ); | ||
|
|
||
| - never process the entire `$_POST` / `$_GET` array; read explicit keys | ||
| - use `wp_unslash()` before sanitizing when needed | ||
| - use prepared statements for SQL; avoid interpolating user input into queries | ||
| // Safe: the request can only pick from a fixed map. | ||
| $options = array( | ||
| 'color' => 'myplugin_color', | ||
| 'layout' => 'myplugin_layout', | ||
| ); | ||
| $key = sanitize_key( wp_unslash( $_POST['setting'] ?? '' ) ); | ||
| if ( ! isset( $options[ $key ] ) ) { | ||
| wp_send_json_error( null, 400 ); | ||
| } | ||
| update_option( $options[ $key ], sanitize_text_field( wp_unslash( $_POST['value'] ?? '' ) ) ); | ||
| ``` |
There was a problem hiding this comment.
Unclear which rule this code applies to
| - Validate and sanitize input before storing it, displaying it, or passing it to a function whose behavior you don't control (hooks, third-party APIs). | ||
| - Never process a whole `$_POST` / `$_GET` / `$_REQUEST` array; read explicit keys and apply `wp_unslash()` before sanitizing. | ||
| - Sanitize a nonce before passing it to `wp_verify_nonce()`: `sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) )`. `check_admin_referer()` and `check_ajax_referer()` read the request themselves. | ||
| - Treat input as unsafe in sensitive positions even after sanitization. A fixed allowlist, strict comparison (`in_array( $v, $allowed, true )`), or hardcoded map must decide: |
There was a problem hiding this comment.
What does "sensitive positions" mean?
| - Treat table names, column names, `ORDER BY` keys, and other identifiers from the request as unsafe unless they come from an allowlist. Pass allowlisted identifiers through `%i` (WordPress 6.2+). If the plugin's `Requires at least` is below 6.2, interpolate only the allowlisted constant value. | ||
| - Prefer `$wpdb->insert()` / `$wpdb->update()` / `$wpdb->delete()` with a formats array for simple writes. | ||
|
|
||
| ```php |
There was a problem hiding this comment.
what rule does this code snippet explain?
|
|
||
| **Prohibited** | ||
|
|
||
| - Features that let users insert arbitrary PHP, JavaScript, or CSS to be executed or output on the site. |
There was a problem hiding this comment.
this line is marked as a bullet point, but appears to be a section summary?
| - Keep magic methods free of side effects. `__wakeup()`, `__destruct()`, and `__toString()` must not delete or write files, include paths, or call callbacks stored in properties; otherwise any deserialization bug elsewhere on the site can use the class as a gadget. Classes that hold paths, callbacks, or credentials and are never meant to be serialized should refuse it: | ||
|
|
||
| ```php | ||
| public function __wakeup() { | ||
| throw new \LogicException( 'This class cannot be unserialized.' ); | ||
| } | ||
| ``` |
There was a problem hiding this comment.
Starting point of a good pattern: Bulleted item with immediate example after.
Code example could show the "not allowed" version of the code, such as the other code samples
| ```php | ||
| // Unsafe: persistent data in the plugin folder, with a request-chosen file name. | ||
| file_put_contents( plugin_dir_path( __FILE__ ) . $_POST['name'] . '.php', $data ); | ||
|
|
||
| // Safe: plugin subdirectory of uploads, fixed name, non-executable format. | ||
| $uploads = wp_upload_dir(); | ||
| $dir = trailingslashit( $uploads['basedir'] ) . 'myplugin'; | ||
| if ( $uploads['error'] || ! wp_mkdir_p( $dir ) ) { | ||
| return new WP_Error( 'myplugin_storage', __( 'Storage is unavailable.', 'myplugin' ) ); | ||
| } | ||
| file_put_contents( $dir . '/export.json', wp_json_encode( $data ) ); | ||
|
|
||
| // Safe delete: target must resolve inside the plugin's directory. | ||
| $base = realpath( $dir ); | ||
| $target = realpath( $dir . '/' . sanitize_file_name( $requested_file ) ); | ||
| if ( false === $base || false === $target || 0 !== strpos( $target, $base . DIRECTORY_SEPARATOR ) ) { | ||
| return new WP_Error( 'myplugin_invalid_path', __( 'Invalid file.', 'myplugin' ) ); | ||
| } | ||
| wp_delete_file( $target ); | ||
| ``` |
There was a problem hiding this comment.
Add or move section heading with relevant security guideline directly above this code.
| **Exceptions** (each needs a stated functional reason) | ||
|
|
||
| - Drop-ins such as `object-cache.php` or `advanced-cache.php` for caching plugins, written only on an explicit administrator action and removed when the feature is turned off. | ||
| - Backup archives, protected from direct download (unguessable names, deny rules, or storage outside the web root). | ||
| - Temporary files from `wp_tempnam()`, deleted when the operation ends. | ||
| - Root-level files that an integration requires (for example, a service verification file), created only on an explicit administrator action. | ||
| - SVG uploads, only when sanitized server-side by a maintained sanitizer. |
There was a problem hiding this comment.
Exception requires a "stated functional reason".
Add why these exceptions are OK. Give examples, skills should have a deterministic value.
How will the harness know when to use an exception to the rule? Don't leave the AI to guess.
The harness should be able to infer from instructions when and why to use the exception.
|
|
||
| - Document every fixed third-party service the plugin contacts in the plugin readme: what the service is, what it is used for, what data is sent and when, and links to its Terms of Service and/or Privacy Policy. For the directory consent and SaaS rules, see the `wp-plugin-directory-guidelines` skill (Guidelines 6–8). | ||
| - Use `wp_safe_remote_get()` / `wp_safe_remote_post()` when a URL comes from input or settings. They reject local and private-network hosts, which blocks server-side request forgery. | ||
| - Never disable TLS verification (`'sslverify' => false`) in shipped code. |
There was a problem hiding this comment.
What is "shipped code?"
Much of the agent skills' work may happen in dev. How does the AI know where the code lives or is intended for?
This requirement probably should be added as an eval like https://github.com/WordPress/agent-skills/blob/trunk/eval/scenarios/plugin-security-nonce-and-capability.json
|
|
||
| **Required** | ||
|
|
||
| - Document every fixed third-party service the plugin contacts in the plugin readme: what the service is, what it is used for, what data is sent and when, and links to its Terms of Service and/or Privacy Policy. For the directory consent and SaaS rules, see the `wp-plugin-directory-guidelines` skill (Guidelines 6–8). |
There was a problem hiding this comment.
Context bloat: Link directly to file instead of loading skill.
The wp-plugin-directory-guidelines/references directory has a file with relevant information. Link there.
| Upstream reference: | ||
|
|
||
| - https://make.wordpress.org/core/2026/03/24/introducing-the-ai-client-in-wordpress-7-0/ | ||
|
|
There was a problem hiding this comment.
add relevant upstream references for this section. It covers a lot of platform surfaces.
| ## Chained vulnerabilities | ||
|
|
||
| Assess each weakness by what it unlocks in combination, not in isolation. A "low-severity" gap is often one link in a full site takeover: | ||
|
|
||
| - **Missing capability check → stored XSS → admin takeover.** A subscriber can save a value the plugin later prints unescaped in wp-admin. The script runs in an administrator's session, can read any nonce on the page, and can create an administrator, edit plugin files, or install a plugin. Nonces and capability checks don't stop it, because the request comes from a real administrator. Fix both links: authorize the write *and* escape the output. | ||
| - **Arbitrary option update → privilege escalation.** An unrestricted `update_option()` lets an attacker set `users_can_register` and `default_role` to `administrator`, then register. Fix: authorize, and allowlist the option name. | ||
| - **Object injection → gadget chain → file write → code execution.** Untrusted `unserialize()` plus any loaded class whose `__destruct()` or `__wakeup()` writes or deletes files is enough. Fix: no untrusted deserialization, and no side effects in magic methods. | ||
| - **Path traversal or unrestricted upload → executable file in the web root.** Fix: plugin-owned directory, fixed names, path validation, no executable types. |
There was a problem hiding this comment.
this section has explanations that read clearly.
| * For `wp_ajax_nopriv_*`, assume unauthenticated attacker-controlled traffic. | ||
| * Return JSON with `wp_send_json_success()` or `wp_send_json_error()`. | ||
| * Do not expose private data from AJAX responses. | ||
| When fixing a finding, check what else reaches the same data, sink, or file, and close every link, not just the one reported. |
There was a problem hiding this comment.
dangling statement. provide context. Should "sink" be "sync?"
Could possibly use a section intro or more explanation.
| - [ ] Every privileged action, sensitive read, or persistent write checks authorization *and* CSRF protection. | ||
| - [ ] Every REST route and ability has a `permission_callback` that resolves and enforces the right capability. | ||
| - [ ] Security checks fail closed: their return values are checked, and the request stops when they fail. | ||
| - [ ] Input is read by explicit key, unslashed, validated, and sanitized for its type. | ||
| - [ ] Request values that select names, paths, callbacks, capabilities, or targets go through a fixed allowlist or map. | ||
| - [ ] Output is escaped late, including shortcode, dynamic block, and filter callback output. | ||
| - [ ] SQL uses `$wpdb->prepare()` for values and allowlisted identifiers. | ||
| - [ ] No code execution, executable uploads, or writes outside the plugin's uploads subdirectory. | ||
| - [ ] No `unserialize()` / `maybe_unserialize()` of untrusted data; magic methods have no dangerous side effects. | ||
| - [ ] Each weakness was assessed for what it unlocks when chained with others, not in isolation. | ||
| - [ ] External services are documented in the readme; credentials come from platform APIs. | ||
| - [ ] No global sessions, global PHP config changes, or silent plugin/theme/update changes. |
There was a problem hiding this comment.
Checklist should probably be created as individual evals or one large plugin security eval, whichever requires less compute.
I'm not very familiar with evals, but this seems like eval territory.
There was a problem hiding this comment.
Checklist is given, but the context of the checklist items is not provided in the relevant sections in the rest of the spec.
Group checklist items by section, and add the checklist item as a brief intro per section.
This is part of a greater organizational restructure I feel this PR very much needs.
For instance, if someone wanted to get into plugin security, they aren't going to get much value with the way the PR is organized right now.
Write for humans and AI. At some point a human will be involved in the review process, or may be updating this document. Having a better organization structure will help.
|
|
||
| For REST schemas and argument validation, see the `wp-rest-api` skill; for abilities, see the `wp-abilities-api` skill. | ||
|
|
||
| Upstream reference: |
There was a problem hiding this comment.
add more references, link to relevant sections instead of whole doc pages. There is a lot covered in this section.
flexseth
left a comment
There was a problem hiding this comment.
Hey Dhruvang ,
I took a couple of hours today (4) to review this PR. It needs a lot of work. It seems like Claude interpreted the request as a new skill, and not an improvement to an existing skill. Please accept these comments and suggestions as solid constructive criticism, and in no way negative. If you want, I'd be happy to get on a Zoom call with you sometime to go over the methodology I used here.
The LLM seems to have glanced over the Agent Skills repository and cherry-picked what it thought were some best practices, but missed some very important conventions for developing good skills.
If it's OK with you, I'd like to take the directives that have been laid out, and rework the skill so it will be consumable for humans and AI. We can even look at this together during the call if you want, I have time next week.
Your contributions will be included in the PR, that I will be comfortable submitting to the maintainers for their review. And you'll get credit for contributing to the repo.
Hopefully my feedback isn't too much with the comments on the PR issue body and the code surface. They're intended to guide you towards being a better WordPress skills developer.
Let me know if that works,
Seth
Summary
Closes #87.
Expands the
wp-plugin-developmentsecurity guidance with the Plugin Team security directives. Each rule is marked Prohibited, Required, or an Exception, and every exception needs a stated functional reason.references/security.md: adds a checklist and covers all six groups from the issue: request authorization and REST permissions; sanitization vs. allowlisting; output and SQL; code execution, uploads, and files; external services and credentials; lifecycle safeguards. Includes safe/unsafe examples for REST permissions, dynamic option names, SQL identifiers, file writes and deletes, and shortcode output.SKILL.md: step 4 now points to the checklist as part of the normal workflow.Added during review, beyond the issue's list:
unserialize(), no side effects in magic methods,phar://paths.is_admin()is not authorization;ABSPATHguard; command injection; SSRF (wp_safe_remote_*);sslverify;wp_safe_redirect().Policy topics (readme disclosure, consent, remote code) link to
wp-plugin-directory-guidelinesinstead of repeating it. Nothing from the scanner's implementation is included; all guidance comes from public WordPress documentation.Testing
node shared/scripts/validate-skills.mjspasses.php -l.plugin-security-nonce-and-capability.jsonnot yet run manually.🤖 Generated with Claude Code