fix(ios): make the Lockdown Mode REST relay serve requests, and contain it - #610
Closed
dcalhoun wants to merge 31 commits into
Closed
fix(ios): make the Lockdown Mode REST relay serve requests, and contain it#610dcalhoun wants to merge 31 commits into
dcalhoun wants to merge 31 commits into
Conversation
Two defects in how the local server decides what reaches its handler.
A relayed `OPTIONS` and a browser preflight arrive at the same target, so
the permissive CORS policy answered both with 204 and the handler never
ran. `canUser` issues `OPTIONS /wp/v2/{resource}` and reads the `Allow`
response header, so the editor silently reported that the user could not
create pages, update settings, upload media, or edit global styles — with
no error surfaced, because the request "succeeded". Discriminate on
`Access-Control-Request-Method`: a preflight always carries it, a
deliberate `OPTIONS` never does. The authentication exemption narrows to
the same condition, so a deliberate `OPTIONS` is authenticated like any
other request rather than riding in on the preflight exemption.
The server also gains an opt-in requirement for `Origin` or
`Sec-Fetch-Site`, headers WebKit sets on every editor `fetch()` and a raw
socket opened by another process on the device does not. The bearer token
remains the control; this is defense in depth, and cheap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The relay has never served a request. `upstreamURL(from:)` assigned `parsed.query` to `percentEncodedQuery`, but `query` includes the leading `?`, so the first query item was named `?url`, the lookup for `url` returned nil, and every request 400d. Rather than fix the parse, drop the caller-supplied URL. The upstream path now rides in the request path — `/proxy/wp/v2/posts?_locale=user` — and resolves natively against the configured site API root, so there is no URL to contain in the first place. The old `hasPrefix` guard ran on an absolute URL without normalizing `..` segments; those are now refused outright, literal or percent-encoded, and the resolved URL is re-checked against the root. Each request also identifies itself in a network log instead of every row reading `/proxy`. Resolution appends to the root rather than resolving relative to it, mirroring `createRootURLMiddleware`: a site on plain permalinks has `https://example.com/?rest_route=/`, where relative resolution would discard the query and the path has to merge into it. Three further defects, all in what comes back: - `Allow` was not exposed, so `canUser` read null even once its `OPTIONS` reached the handler. - Error bodies were `text/plain`, reaching JavaScript as an unparseable `invalid_json` with the real reason lost. They are now WordPress-shaped `{code, message}`, as `MediaUploadServer.errorResponse` already was. - `URLSession` followed 3xx responses with no task delegate, so the containment check only ever applied to the first hop and a redirect carried the site credential to another host. Cross-root redirects are now refused and the 3xx handed back instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`MediaUploadServer.start` omitted `maxConnections`, so the server ran on the library default of 5. That suits a server receiving one upload at a time, but this one also carries every editor REST request under Lockdown Mode, and editor boot fans out well past five. Each connection serves exactly one request, and one past the limit is closed immediately — surfacing in JavaScript as an unretried `fetch_error`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The relay was a middleware that short-circuited past `next()`, but `apiFetch.use()` unshifts and api-fetch applies its middleware with `reduceRight`, so a registered middleware runs *outside* the four built-in ones and `defaultFetchHandler`, not inside them. Every relayed request therefore lost what those do: `options.data` never became a body (so every save sent `Content-Length: 0`, which WordPress accepts as a no-op — silent data loss), the `Accept` header WordPress uses to recognize a REST request was dropped, the HTTP v1 method override was lost, and `signal` never reached `fetch`, so cancellation did not propagate. Installing the relay as the fetch handler puts it where the old comment said it already was. Everything above is fixed at once, and `_locale=user` and the `per_page=-1` expansion now hold because their middleware runs, rather than by the accident of a failed direct attempt leaving its mutations behind. Which transport to use is now read from configuration. The relay is only advertised when the host knows direct requests cannot work, so a direct attempt first is a guaranteed-doomed round trip per request; the previous module-global flag inferred the answer from an observed success, which one misleading response could latch on for the rest of the session. The upstream path travels in the request path, so `Access-Control-Allow- Headers` no longer needs a relay header, and `PATCH` joins the allowed methods — it was blocked outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`LocalWordPressCredentials.load()` fell back to a compiled-in application password and a hardcoded LAN IP when `WP_ENV_CREDENTIALS_PATH` was unset. Throwaway local dev credentials rather than production ones, but they should not ship, and the silent fallback turned a misconfigured environment into a confusing failure against someone else's machine. `SitePreparationView` already explains what to run when `load()` returns nil. Keeping the screen awake is now opt-in behind `GUTENBERG_DISABLE_IDLE_ TIMER` rather than unconditional; it exists for debugging workflows that break on auto-lock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The relay's defects were invisible to unit tests: each one needed a real request to cross the loopback server, be forwarded natively, and come back. These tests do that against the `make wp-env-start` environment, and are skipped unless `WP_ENV_CREDENTIALS_PATH` is set, so a normal run and CI never need a site. The write test reads the record back rather than trusting the create response, because the defect it covers sent an empty body — which WordPress accepts as a no-op while still answering 2xx. `canUser` is not verifiable here: the Playground runtime's web server answers every `OPTIONS` itself with a bodiless 204 before WordPress is reached, so there is no `Allow` header to relay. The test asserts the relay forwards the `OPTIONS` upstream instead, which is the half of that chain this code owns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`getGBKit()` falls back to the copy of the config in `localStorage`, which iOS writes on every load and never clears. A relay's port and per-session token belong to the server that issued them, so a persisted copy points at a listener that has been stopped — or at a port something else now owns. That was survivable while the relay was a fallback for requests that had already failed. It is not now that it is the transport: every REST request in the session would go to the stale port. Read the relay's details from the injected global only, and fall back to no relay, which is what requests did before one existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
WordPress builds `Link` headers and `_links` hrefs from `home_url()`, which need not be the host the app was configured with — `www.` versus bare, a mapped or reverse-proxied domain, an `http` `siteurl` behind `https`. wp-env is the everyday case: its credentials report `localhost` while WordPress reports `127.0.0.1`. Matching the target against the configured root as a plain string left those unrecognized, so `fetchAllMiddleware` — which follows the absolute URL from the `Link` header — had page 2 of every `per_page=-1` collection refused with a bare `fetch_error`. The existing pagination test could not catch it: it mocks the `Link` header with the configured root verbatim. Move the target onto the root's origin before comparing, and parse both sides so they normalize identically. Path differences stay unmatched deliberately — in a subdirectory multisite two roots that differ only by path are separate sites, and matching across them would route one site's request into the other's API root. The root is also parsed slash-terminated, so a sibling can no longer match it as a prefix: `https://site/wp-json` matched `https://site/wp-jsonx/…`, and a host-supplied root without a trailing slash is not hypothetical. Found by the session working the scheme-handler branch, which hit it first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`setFetchHandler` was the only api-fetch hook that runs after every middleware, so it was the only way to intercept without short-circuiting past `_locale=user`, the `per_page=-1` expansion, and the HTTP v1 method override. But it replaces the response half too, so `data` serialization, the default `Accept` header, `parse: false`, the 204 case, parse-and-throw on non-2xx, and `offline_error`/`fetch_error` normalization all had to be reimplemented here and kept in step with a package we do not control. Wrapping `fetch` sits below all of it. api-fetch builds the request, hands it over, and parses whatever comes back; this layer only changes where the request goes. That deletes the reimplementation — about 130 lines — and `configureApiFetch` goes back to nothing but middleware registration. The predicate is not a new skip list. It is the same "is this a site API request?" test, minus its relative-path branch, which was needed only because `setFetchHandler` receives options where `path` may be set without `url`. `blob:`, `data:`, `gbk-media-file:` and relative URLs all fail an absolute-URL-under-the-API-root test on their own. The one addition is a guard for the relay's own origin, which is load-bearing because matching deliberately ignores the origin to tolerate host aliases — the upload route shares that server, and a site configured with a bare root would otherwise match it on path. Ordering is explicit in the bootstrap: the relay installs before the network log, so the log records the request the editor made rather than the loopback rewrite of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Two wrappers now sit on the global `fetch` — the network log and the Lockdown Mode relay — and their order is behavior, not preference: a wrapper that rewrites the request changes what every wrapper inside it observes, so the log has to sit outside the relay to record the request the editor made rather than the loopback rewrite of it. Encoding that in the order two modules happen to patch `window.fetch` leaves nothing to read and nothing to test. `installFetchWrappers` takes the chain outermost-first and composes it, so the ordering and its rationale live in one place. Each wrapper is a `( next ) => fetch` transform — the same shape as an `apiFetch` middleware, one layer down — which also makes each testable against a stub `next` rather than against globals. A wrapper reports itself inapplicable by returning `null`, so a disabled feature installs no pass-through layer. `fetch-interceptor` is renamed to `fetch-logging`: it was "the" interceptor when it was the only one, and is now one wrapper among several. Its behavior is unchanged and its tests carry over as they were, against a one-line helper standing in for the chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The CORS shim short-circuited every `OPTIONS` request site-wide with a bodiless 204, without checking whether it was a CORS preflight. A preflight always carries `Access-Control-Request-Method`; an `OPTIONS` a client sent on its own behalf never does. `canUser` issues the latter and reads `Allow` to decide whether the user may create a page, update settings, upload media, or edit global styles — so against the local environment every one of those read as false, and read as *success*, so nothing surfaced. Discriminating on that header lets core answer the deliberate ones, where `rest_handle_options_request` builds the response and `rest_send_allow_header` fills in `Allow`. The site-wide hook stays: the editor also sends authenticated requests to `admin-ajax.php`, which core does not answer preflights for. `Allow` is also now added to the exposed CORS headers, through core's `rest_exposed_cors_headers` filter rather than by sending the header directly — core sends its own `Access-Control-Expose-Headers`, so a second `header()` call would replace that value instead of extending it. Without this the header is on the wire and invisible to JavaScript cross-origin, which is the same failure by a different route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`canUser` issues a deliberate `OPTIONS` and reads `Allow` to decide what the user may do. Asserting the header arrives covers the whole chain in one check: the request reaches WordPress rather than being answered locally as a preflight, WordPress computes the header from the matched route's permission callbacks, and the relay passes it through and exposes it. Replaces a weaker assertion that the response merely carried the site's server headers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
…other The alias divergence is a documented property of this environment, not a one-off: `e2e/wp-env-fixtures.js` already works around the Playground runtime resolving `localhost` to `127.0.0.1` in `WP_SITEURL` by matching uploads on path rather than hostname. Citing it justifies the tolerance better than a single observation could. The relay's redirect guard stays origin-exact, and now says why. The two comparisons answer different questions: recognizing an alias decides where a request is sent, while the redirect guard decides whether the site credential follows a redirect somewhere we did not choose. `isSameOrigin` in `ajax.js` already draws that line for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Refusing the redirect left `URLSession` holding the 3xx, and the relay passed it straight through — `Location` header included. `fetch` follows redirects by default, so the web view then chased the redirect to the very host the guard had just declined, and the request failed there as an opaque CORS error. The refusal was undone by the layer above it, and the reason was nowhere in the result. The relay now answers with a WordPress-shaped 502 naming the target it declined, so whoever hits this can see that the site redirected out of its own API root rather than going hunting in the relay. Also records the trade the prefix check makes, which is not obvious from the code: matching the whole URL rather than the host refuses a redirect to another path on the same site, and refuses a scheme downgrade without a rule of its own — but it also refuses a legitimate permalink-structure redirect. Refusing is the right default, because it cannot hand the site credential somewhere unverified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Scratch tooling from the Lockdown Mode investigation, committed alongside it. It builds bare web views to compare page-origin variants, which is not something the relay depends on, and seven of its eleven measurements point at a CORS-instrumented echo server that only ever ran on one developer's machine — as its own comment says. It cannot run for anyone, and making it run would mean building that server for a question this work does not ask. Takes the last hardcoded LAN address in the demo app with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Two of its measurements went to a CORS-instrumented echo server that only ever ran on one developer's machine, so they reported connection failures for everyone else. What they covered — a cross-origin GET and a FormData POST — the `site_get_direct` and `site_post_media_direct` cases already cover against a real WordPress. The probe now needs nothing but a reachable site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
XCFramework BuildThis PR's XCFramework is available for testing. Add the following to your .package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/610")Built from c2ebc04 |
`containsDotSegment` split on literal `/` only, so a traversal spelled `%2e%2e%2f%2e%2e%2fwp-admin` arrived as one segment and passed the guard. A server that decodes the separator before normalizing then resolves it outside the API root, with the site credential attached. Decode the separators alongside the dots so the guard holds the boundary its documentation promises. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
`relayUpstreamPath` replaced the target's whole origin before comparing, so any host whose path started with the API root's path was captured and rewritten onto the configured site — a third party's request sent to the user's own site with the site credential attached. Compare the hosts first, tolerating only the spellings that name the same host: a `www.` prefix and the loopback addresses. Anything else keeps the direct path it had before a relay existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The passthrough guard compared the target's origin against the relay's `127.0.0.1` spelling, but the native upload request it exists to exempt is issued to `localhost` (the name Android hosts permit cleartext to). That request fell through to the site match, where only the path stood between a multipart upload and being relayed to the REST API. Match the local server by port instead, accepting any loopback spelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
`buildEditorConfiguration` decided on the delegate alone while `startUploadServer` also required a site credential, so a host with a delegate but no auth header — with the relay starting the server anyway — advertised a port whose `/upload` route had no uploader. Every media upload then failed with a 500 instead of falling back to the WebView path. Record the decision once, where it is made, and read it when the configuration is built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
`GBKit` carries the site credential and the local server's port and tokens, all valid only for the load that injected them, and iOS mirrored it into `localStorage` where it outlived the session. Anything reading through the `getGBKit` fallback — the media upload port and token — could pick up a previous session's values. Remove the key as the configuration is injected, matching Android, which clears the WebView's web storage before each load. With no stale copy to guard against, the relay details read through `getGBKit` like every other field and `getNetworkProxy` goes away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The chain replaced the interceptor's idempotency guard with nothing, so a retried boot or a re-injected bundle wrapped the already-wrapped `fetch`: every request logged to the native host twice and relayed through two layers. Mark the wrapped `fetch` and skip a second install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
Two changes to the relay's upstream request: A `URLSession` was built per relay and never invalidated, so one accumulated per editor load under Lockdown Mode. Nothing about it is per-relay, so share one for the process. `RequestBody.count` is a file-size lookup that reports zero when it fails, and the streamed branch sent that as the `Content-Length` — uploading nothing, which WordPress accepts as a no-op and answers 2xx. The missing file that is the likeliest cause already throws in `makeInputStream()`, which the existing catch turns into a 500, so this closes the remaining window rather than a live bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
…rs it The exemption rests on the library answering a preflight with its own 204 before the handler runs, which only happens under `CORSPolicy.permissive`. Under any other policy an unauthenticated `OPTIONS` carrying `Access-Control-Request-Method` reached the handler — an unauthenticated way into a server that requires authentication. Exempt a preflight only when the permissive policy will answer it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The struct is public and names a parameter of a public initializer, but its memberwise initializer is internal, so the only value a host could pass was nil. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
A stray fragment above `loadEditorWithoutDependencies` merged into that method's documentation, and the symbol link to `HTTPServer.start(…)` was not updated for the `requiresBrowserOrigin` parameter, so it no longer resolved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
api-fetch turns every PUT/PATCH/DELETE into a POST carrying `X-HTTP-Method-Override`. The header is not CORS-safelisted, so the browser announces it in the preflight and wp-env's allow-list — which replaces core's — rejected every such request from the dev server. The iOS policy gained the same header in this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
`RedirectGuard` and `createRelayFetch` carried several paragraphs of rationale each; keep the reason and the cost, drop the retelling. Also remove the claim that api-fetch's default `Accept` value reaches a preflight — none of its bytes are CORS-unsafe, so the header is safelisted. It stays in the allow-list for values that are not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
Restores the `localStorage` copy of `GBKit`, keeping iOS in step with Android until the persistence is removed from both. The relay details still read through `getGBKit` like every other field. The fallback that copy feeds cannot reach the relay in a production build: boot aborts on a missing `window.GBKit` before the fetch wrappers are installed, except under `?dev_mode`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The origin rewrite replaced the target's port instead of comparing it, so a request to another service on the site's host — `example.com:8443` for a site on 443 — matched the API root and was answered by the site's REST API instead. Scheme replacement has a reason (an `http` `siteurl` behind a TLS-terminating proxy); the port never did. Also record the alias shapes this cannot recognize, and where that belongs instead: a site reached by LAN IP emits `localhost` URLs, whose paginated `Link` targets go direct and fail under Lockdown Mode. Growing the list of spellings cannot close that class — the relay can, because it knows where the response came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The `host` setter retains an existing port when the value carries none — it does not drop it, as the comment claimed. That retention is what made `host` wrong for the old code, which assigned the port separately afterwards; now that the port is compared before substitution, either setter would do and the honest reason is simply that nothing but the scheme and the host spelling is left to reconcile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
Member
Author
|
Superseded by #611. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What?
Makes the Lockdown Mode REST relay in #544 actually serve requests, and contains it.
The relay has never served one.
upstreamURL(from:)assignedparsed.querytopercentEncodedQuery, butquerycarries its leading?, so the first query item was named?url, the lookup forurlreturned nil, and every request 400d. That masked everything behind it.Why?
Under iOS Lockdown Mode the editor's
file://page loses its CORS exemption, and WordPress sanitizesOrigin: file://through a protocol allowlist that excludesfile— so it answers with an emptyAccess-Control-Allow-Originand WebKit rejects every response. The relay is the way REST works at all in that mode.Behind the 400, several defects would have surfaced in order. The three worth calling out by user impact:
proxyFetchreadoptions.body, but api-fetch serializesoptions.datainto a JSON body insidedefaultFetchHandler, which the relay short-circuited past. Writes went out withContent-Length: 0; WordPress answered 200 with the record unchanged, and the editor reported success.canUserissuesOPTIONSand reads theAllowresponse header. A relayedOPTIONSand a browser preflight arrive at the same target, so the permissive CORS layer answered both with 204 and the handler never ran — andAllowwasn't inAccess-Control-Expose-Headersanyway. No error surfaced, because the request "succeeded".text/plain, so they arrived asinvalid_jsonwith the real reason lost.Two security gaps: the SSRF guard matched a caller-supplied absolute URL by string prefix without normalizing
..segments, andURLSessionfollowed 3xx responses with no task delegate, so the check only ever applied to the first hop.How?
The relay takes a path, not a URL. The upstream path rides in the request path —
/proxy/wp/v2/posts?_locale=user— and resolves natively against the configured site API root. There is no caller-supplied URL to contain, dot segments are refused outright, and each request identifies itself in a network log. Resolution appends to the root rather than resolving against it, mirroringcreateRootURLMiddleware, because a site on plain permalinks hashttps://example.com/?rest_route=/where the path has to merge into an existing query.The relay is the transport, not a middleware.
apiFetch.use()unshifts and api-fetch composes withreduceRight, so a registered middleware can never run innermost — it short-circuits pastdata-to-body serialization, the defaultAcceptheader, the HTTP v1 method override,signal, and every response-parsing rule. Wrappingfetchputs the relay below all of it: api-fetch builds the whole request and parses whatever comes back, and this layer only changes where the request goes. Which transport to use is read from configuration rather than inferred from an observed success, so one misleading response can no longer latch the editor onto the wrong path for a session.Preflights and deliberate
OPTIONSare told apart byAccess-Control-Request-Method, in the HTTP server and in the wp-env CORS shim, which had the same bug.Allowis exposed in both.Site URLs are recognized across host aliases. WordPress builds
Linkheaders fromhome_url(), which need not be the configured host — wp-env is the everyday case, ande2e/wp-env-fixtures.jsalready documents the same divergence. Path differences stay unmatched deliberately: in a subdirectory multisite two roots differing only by path are separate sites.Also: redirects out of the API root are refused and answered rather than relayed (relaying the 3xx let
fetchfollow it, undoing the refusal); the connection limit is raised off the library default of 5, which editor boot exceeds; and the demo app's compiled-in wp-env credentials and the dead origin probe are gone.Sixteen numbered items, one per commit where they were separable. The commit messages carry the detail.
Testing Instructions
Automated coverage first — none of it needs a site except where noted:
The last one relays real requests against wp-env: a write is read back to confirm the record actually changed,
Allowis asserted end to end, and ten concurrent requests are fired (which fails at the old connection cap).Manual testing is in the review comments — the regression risk is in paths the suites can't reach, and it needs a device or simulator.