Skip to content

Commit 86aa7d3

Browse files
authored
Merge pull request #88 from plugpressco/build/rc3
chore: merge site-editor branch and cut 1.0.0-rc3
2 parents 65043cf + d668c7c commit 86aa7d3

32 files changed

Lines changed: 3252 additions & 532 deletions
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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

Comments
 (0)