Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,22 @@ internal class MediaUploadServer(
*
* A list rather than a map so repeated names (e.g. a `field[]` array) survive
* verbatim, in the order the editor sent them.
*
* This decode can't mangle anything, but only because of who is on the other end —
* nothing in the code enforces it. Three things have to stay true:
*
* 1. Only the editor's own web page can reach this server. It listens on loopback,
* and every request has to carry a per-session token.
* 2. Text the editor puts in a form field is already valid Unicode. The browser
* guarantees that when the value is set, so it cannot hand us bad bytes.
* 3. The only way a browser can put *raw* bytes in a form is a file or a Blob, and
* those always arrive with a filename. Anything with a filename is handled as the
* file, never as a field — so raw bytes never reach this decode.
*
* If one of those stops being true, bad bytes quietly turn into replacement
* characters, and the platforms don't even agree on how many: ED A0 80 becomes one
* of them here and three on iOS. There is no single behavior worth documenting, so
* the tests pin rule 3 instead.
*/
private fun formFields(parts: List<MultipartPart>): List<MediaUploadField> =
parts.map { MediaUploadField(it.name, String(it.body.readBytes(), Charsets.UTF_8)) }
Expand Down Expand Up @@ -697,10 +713,10 @@ internal open class InternalMediaClient(
): MediaUploadResponse {
val mediaType = mimeType.toMediaType()
val builder = okhttp3.MultipartBody.Builder().setType(okhttp3.MultipartBody.FORM)
// Preserve the non-file parts (post, additionalData) through the re-encode.
// Append each field's raw bytes (not via String) so a non-UTF-8 value is
// forwarded verbatim rather than coerced. filename=null makes it a plain
// field, matching okhttp's String overload byte-for-byte.
// Non-file parts (post, additionalData) have to survive the re-encode unchanged:
// appending raw bytes keeps them byte-for-byte identical to the plain passthrough,
// and filename=null makes each a plain field, exactly what okhttp's String overload
// would emit. (Bad bytes can't get here; see formFields.)
for (part in extraParts) {
builder.addFormDataPart(part.name, null, part.body.readBytes().toRequestBody())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,85 @@ class MediaUploadServerTest {
assertFalse(client.uploadCalled)
}

@Test
fun `keeps a binary Blob part out of an uploader's fields`() {
// Pin rule 3: a Blob always has a filename, so it's dropped before the decode.
val uploader = RecordingUploader()
server.stop()
server = MediaUploadServer(
processor = null, internalClient = MockInternalMediaClient(), uploader = uploader,
cacheDir = tempFolder.root
)

val boundary = "test-boundary-blob"
val body = java.io.ByteArrayOutputStream().apply {
// Ordered as uploadToServer emits it: the file first, then additionalData.
write("--$boundary\r\n".toByteArray())
write("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n".toByteArray())
write("Content-Type: image/jpeg\r\n\r\n".toByteArray())
write("fake image data".toByteArray())
write("\r\n--$boundary\r\n".toByteArray())
write("Content-Disposition: form-data; name=\"post\"\r\n\r\n".toByteArray())
write("42\r\n".toByteArray())
// A Blob-shaped part: it has a filename, and its bytes are not valid UTF-8.
write("--$boundary\r\n".toByteArray())
write("Content-Disposition: form-data; name=\"blob\"; filename=\"blob\"\r\n".toByteArray())
write("Content-Type: application/octet-stream\r\n\r\n".toByteArray())
write(byteArrayOf(0xED.toByte(), 0xA0.toByte(), 0x80.toByte()))
write("\r\n--$boundary--\r\n".toByteArray())
}.toByteArray()

sendRawRequest(
method = "POST",
path = "/upload",
headers = mapOf(
"Relay-Authorization" to "Bearer ${server.token}",
"Content-Type" to "multipart/form-data; boundary=$boundary"
),
body = body
)

// The Blob is dropped rather than decoded, and file is still the file.
assertEquals("photo.jpg", uploader.received?.filename)
assertEquals(listOf(MediaUploadField("post", "42")), uploader.received?.fields)
}

@Test
fun `round-trips a non-Latin field value exactly`() {
// The other half: valid UTF-8 round-trips, so real captions and titles survive.
val uploader = RecordingUploader()
server.stop()
server = MediaUploadServer(
processor = null, internalClient = MockInternalMediaClient(), uploader = uploader,
cacheDir = tempFolder.root
)

val caption = "Grüße 🎉 日本語"
val boundary = "test-boundary-utf8"
val body = java.io.ByteArrayOutputStream().apply {
write("--$boundary\r\n".toByteArray())
write("Content-Disposition: form-data; name=\"caption\"\r\n\r\n".toByteArray())
write("$caption\r\n".toByteArray())
write("--$boundary\r\n".toByteArray())
write("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n".toByteArray())
write("Content-Type: image/jpeg\r\n\r\n".toByteArray())
write("fake image data".toByteArray())
write("\r\n--$boundary--\r\n".toByteArray())
}.toByteArray()

sendRawRequest(
method = "POST",
path = "/upload",
headers = mapOf(
"Relay-Authorization" to "Bearer ${server.token}",
"Content-Type" to "multipart/form-data; boundary=$boundary"
),
body = body
)

assertEquals(listOf(MediaUploadField("caption", caption)), uploader.received?.fields)
}

@Test
fun `an uploader sees a file the processor's metadata gate would have declined`() {
// The gate exists to skip a temp copy for a file the processor won't touch. An
Expand Down
27 changes: 22 additions & 5 deletions ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,22 @@ final class MediaUploadServer: Sendable {
///
/// A list rather than a dictionary so repeated names (e.g. a `field[]` array)
/// survive verbatim, in the order the editor sent them.
///
/// This decode can't mangle anything, but only because of who is on the other end —
/// nothing in the code enforces it. Three things have to stay true:
///
/// 1. Only the editor's own web page can reach this server. It listens on loopback,
/// and every request has to carry a per-session token.
/// 2. Text the editor puts in a form field is already valid Unicode. The browser
/// guarantees that when the value is set, so it cannot hand us bad bytes.
/// 3. The only way a browser can put *raw* bytes in a form is a file or a Blob, and
/// those always arrive with a filename. Anything with a filename is handled as
/// the file, never as a field — so raw bytes never reach this decode.
Comment on lines +473 to +475

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding from Claude:

Two predicates doing two different jobs on the same array:

  • :210 — parts.first(where: { $0.filename != nil }) picks the file: the first filename-bearing part.
  • :216 — parts.filter { $0.filename == nil } picks the fields: only the filename-less ones.

So a second filename-bearing part is neither. first has already returned, and the filter excludes it for having a filename — it falls out of the request entirely. (On this path, at least; the passthrough at :301 relays the original body verbatim, so it survives there.)

The rule's conclusion is still safe — :216 excludes every filename-bearing part, so raw bytes can't reach the decode. It's the stated reason that's off: "never as a field" holds for all of them, but "handled as the file" holds only for the first, and implies a second Blob gets processed as an upload when it's actually dropped.

Suggested change
/// 3. The only way a browser can put *raw* bytes in a form is a file or a Blob, and
/// those always arrive with a filename. Anything with a filename is handled as
/// the file, never as a field — so raw bytes never reach this decode.
/// 3. The only way a browser can put *raw* bytes in a form is a file or a Blob, and
/// those always arrive with a filename. Anything with a filename is excluded from
/// the fields, so raw bytes never reach this decode. (The first such part is the
/// file; on this path any others are dropped.)

Same wording on MediaUploadServer.kt:614-616.

///
/// If one of those stops being true, bad bytes quietly turn into replacement
/// characters, and the platforms don't even agree on how many: `ED A0 80` becomes
/// three of them here and one on Android. There is no single behavior worth
/// documenting, so the tests pin rule 3 instead.
private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] {
var fields: [MediaUploadField] = []
for part in parts {
Expand Down Expand Up @@ -750,11 +766,12 @@ class InternalMediaClient: @unchecked Sendable {
mimeType: String,
extraFields: [(name: String, value: Data)]
) throws -> (InputStream, Int) {
// Serialize the non-file parts (post, additionalData) into the preamble
// ahead of the streamed file. They are small, so keeping them in memory is
// fine; `contentLength` counts them via `preamble.count`. Field values are
// appended as raw bytes (not through String) so a non-UTF-8 value is
// forwarded verbatim rather than coerced to empty.
// The non-file parts (post, additionalData) go into the preamble ahead of the streamed
// file; they're small, and `contentLength` counts them via `preamble.count`. Their
// values are appended as raw bytes rather than through `String(data:encoding:)`, which
// returns nil on bad UTF-8 — and the `?? ""` you'd reach for behind it would quietly
// drop a whole field. Raw bytes also keep this re-encode byte-for-byte identical to
// the plain passthrough it replaces. (Bad bytes can't get here; see `formFields`.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding from Claude:

This says bad bytes can't reach multipartBodyStream, but multipartBodyPreservesNonUTF8FieldValue — "forwards a non-UTF-8 field value verbatim", MediaUploadServerTests.swift:836 — calls this function with exactly those bytes. The rationale this replaced was the text that test pointed at.

The risk is someone reads the new sentence and deletes the test as vacuous. Scoping it would keep both true, e.g. "Bad bytes can't reach here via the server; this function's own handling of them is pinned by multipartBodyPreservesNonUTF8FieldValue."

Same on MediaUploadServer.kt:719.

var preamble = Data()
for field in extraFields {
preamble.append(Data("--\(boundary)\r\n".utf8))
Expand Down
70 changes: 70 additions & 0 deletions ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,76 @@ struct MediaUploadServerTests {
#expect(received.query == "?_embed=wp:featuredmedia")
}

@Test("keeps a binary Blob part out of an uploader's fields")
func binaryPartExcludedFromFields() async throws {
// Pin rule 3: a Blob always has a filename, so it's dropped before the decode.
let uploader = RecordingUploader()
let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient())
defer { server.stop() }

let boundary = UUID().uuidString
var body = Data()
// Ordered as `uploadToServer` emits it: the file first, then additionalData.
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n")
body.append("Content-Type: image/jpeg\r\n\r\n")
body.append(Data("fake image data".utf8))
body.append("\r\n--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"post\"\r\n\r\n")
body.append("42\r\n")
// A Blob-shaped part: it has a filename, and its bytes are not valid UTF-8.
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"blob\"; filename=\"blob\"\r\n")
body.append("Content-Type: application/octet-stream\r\n\r\n")
body.append(Data([0xED, 0xA0, 0x80]))
body.append("\r\n--\(boundary)--\r\n")

let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body

_ = try await URLSession.shared.data(for: request)

// The Blob is dropped rather than decoded, and `file` is still the file.
let received = try #require(uploader.received)
#expect(received.filename == "photo.jpg")
#expect(received.fields == [MediaUploadField(name: "post", value: "42")])
}

@Test("round-trips a non-Latin field value exactly")
func nonLatinFieldRoundTrips() async throws {
// The other half: valid UTF-8 round-trips, so real captions and titles survive.
let uploader = RecordingUploader()
let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient())
defer { server.stop() }

let caption = "Grüße 🎉 日本語"
let boundary = UUID().uuidString
var body = Data()
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"caption\"\r\n\r\n")
body.append("\(caption)\r\n")
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n")
body.append("Content-Type: image/jpeg\r\n\r\n")
body.append(Data("fake image data".utf8))
body.append("\r\n--\(boundary)--\r\n")

let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body

_ = try await URLSession.shared.data(for: request)

#expect(uploader.received?.fields == [MediaUploadField(name: "caption", value: caption)])
}

@Test("a processor still processes the file an uploader delivers")
func processorRunsForUploader() async throws {
let processor = ProcessOnlyProcessor()
Expand Down
Loading