|
| 1 | +--- |
| 2 | +name: wp-security-rules |
| 3 | +description: Saddle-specific WordPress security rules. Load when reviewing or writing PHP that handles a request (REST route, ability permission/execute callback, admin_post handler, cron callback), renders output, or touches the database. Encodes the tier system, approval gate, and escaping-context judgments that phpcs cannot make. |
| 4 | +--- |
| 5 | + |
| 6 | +# Saddle security rules |
| 7 | + |
| 8 | +Covers `plugpress/saddle` and `plugpress/saddle-pro`. Every rule is a **semantic** |
| 9 | +judgment; phpcs (`WordPress` ruleset, `composer lint` in CI) already catches the |
| 10 | +mechanical failures listed at the end — do not re-report those. |
| 11 | + |
| 12 | +Saddle is an MCP server: 108 agent-callable abilities, an OAuth 2.1 server, a |
| 13 | +React admin. The caller is normally an AI agent holding an Application Password |
| 14 | +or OAuth token, not a browser session. That inverts one usual WordPress |
| 15 | +assumption — see rule 5. |
| 16 | + |
| 17 | +The codebase is currently clean against every rule here, so BAD examples are |
| 18 | +marked **constructed**: they are the failure the rule prevents, not code that |
| 19 | +exists. GOOD examples are always real, with file:line. |
| 20 | + |
| 21 | +## Bindings |
| 22 | + |
| 23 | +Repo-specific names in one place. Porting these rules elsewhere means rewriting |
| 24 | +this table and re-deriving every example. |
| 25 | + |
| 26 | +| Role | This codebase | |
| 27 | +|---|---| |
| 28 | +| Class / option prefix | `Saddle_` `saddle_` · `Saddle_Pro_` `saddle_pro_` | |
| 29 | +| Text domain | `saddle` · `saddle-pro` | |
| 30 | +| Ability ids | `saddle/dash-case` (Pro too, as `saddle/divi-*`) | |
| 31 | +| Permission gate | `Saddle_Capabilities::permission( $tier, $cap, $short )` | |
| 32 | +| Effective tier (decide) | `Saddle_Capabilities::get_tier()` | |
| 33 | +| Configured tier (report) | `Saddle_Capabilities::get_site_tier()` | |
| 34 | +| Destructive gate | `Saddle_Approval::gate()` | |
| 35 | +| Object-level authz | `Saddle_Abilities::authorize_write()` · `Saddle_Pro_Divi::editable_divi5_post()` | |
| 36 | +| SSRF guard | `Saddle_HTTP::url_is_safe()` | |
| 37 | +| Admin REST gate | `Saddle_REST_Admin::can_manage` | |
| 38 | +| Option allowlist | `Saddle_Abilities::guard_option()` | |
| 39 | +| Audit log | `Saddle_Log::record()` / `record_action()` | |
| 40 | +| Constant-time compare | `Saddle_OAuth::secure_equals()` | |
| 41 | +| PHP floor | 7.4 (saddle) · 8.0 (saddle-pro) | |
| 42 | + |
| 43 | +## Severity |
| 44 | + |
| 45 | +**CRITICAL** — exploitable now by a caller holding a credential the site itself |
| 46 | +hands out. *e.g.* an ability that writes post content but never checks |
| 47 | +`edit_post` against the target id, letting a Contributor-level Application |
| 48 | +Password edit an Administrator's page. |
| 49 | + |
| 50 | +**HIGH** — a required control is missing or incomplete, no proven path to reach |
| 51 | +it. *e.g.* a destructive ability that calls `Saddle_Approval::gate()` but passes |
| 52 | +no `bind`, so preview-swap is possible in principle. |
| 53 | + |
| 54 | +**NOTE** — hardening; the control is present and correct, the concern is blast |
| 55 | +radius. *e.g.* `saddle/activate-theme` (`includes/abilities/site.php:129`) is |
| 56 | +ungated because switching a theme is reversible, which fits the stated gate |
| 57 | +criterion — but it changes every page on the site in one agent call. |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## 1. The capability must match what the ability touches — CRITICAL |
| 62 | + |
| 63 | +`Saddle_Capabilities::permission()` takes a capability and nothing verifies it is |
| 64 | +the *right* one. Pick it from the object mutated, not from the tier. |
| 65 | + |
| 66 | +```php |
| 67 | +// BAD (constructed) — seeds site-wide design tokens, gated as a post edit |
| 68 | +'permission_callback' => Saddle_Capabilities::permission( 'write', 'edit_posts', 'bootstrap-design-system' ), |
| 69 | +// GOOD — includes/abilities/blocks.php:122 |
| 70 | +'permission_callback' => Saddle_Capabilities::permission( 'admin', 'edit_theme_options', 'bootstrap-design-system' ), |
| 71 | +``` |
| 72 | + |
| 73 | +Established pairings: theme/design → `edit_theme_options` (`blocks.php:122`, |
| 74 | +`saddle-pro/includes/abilities/divi-design.php:61`); options → `manage_options` |
| 75 | +(`site.php:226`); plugins → `activate_plugins` (`site.php:78`); media → |
| 76 | +`upload_files` (`core-content.php:398`); taxonomy → `manage_categories` |
| 77 | +(`core-content.php:470`); users → `list_users` (`users.php:60`). |
| 78 | + |
| 79 | +## 2. `get_tier()` decides, `get_site_tier()` reports — CRITICAL |
| 80 | + |
| 81 | +Not interchangeable. `get_tier()` applies the `saddle_tier_ceiling` filter, which |
| 82 | +is how an OAuth token's granted scope *lowers* the effective tier. |
| 83 | +`get_site_tier()` is the owner's configured value and ignores the caller. Using |
| 84 | +it in an access decision discards the OAuth clamp — a `saddle:read` token would |
| 85 | +get write on a write-tier site. |
| 86 | + |
| 87 | +```php |
| 88 | +// BAD (constructed) — ignores the scope ceiling on this credential |
| 89 | +if ( 'admin' === Saddle_Capabilities::get_site_tier() ) { /* allow */ } |
| 90 | +// GOOD — includes/class-saddle-capabilities.php:182 |
| 91 | +return self::$levels[ self::get_tier() ] >= self::$levels[ $required ]; |
| 92 | +``` |
| 93 | + |
| 94 | +`get_site_tier()` is correct only for config display and the clone-domain warning |
| 95 | +(`includes/admin/class-saddle-rest.php:505`, `class-saddle-capabilities.php:445`). |
| 96 | + |
| 97 | +## 3. `destructive => true` implies `gate()`, and `gate()` needs `bind` — HIGH |
| 98 | + |
| 99 | +All 7 destructive abilities gate today; keep the correspondence 1:1 both ways. |
| 100 | + |
| 101 | +`bind` is the non-obvious half. The token binds *action* and *target* but not the |
| 102 | +payload, so a token issued for one preview is otherwise replayable with different |
| 103 | +arguments. Any gate whose outcome depends on a caller-varied value must bind it. |
| 104 | + |
| 105 | +```php |
| 106 | +// BAD (constructed) — a trash preview's token also confirms force-delete |
| 107 | +return Saddle_Approval::gate( array( |
| 108 | + 'action' => $action, 'target' => (string) $id, 'input' => $input, 'execute' => $run, |
| 109 | +) ); |
| 110 | +// GOOD — includes/abilities/core-content.php:1967 |
| 111 | +'bind' => $force ? 'permanent' : 'trash', |
| 112 | +// GOOD — includes/abilities/site.php:586 (binds the proposed value) |
| 113 | +'bind' => substr( hash( 'sha256', wp_json_encode( $value ) ), 0, 16 ), |
| 114 | +``` |
| 115 | + |
| 116 | +## 4. The permission callback is necessary, never sufficient — CRITICAL |
| 117 | + |
| 118 | +Core's post insert/update/delete primitives do not apply `map_meta_cap`, so the |
| 119 | +generic capability says nothing about *this* object. Every write path re-checks |
| 120 | +the target inside the execute callback. |
| 121 | + |
| 122 | +```php |
| 123 | +// BAD (constructed) — permission_callback passed edit_posts; nothing checks THIS post |
| 124 | +public static function update_post( $input ) { |
| 125 | + return wp_update_post( array( 'ID' => (int) $input['id'], 'post_title' => $input['title'] ) ); |
| 126 | +} |
| 127 | +// GOOD — includes/abilities/core-content.php:1873 |
| 128 | +$denied = self::authorize_write( $type, $input, (int) $existing->post_author, $id ); |
| 129 | +``` |
| 130 | + |
| 131 | +`authorize_write()` (`core-content.php:868`) covers `edit_post` on the target, |
| 132 | +`publish_posts`/`publish_pages` on status change to publish, and |
| 133 | +`edit_others_posts`/`edit_others_pages` on author reassignment. |
| 134 | + |
| 135 | +**Pro has three funnels, not one** — `authorize_write()` is private to |
| 136 | +`Saddle_Abilities`, so Pro re-derives the check. A new Pro write ability must |
| 137 | +route through `Saddle_Pro_Divi::editable_divi5_post()` |
| 138 | +(`includes/builders/divi/class-divi.php:108`), `edit_guard()` |
| 139 | +(`includes/abilities/divi.php:789`), or `locate_module()` (`divi.php:1851`). |
| 140 | +`set_page` keeps a documented inline guard (`divi.php:1272`) — it deliberately |
| 141 | +allows building an empty non-Divi post. Any other route to `post_content` is a |
| 142 | +finding. |
| 143 | + |
| 144 | +## 5. Agent-facing payloads must NOT be HTML-escaped — HIGH |
| 145 | + |
| 146 | +Ability return values are JSON for an AI agent, not HTML for a browser. |
| 147 | +`esc_html()`/`wp_kses()` corrupts instruction text — angle-bracket placeholders |
| 148 | +like `<id>` in a skill body are exactly what the agent must receive verbatim. |
| 149 | +This is the one place generic WordPress advice points the wrong way. |
| 150 | + |
| 151 | +```php |
| 152 | +// BAD (constructed) — mangles the playbook the agent is meant to follow |
| 153 | +return array( 'body' => esc_html( $skill['body'] ) ); |
| 154 | +// GOOD — includes/class-saddle-skills.php:319 (UTF-8 + control-char strip only) |
| 155 | +$body = wp_check_invalid_utf8( (string) $body, true ); |
| 156 | +$body = preg_replace( '/[^\P{C}\n\t]/u', '', $body ); |
| 157 | +``` |
| 158 | + |
| 159 | +Corollary: because output escaping is not doing the work, **input sanitization at |
| 160 | +the store boundary is**. Route `args` carry no `sanitize_callback` (0 of 38 |
| 161 | +routes) — sanitizing lives in the store class. A new store method that persists |
| 162 | +caller text unsanitized is a finding even though its route looks like its |
| 163 | +neighbours. Real boundaries: `Saddle_Context::set_user()` → |
| 164 | +`sanitize_textarea_field` (`class-saddle-context.php:45`); |
| 165 | +`Saddle_Memory::remember()` → `wp_kses( …, array() )` (`class-saddle-memory.php:100`). |
| 166 | + |
| 167 | +## 6. Escaping context on the two HTML surfaces — HIGH |
| 168 | + |
| 169 | +Server-rendered HTML exists in exactly two files: the OAuth consent screen |
| 170 | +(`includes/oauth/class-saddle-oauth-consent.php`) and the Pro license page |
| 171 | +(`saddle-pro/includes/class-license-page.php`). Judge the escaper by the slot, |
| 172 | +not the variable. |
| 173 | + |
| 174 | +```php |
| 175 | +// BAD (constructed) — esc_attr in an href slot permits javascript: URLs |
| 176 | +echo '<form action="' . esc_attr( $url ) . '">'; |
| 177 | +// GOOD — class-saddle-oauth-consent.php:162,165 (URL slot vs attribute slot) |
| 178 | +echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">'; |
| 179 | +echo '<input type="hidden" name="saddle_req" value="' . esc_attr( $request_id ) . '">'; |
| 180 | +``` |
| 181 | + |
| 182 | +`class-saddle-oauth-consent.php:154` prints `redirect_uri` with `esc_html()`, not |
| 183 | +`esc_url()` — correct: it is displayed as text, not used as a target, and |
| 184 | +`esc_url()` would silently rewrite what the user is shown before deciding. |
| 185 | + |
| 186 | +## 7. External fetches use `Saddle_HTTP::url_is_safe()` — CRITICAL |
| 187 | + |
| 188 | +`wp_http_validate_url()` does **not** block link-local `169.254.0.0/16` — the |
| 189 | +cloud metadata range (`169.254.169.254`). Any fetch of a caller-supplied URL uses |
| 190 | +the shared guard. |
| 191 | + |
| 192 | +```php |
| 193 | +// BAD (constructed) — reaches cloud instance metadata |
| 194 | +if ( wp_http_validate_url( $url ) ) { $body = wp_remote_get( $url ); } |
| 195 | +// GOOD — includes/class-saddle-http.php:84 |
| 196 | +if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { |
| 197 | +``` |
| 198 | + |
| 199 | +Callers: `source_url_is_safe()` (`core-content.php:916`) for media sideload, and |
| 200 | +OAuth client-metadata fetching. The DNS-rebinding TOCTOU is documented at |
| 201 | +`core-content.php:902` — do not re-report it. |
| 202 | + |
| 203 | +## 8. Option reads and writes go through `guard_option()` — CRITICAL |
| 204 | + |
| 205 | +Arbitrary option writes are site takeover (`siteurl`, `default_role`, |
| 206 | +`users_can_register`). The blocklist is pattern-based, catching |
| 207 | +`secret|salt|nonce|token|password|auth_key|_key$|user_roles|capabilities` |
| 208 | +(`site.php:854`). |
| 209 | + |
| 210 | +```php |
| 211 | +// BAD (constructed) — trusts the allowlist filter without the blocklist |
| 212 | +if ( in_array( $name, apply_filters( 'saddle_option_allowlist', array() ), true ) ) { |
| 213 | + update_option( $name, $value ); |
| 214 | +} |
| 215 | +// GOOD — includes/abilities/site.php:867 |
| 216 | +if ( ! in_array( $name, self::allowlist(), true ) ) { return new WP_Error( 'saddle_option_not_allowed', … ); } |
| 217 | +``` |
| 218 | + |
| 219 | +`allowlist()` (`site.php:798`) re-filters through `is_blocked_option()` *after* |
| 220 | +applying `saddle_option_allowlist`, so a third-party filter cannot widen the set |
| 221 | +into security material. Preserve that ordering in any new path. |
| 222 | + |
| 223 | +## 9. Nonce scope must cover the target, not just the action — HIGH |
| 224 | + |
| 225 | +A nonce naming only the action class is replayable across every target of that |
| 226 | +action. |
| 227 | + |
| 228 | +```php |
| 229 | +// BAD (constructed) — a nonce from any pending request approves any other |
| 230 | +check_admin_referer( self::ACTION ); |
| 231 | +// GOOD — includes/oauth/class-saddle-oauth-consent.php:183 |
| 232 | +check_admin_referer( self::ACTION . '_' . $request_id ); |
| 233 | +``` |
| 234 | + |
| 235 | +Paired with `wp_nonce_field( self::ACTION . '_' . $request_id )` at line 163. |
| 236 | +Pro's license actions (`class-license-page.php:155`) use unscoped nonces — |
| 237 | +correct there, the action has no target to bind. |
| 238 | + |
| 239 | +## 10. Compare secrets in constant time — HIGH |
| 240 | + |
| 241 | +```php |
| 242 | +// BAD (constructed) — leaks the signature byte-by-byte under timing analysis |
| 243 | +if ( $signature === self::signature( $post_id, $expires, $secret ) ) { return true; } |
| 244 | +// GOOD — includes/preview/class-saddle-preview.php:105 |
| 245 | +if ( '' !== $secret && hash_equals( self::signature( (int) $post_id, $expires, $secret ), $signature ) ) { |
| 246 | +``` |
| 247 | + |
| 248 | +Also `Saddle_OAuth::secure_equals()` (`class-saddle-oauth.php:350`) for |
| 249 | +non-string-safe input, and PKCE at `oauth/class-saddle-oauth-endpoints.php:385`. |
| 250 | + |
| 251 | +## 11. A new `__return_true` needs a written reason — NOTE |
| 252 | + |
| 253 | +Eleven public routes exist, all OAuth-spec-required plus one documented probe. A |
| 254 | +twelfth needs a comment stating why it must be unauthenticated and what it |
| 255 | +discloses. |
| 256 | + |
| 257 | +```php |
| 258 | +// GOOD — includes/class-saddle-connection.php:358 |
| 259 | +// Unauthenticated: reports only whether an Authorization header arrived. |
| 260 | +// Used exclusively by self_check()'s loopback request to itself. |
| 261 | +'permission_callback' => '__return_true', |
| 262 | +``` |
| 263 | + |
| 264 | +--- |
| 265 | + |
| 266 | +## Known-safe patterns — do not flag |
| 267 | + |
| 268 | +- **11 OAuth `__return_true` routes** — `oauth/class-saddle-oauth-discovery.php:84` |
| 269 | + (shared by 7 docs), `oauth/class-saddle-oauth-endpoints.php:43,53,63`, |
| 270 | + `oauth/class-saddle-oauth-clients.php:102`. Required by RFC 8414/9728 + MCP spec. |
| 271 | +- **`/auth-probe`** — `class-saddle-connection.php:366`. Returns booleans about |
| 272 | + the caller's own headers; reads no credential. |
| 273 | +- **MCP transport gated only on `is_user_logged_in()`** — `class-saddle-mcp.php:168`. |
| 274 | + Per-tool authorization is each ability's `permission_callback`. |
| 275 | +- **Unescaped echo of captured admin notices** — `includes/admin/class-saddle-settings.php:134`, |
| 276 | + standing `phpcs:ignore`. Other plugins' rendered HTML moved to a hidden |
| 277 | + container; escaping breaks their dismiss buttons. |
| 278 | +- **`printf()` with `<code>` in the argument** — `class-saddle-oauth-consent.php:114-118`. |
| 279 | + Format string escaped, interpolated value `esc_html()`'d, tags intentional. |
| 280 | +- **Both `$wpdb` calls** — `class-saddle-unsplash.php:326`, |
| 281 | + `oauth/class-saddle-oauth-store.php:190`. Prepared; `{$wpdb->posts}` / |
| 282 | + `{$wpdb->postmeta}` are table names, not user input. |
| 283 | +- **Preview capability URLs** — `includes/preview/class-saddle-preview.php`. An |
| 284 | + unauthenticated token holder reads one draft by design: HMAC-signed, |
| 285 | + post-bound, 300s TTL, `noindex`. |
| 286 | +- **Skill bodies kept byte-identical** — `class-saddle-skills.php:319`. See rule 5. |
| 287 | +- **`@dns_get_record()` silenced** — `class-saddle-http.php:62`. Deliberate; an |
| 288 | + attacker-chosen name must not warn or throw. |
| 289 | +- **Verbose ability `description` fields** — agent-facing API surface, not |
| 290 | + comments. |
| 291 | + |
| 292 | +## Already covered by phpcs — do not re-report |
| 293 | + |
| 294 | +The `WordPress` ruleset reliably catches: missing `esc_*` on echo/print, missing |
| 295 | +`wp_unslash()`, unsanitized `$_GET`/`$_POST`/`$_SERVER`, direct DB queries without |
| 296 | +`prepare()`, missing translator comments, missing text domain, missing |
| 297 | +`defined( 'ABSPATH' ) || exit`, `eval`/`exec`/`shell_exec`/`proc_open`, and PHP |
| 298 | +incompatibilities below 7.4. Four sniffs are excluded in `phpcs.xml.dist` (file |
| 299 | +naming, mixed function/OO layout, two docblock-capitalization sniffs) — none is a |
| 300 | +security exclusion. |
0 commit comments