$npx -y skills add Lonsdale201/wp-agent-skills --skill br-cors-public-clientConfigure better-route 1.1 CORS for browser, mobile, and embedded WordPress REST clients. Use for CorsPolicy, CorsMiddleware, WordPressCorsBridge, allowed origins/methods/headers, credentials, OPTIONS preflight, Authorization, X-WP-Nonce, Idempotency-Key, If-Match, If-None-Match,
| 1 | # better-route: CORS and preflight |
| 2 | |
| 3 | Define an explicit origin policy and attach it before declaring the routes that should inherit it. |
| 4 | |
| 5 | ```php |
| 6 | use BetterRoute\Middleware\Cors\CorsMiddleware; |
| 7 | use BetterRoute\Middleware\Cors\CorsPolicy; |
| 8 | |
| 9 | $cors = new CorsMiddleware(new CorsPolicy( |
| 10 | allowedOrigins: ['https://app.example.com'], |
| 11 | allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], |
| 12 | allowCredentials: true, |
| 13 | maxAgeSeconds: 600, |
| 14 | )); |
| 15 | |
| 16 | $router->middleware([$cors]); |
| 17 | |
| 18 | $router->get('/catalog', $catalog)->publicRoute(); |
| 19 | $router->patch('/account', $update) |
| 20 | ->protectedByMiddleware('cookieNonce') |
| 21 | ->middleware([$cookieNonce]); |
| 22 | |
| 23 | $router->options('/account', static fn () => null) |
| 24 | ->publicRoute(); |
| 25 | ``` |
| 26 | |
| 27 | Router middleware is captured when each route is declared. Add global/group CORS before declaring those routes, or attach it per route. |
| 28 | |
| 29 | ## 1.1 WordPress bridge |
| 30 | |
| 31 | `CorsMiddleware` implements `WordPressRouteMiddlewareInterface`. During `Router::register()`, every matched route is registered with `WordPressCorsBridge`. |
| 32 | |
| 33 | The bridge: |
| 34 | |
| 35 | - answers a matched preflight on `rest_pre_dispatch` before the normal route callback/middleware auth flow; |
| 36 | - returns `204` for allowed preflight; |
| 37 | - returns `403 cors_origin_denied` for a disallowed origin when rejection is enabled; |
| 38 | - removes WordPress core CORS headers for matched routes and emits the configured policy on `rest_pre_serve_request`; |
| 39 | - preserves unrelated `Vary` tokens while managing `Vary: Origin`. |
| 40 | |
| 41 | This prevents WordPress core from broadening or contradicting the application allowlist. It affects only registered Better Route paths carrying this middleware. |
| 42 | |
| 43 | Every raw route denies by default in 1.1. If an explicit `Router::options()` route is needed, call `publicRoute()` or another deliberate permission method. Keep business logic out of preflight handlers. |
| 44 | |
| 45 | ## Defaults |
| 46 | |
| 47 | Default allowed request headers include: |
| 48 | |
| 49 | - `Authorization` |
| 50 | - `Content-Type` |
| 51 | - `Idempotency-Key` |
| 52 | - `If-Match` |
| 53 | - `If-None-Match` |
| 54 | - `X-Request-ID` |
| 55 | - `X-WP-Nonce` |
| 56 | |
| 57 | Default exposed response headers include `ETag`, `Idempotency-Replayed`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `X-Request-ID`. |
| 58 | |
| 59 | Add `Retry-After` to `exposedHeaders` if browser JavaScript must read it from a `429` response. |
| 60 | |
| 61 | ## Validation and security |
| 62 | |
| 63 | `CorsPolicy` validates configured origins, methods, request header names, response header names, and non-negative max age. Invalid tokens or header-injection characters throw during construction. |
| 64 | |
| 65 | Never combine wildcard origin with credentials: |
| 66 | |
| 67 | ```php |
| 68 | // Throws InvalidArgumentException. |
| 69 | new CorsPolicy(['*'], allowCredentials: true); |
| 70 | ``` |
| 71 | |
| 72 | Use `*` only for a non-credentialed public API. With credentials, list exact `http`/`https` origins. CORS is a browser policy, not authentication or CSRF protection; keep auth/nonce/signature middleware in place. |
| 73 | |
| 74 | `rejectDisallowedOrigins: false` omits CORS headers for a disallowed origin instead of returning 403. Use that only when non-browser callers should continue and browsers should enforce the denial by absence of headers. |
| 75 | |
| 76 | ## Smoke checks |
| 77 | |
| 78 | ```bash |
| 79 | curl -i -X OPTIONS 'https://example.com/wp-json/myapp/v1/account' \ |
| 80 | -H 'Origin: https://app.example.com' \ |
| 81 | -H 'Access-Control-Request-Method: PATCH' \ |
| 82 | -H 'Access-Control-Request-Headers: Authorization, Content-Type, If-Match' |
| 83 | ``` |
| 84 | |
| 85 | Verify: |
| 86 | |
| 87 | - allowed origin gets `204` and only configured CORS headers; |
| 88 | - denied origin gets 403 or no CORS headers according to policy; |
| 89 | - credentials never appear with `Access-Control-Allow-Origin: *`; |
| 90 | - normal success and error responses get the same authoritative allow-origin policy; |
| 91 | - WordPress core does not leave a second/conflicting allow-origin header. |
| 92 | |
| 93 | ## Related skills |
| 94 | |
| 95 | - Use `br-routes` for raw OPTIONS access intent. |
| 96 | - Use `br-auth-middleware` for identity; CORS does not authenticate. |
| 97 | - Use `br-etag-cache` and `br-rate-limiting` when exposing their headers to browser code. |
| 98 | |
| 99 | ## References |
| 100 | |
| 101 | - Verified source paths: |
| 102 | - `src/Middleware/Cors/CorsMiddleware.php` |
| 103 | - `src/Middleware/Cors/CorsPolicy.php` |
| 104 | - `src/Middleware/Cors/WordPressCorsBridge.php` |
| 105 | - `src/Middleware/WordPressRouteMiddlewareInterface.php` |