Skip to content

fix: harden the native media upload server (review of #357) - #561

Draft
jkmassel wants to merge 10 commits into
feat/leverage-host-media-processingfrom
feat/leverage-host-media-processing-v1
Draft

fix: harden the native media upload server (review of #357)#561
jkmassel wants to merge 10 commits into
feat/leverage-host-media-processingfrom
feat/leverage-host-media-processing-v1

Conversation

@jkmassel

Copy link
Copy Markdown
Contributor

Adversarial review of the native media upload server introduced in #357, with the findings fixed. Ten changes across iOS, Android, and the JS middleware — timeout, lifecycle, concurrency, resource-leak, and injection bugs — each verified, and the behavior-changing ones mutation-tested (remove the guard → the new test fails → restore).

Stacks on #357: the base is feat/leverage-host-media-processing, so this merges into that PR's branch rather than trunk.

Summary

Correctness

  • Large uploads were aborted mid-transfer (b0739f10, both platforms) — the read timeout was a single 30s total-duration cap over the whole request while the body limit is 4 GB, so a legitimate large upload that streamed steadily for more than 30s got a 408. Split the read: the pre-body phase (headers + oversized drain — the unauthenticated-reachable part) keeps the total cap; the accepted body is bounded by the per-read idle timeout plus a generous ceiling. Coupled fix: reject auth-exempt OPTIONS that carry a body, so an unauthenticated OPTIONS with an oversized Content-Length can't abuse the now idle-only body read.
  • iOS: mediaUploadDelegate set after load silently did nothing (cdb1683b) — it's captured once into the page config at load. Kept the weak reference (making it strong reintroduces a deliberately-avoided retain cycle that would leak the server) and made both misuse paths trap: set-after-load, and a delegate deallocated before load.
  • iOS: concurrent editors wiped each other's in-flight upload buffers (8a08a084) — the start-time temp-file sweep deleted every file in a shared directory, and two editors (iPad multi-window, or one tearing down as another starts) share it. Added Android's activeFiles guard so the sweep skips live buffers while still reclaiming crash orphans.
  • Android: the upload server could be resurrected on a detached view (d7cddecb) — a delegate assigned after onDetachedFromWindow started a server that nothing would ever stop. @Volatile the field (matching its sibling) plus a torn-down guard.

Resource leaks

  • iOS: a stalled upload leaked the writer thread and its file descriptor (63d046a1) — the bound-pair writer blocks forever if URLSession abandons the body stream; close the input on every exit so it unwinds.
  • Android: stop() didn't cancel an internally-created coroutine scope (70497ea3).

Security hardening

  • iOS: multipart header injection (ebc9e802) — client-supplied filename, field names, and MIME type were interpolated into Content-Disposition/Content-Type unescaped, so a " or CRLF could inject headers or an extra part into the request relayed to WordPress. Escape at the serialization boundary. Bounded by the trust model (the token holder already holds the WordPress credential), so this is defense-in-depth rather than privilege escalation.

JS middleware

  • A cancelled upload could throw undefined (b7b39a5e) when the abort signal carried no reason; fall back to a canonical AbortError.
  • The cancel/timeout tests were tautological (4aa967ca) — they rejected fetch with the same object they asserted on, so a regression that rethrew the network error would have passed. Rewritten to exercise the abort-vs-network race.

Documented, not fixed

  • iOS: the upload body is a one-shot stream (55867b64) — a 307/308 redirect on the media POST (which WordPress core never emits) would resend an exhausted, empty body → a clean failure, not a corrupt attachment. Documented the limitation rather than adding needNewBodyStream plumbing for so rare a case.

Determined non-issues

  • iOS siteApiRoot guard — the finding assumed Android's empty-String check applies, but iOS's siteApiRoot is a non-optional URL, so there is no empty state to guard against.
  • iOS ATS / cleartext guard — loopback isn't subject to iOS cleartext blocking (uploads work), and there's no runtime API to mirror Android's NetworkSecurityPolicy check. Android-specific by nature.

Test plan

  • iOS GutenbergKitHTTP suite (386 tests) plus new timeout / OPTIONS-with-body / temp-file-cleanup / bound-stream-teardown tests
  • iOS GutenbergKit upload and multipart suites, including a new header-injection test
  • iOS Simulator build — EditorViewController is #if canImport(UIKit), so host swift build compiles it empty; verified with xcodebuild -destination 'generic/platform=iOS Simulator'
  • Android :gutenberg unit tests and detekt, including new Robolectric detached-view-leak and owned-scope-cancel tests
  • JS suite (183 tests) and eslint
  • Mutation-tested the behavior-changing fixes (timeout split, header escaping, detached-view guard, AbortError fallback, scope cancel, cancel-test rewrite) — removed each guard, confirmed the new test fails, restored

Out of scope

  • The WordPress authHeader persisted to WebView localStorage predates this feature (it's already in origin/trunk); this PR only adds a short-lived loopback token to that already-persisted blob, so it isn't addressed here.

jkmassel added 10 commits July 22, 2026 12:59
The embedded upload server capped the whole request read at a single 30s total-duration timeout while advertising a 4GB body limit, so a legitimate large upload that streamed steadily for longer than 30s was aborted with a 408 on both platforms. Split the read: the pre-body phase (headers + oversized drain — the unauthenticated-reachable portion) keeps the readTimeout cap, while the accepted body is bounded only by the per-read idle timeout plus a generous bodyReadTimeout (10 min for uploads), so a steadily-streamed body is never failed on total duration.

Reject auth-exempt OPTIONS that carry a body (Content-Length > 0) before the drain, so idle-only body reads can't be abused by an unauthenticated OPTIONS with an oversized Content-Length to hold a connection slot.

iOS splits the timeout task group via a withReadTimeout helper and adds HTTPServerError.unexpectedBody. Android splits deadlineNanos, makes readUntil cancellable (ensureActive) so shutdown reaps an active connection, and rethrows CancellationException. Adds timeout/OPTIONS regression tests on both platforms.
…arly

Assigning mediaUploadDelegate after the editor had loaded silently did nothing — the delegate is captured once, at load, into the page's initial window.GBKit config, and there was no observer to react. A delegate that a host set but didn't retain (the property is weak) was likewise silently deallocated before load, disabling native uploads with no error.

Keep the weak reference — making it strong would reintroduce the deliberately-avoided VC -> server -> ... -> delegate -> VC retain cycle that leaks the server — and instead fail loudly: a precondition in the setter rejects a write after loading has started, and startUploadServer traps if a delegate that was assigned has already been deallocated. Track mediaUploadDelegateWasAssigned so a premature deallocation is distinguished from a deliberate opt-out (never set / explicitly nil).

siteApiRoot is a non-optional URL on iOS, so unlike Android there is no empty-root case to guard.
…ve buffers

The upload server's start-time orphan sweep deleted every file in its temp directory. Two same-name server instances share that directory (two editors open at once, or one being torn down as another starts — the ARC deinit that stops the old server isn't synchronous with the new one starting), so the second's sweep could delete the first's in-flight request-body buffer and fail that upload with a bufferIOError.

Mirror Android's activeFiles guard: register a temp file in a process-wide set while it backs a live request (Buffer/TempFileOwner), and skip registered files in cleanOrphanedTempFiles. Files not in the set have no live owner in this process — they're crash orphans and are still reclaimed. Register before creating the file to close the create-vs-sweep window.
Both upload paths feed URLSession a bound stream pair whose background writer blocks on output.write when the buffer is full. If URLSession abandons the stream without draining it (cancel, or a failure that doesn't close it), the writer blocks forever, leaking the thread and its open file handle; repeated stalls exhaust file descriptors.

Close the request's httpBodyStream in a defer around performRaw so the bound pair is always broken and the writer unwinds — on success (no-op, already drained), failure, or cancellation. Covers both the multipart re-encode and file-slice passthrough paths. A BoundStreamTeardownTests case verifies that closing the input unblocks a writer blocked on a full buffer.

The verification test also documents the CoreFoundation bound-stream behavior the fix relies on.
…race

The abort and timeout tests rejected fetch with the same object they set as signal.reason, so throw options.signal.reason and a regression to throw connectionError were indistinguishable — the tests passed either way. Reject fetch with a distinct network TypeError while the signal is aborted so the tests actually assert the middleware rethrows the signal's reason (the canonical cancellation), not the racing fetch rejection.

Verified by mutation: with the bug in place the old tests pass but the rewritten ones fail, and the rewritten tests pass on correct code.
The uploadServer field was a plain var (its sibling one line up is @volatile), mutated from the mediaUploadDelegate setter, startUploadServer, and onDetachedFromWindow. onDetachedFromWindow stops the server and won't fire again, so a delegate assigned after detach ran startUploadServer and started a server (bound socket + accept-loop coroutine) that nothing ever stopped — a leak reachable even single-threaded.

Mark the field @volatile (matching the sibling) and add an isTornDown flag, set in onDetachedFromWindow and reset in onAttachedToWindow, that startUploadServer checks first — so a detached view never starts a server, while a not-yet-attached view (the legitimate set-delegate-during-construction case) still does.

A Robolectric test proves the contrast (server starts on a live view, not after detach); mutation-tested by removing the guard and confirming the leak test then fails.
MediaUploadServer.stop() cancelled cleanupJob but not the CoroutineScope, so when no scope was supplied (the default) the internally-created scope was never cancelled. Production passes a lifecycle-scoped coroutineScope so it was unaffected, but the default path (tests, and any caller relying on it) leaked the scope's Job.

Default the scope parameter to null and create an owned scope only when the caller supplies none; stop() cancels that owned scope, while a caller-supplied scope is left to the caller's lifecycle. A test proves stop() cancels the owned scope and not a borrowed one; mutation-tested by dropping the cancel.

Verified: with the cancel removed the new test fails; restored, all 19 MediaUploadServer tests pass and detekt is clean.
multipartBodyStream interpolated the client-supplied filename, form-field names, and MIME type straight into Content-Disposition/Content-Type headers. A value containing a quote or CRLF could break the header line or inject an extra multipart part into the request relayed to WordPress (sanitizeFilename only strips path separators for temp-file naming and wasn't applied here).

Percent-encode CR, LF, and double-quote in the quoted name/filename parameters (matching WHATWG's form-data serialization) and strip CR/LF from the MIME type. A test crafts CRLF-injecting values for all three and asserts no fake header survives; mutation-tested by removing the escaping.

Bounded by the trust model (the token holder already holds the WordPress credential), so this is defense-in-depth against a malformed/crafted filename rather than a privilege escalation.
The upload request body is a one-shot bound-pair stream, so URLSession can't resend it. That only bites on a 307/308 redirect that preserves the POST (301/302/303 downgrade to a bodyless GET; a Bearer 401 doesn't resend), which WordPress core never emits for POST /wp/v2/media. If a proxy/misconfig did, the resend sends an empty body that WordPress rejects — a clean failure, not a corrupt attachment.

Document that we accept this rather than add needNewBodyStream handling or buffer the body to a replayable file for so rare a case.
…s no reason

nativeMediaUploadMiddleware rethrew options.signal.reason on a cancelled upload, but an engine that marks a signal aborted without populating reason would make it throw undefined — which @wordpress/media-utils surfaces as a spurious upload failure instead of a silent cancel.

Fall back to a DOMException('AbortError') when reason is nullish. A test covers the nullish-reason branch; mutation-tested by dropping the fallback (the test then fails).
@github-actions github-actions Bot added the [Type] Bug An existing feature does not function as intended label Jul 22, 2026
@wpmobilebot

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/561")

Built from b7b39a5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants