From 28f17e38a434a46ba6206116afeea7344b383cd2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:24:07 -0600 Subject: [PATCH 1/5] fix(ios): use Int64 for HTTPRequestParser.bytesWritten Change `bytesWritten` from `Int` to `Int64` for consistency with `expectedContentLength` and `maxBodySize`, which are already `Int64`. --- .../GutenbergKitHTTP/HTTPRequestParser.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift index e2778bef0..186946493 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift @@ -50,7 +50,7 @@ public final class HTTPRequestParser: @unchecked Sendable { private var buffer: Buffer private let maxBodySize: Int64 private let inMemoryBodyThreshold: Int - private var bytesWritten: Int = 0 + private var bytesWritten: Int64 = 0 private var _state: State = .needsMoreData // Lightweight scan results (populated by append) @@ -146,7 +146,7 @@ public final class HTTPRequestParser: @unchecked Sendable { } if _parsedHeaders == nil { - let headerData = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize)) + let headerData = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize)))) switch HTTPRequestSerializer.parseHeaders(from: headerData) { case .parsed(let headers): _parsedHeaders = headers @@ -203,9 +203,9 @@ public final class HTTPRequestParser: @unchecked Sendable { // In drain mode, discard bytes without buffering and check // whether the full Content-Length has been consumed. if case .draining = _state { - bytesWritten += data.count + bytesWritten += Int64(data.count) if let offset = headerEndOffset, - Int64(bytesWritten) - Int64(offset) >= expectedContentLength { + bytesWritten - Int64(offset) >= expectedContentLength { _state = .complete } return @@ -224,12 +224,12 @@ public final class HTTPRequestParser: @unchecked Sendable { _state = .complete return } - bytesWritten += data.count + bytesWritten += Int64(data.count) if headerEndOffset == nil { let buffered: Data do { - buffered = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize)) + buffered = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize)))) } catch { _parseError = .bufferIOError _state = .complete @@ -247,7 +247,7 @@ public final class HTTPRequestParser: @unchecked Sendable { let effectiveData = buffered[scanStart...] guard let separatorRange = effectiveData.range(of: separator) else { - if bytesWritten > Self.maxHeaderSize { + if bytesWritten > Int64(Self.maxHeaderSize) { _parseError = .headersTooLarge _state = .complete } else { @@ -274,9 +274,9 @@ public final class HTTPRequestParser: @unchecked Sendable { } guard let offset = headerEndOffset else { return } - let bodyBytesAvailable = bytesWritten - offset + let bodyBytesAvailable = bytesWritten - Int64(offset) - if Int64(bodyBytesAvailable) >= expectedContentLength { + if bodyBytesAvailable >= expectedContentLength { _state = .complete } else { _state = .headersComplete From 8515601243ec7599c2ab69f19d4a9d6e6438ce4d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:38:03 -0600 Subject: [PATCH 2/5] test(ios): add end-to-end test for 413 response with CORS headers Expose maxRequestBodySize on MediaUploadServer.start() and add an integration test that sends an oversized request and verifies the response includes both a 413 status and CORS headers. --- .../Sources/Media/MediaUploadServer.swift | 6 ++++- .../Media/MediaUploadServerTests.swift | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 0a8c25fa9..c06a0fa79 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -27,15 +27,19 @@ final class MediaUploadServer: Sendable { /// - Parameters: /// - uploadDelegate: Optional delegate for customizing file processing and upload. /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`. + /// - maxRequestBodySize: The maximum allowed request body size in bytes. + /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. static func start( uploadDelegate: (any MediaUploadDelegate)? = nil, - defaultUploader: DefaultMediaUploader? = nil + defaultUploader: DefaultMediaUploader? = nil, + maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) let server = try await HTTPServer.start( name: "media-upload", requiresAuthentication: true, + maxRequestBodySize: maxRequestBodySize, handler: { request in await Self.handleRequest(request, context: context) } diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index b67af18db..849950187 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -163,6 +163,31 @@ struct MediaUploadServerTests { #expect(result.id == 99) } + @Test("returns 413 with CORS headers when request body exceeds max size") + func oversizedUploadReturns413WithCORSHeaders() async throws { + let server = try await MediaUploadServer.start(maxRequestBodySize: 100) + defer { server.stop() } + + let boundary = UUID().uuidString + let oversizedData = Data(repeating: 0x42, count: 200) + let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData) + + 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 + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 413) + #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*") + + let responseBody = String(data: data, encoding: .utf8) ?? "" + #expect(responseBody.contains("too large")) + } + private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data { var body = Data() body.append("--\(boundary)\r\n") From 1c97b396caedde6491b65e5c91fca3ec60da0d72 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:16:51 -0600 Subject: [PATCH 3/5] fix(test): increase maxRequestBodySize for 413 test The multipart overhead (~191 bytes) plus auth headers meant the previous limit of 100 bytes caused the connection to reset before the drain could complete. Use 1024 bytes with a 2048-byte payload so the parser can drain the body and deliver the 413 response. --- .../GutenbergKitTests/Media/MediaUploadServerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 849950187..02f2f93e7 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -165,11 +165,11 @@ struct MediaUploadServerTests { @Test("returns 413 with CORS headers when request body exceeds max size") func oversizedUploadReturns413WithCORSHeaders() async throws { - let server = try await MediaUploadServer.start(maxRequestBodySize: 100) + let server = try await MediaUploadServer.start(maxRequestBodySize: 1024) defer { server.stop() } let boundary = UUID().uuidString - let oversizedData = Data(repeating: 0x42, count: 200) + let oversizedData = Data(repeating: 0x42, count: 2048) let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData) let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! From fbc967c33069d0d4bb0581394f7d31a86a7f080a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:29:48 -0600 Subject: [PATCH 4/5] fix(test): use raw TCP for 413 test to avoid URLSession connection reset URLSession treats a server response during upload as a connection error (NSURLErrorNetworkConnectionLost). Use a raw NWConnection to send the request and read the response directly, which correctly receives the 413 with CORS headers. --- .../Media/MediaUploadServerTests.swift | 62 +++++++++++++++---- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 02f2f93e7..fdca22460 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -1,5 +1,6 @@ import Foundation import GutenbergKitHTTP +import Network import Testing @testable import GutenbergKit @@ -168,24 +169,59 @@ struct MediaUploadServerTests { let server = try await MediaUploadServer.start(maxRequestBodySize: 1024) defer { server.stop() } + // Build a raw HTTP request to avoid URLSession's connection-reset + // behavior when the server responds before the upload completes. let boundary = UUID().uuidString let oversizedData = Data(repeating: 0x42, count: 2048) let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData) - 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 - - let (data, response) = try await URLSession.shared.data(for: request) - let httpResponse = try #require(response as? HTTPURLResponse) - #expect(httpResponse.statusCode == 413) - #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*") + let headers = [ + "POST /upload HTTP/1.1", + "Host: 127.0.0.1:\(server.port)", + "Relay-Authorization: Bearer \(server.token)", + "Content-Type: multipart/form-data; boundary=\(boundary)", + "Content-Length: \(body.count)", + "", "" + ].joined(separator: "\r\n") + + let rawRequest = Data(headers.utf8) + body + let responseData = try await sendRawTCP(to: server.port, data: rawRequest) + let responseString = String(data: responseData, encoding: .utf8) ?? "" + + #expect(responseString.contains("HTTP/1.1 413")) + #expect(responseString.contains("Access-Control-Allow-Origin: *")) + #expect(responseString.contains("too large")) + } - let responseBody = String(data: data, encoding: .utf8) ?? "" - #expect(responseBody.contains("too large")) + /// Sends raw bytes over TCP and reads the response. + private func sendRawTCP(to port: UInt16, data: Data) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + let connection = NWConnection( + host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!, + using: .tcp + ) + connection.stateUpdateHandler = { state in + if case .ready = state { + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + return + } + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, _, recvError in + connection.cancel() + if let error = recvError { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: content ?? Data()) + } + } + }) + } else if case .failed(let error) = state { + continuation.resume(throwing: error) + } + } + connection.start(queue: .global()) + } } private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data { From 8d0e3af8666c36ac9af37b3dade33e79a46fb961 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:32:39 -0600 Subject: [PATCH 5/5] fix: complete drain immediately when body arrives with headers When the entire HTTP request (headers + body) arrives in a single read, the parser enters DRAINING but never completes because the body bytes were already counted in bytesWritten. Subsequent reads find no more data, causing a timeout. Check the drain condition immediately when entering the draining state, transitioning to complete if all body bytes have already been received. Fixes both iOS and Android parsers. --- .../gutenberg/http/HTTPRequestParser.kt | 17 +++-- .../GutenbergKitHTTP/HTTPRequestParser.swift | 10 ++- .../Media/MediaUploadServerTests.swift | 62 ++++--------------- 3 files changed, 35 insertions(+), 54 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt index 1c94163db..0157386e4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt @@ -126,10 +126,7 @@ class HTTPRequestParser( // whether the full Content-Length has been consumed. if (_state == State.DRAINING) { bytesWritten += data.size.toLong() - val offset = headerEndOffset - if (offset != null && bytesWritten - offset >= expectedContentLength) { - _state = State.COMPLETE - } + drainIfComplete() return } @@ -193,6 +190,10 @@ class HTTPRequestParser( if (expectedContentLength > maxBodySize) { parseError = HTTPRequestParseError.PAYLOAD_TOO_LARGE _state = State.DRAINING + // Complete immediately if body bytes already received + // satisfy the drain — small requests may arrive as a + // single read. + drainIfComplete() return } } @@ -207,6 +208,14 @@ class HTTPRequestParser( } } + /** Transitions from DRAINING to COMPLETE if all body bytes have been received. */ + private fun drainIfComplete() { + val offset = headerEndOffset ?: return + if (bytesWritten - offset >= expectedContentLength) { + _state = State.COMPLETE + } + } + /** * Parses the buffered data into a structured HTTP request. * diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift index 186946493..07f188ea3 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift @@ -268,7 +268,15 @@ public final class HTTPRequestParser: @unchecked Sendable { if expectedContentLength > maxBodySize { _parseError = .payloadTooLarge - _state = .draining + // Check if the body bytes already received in this + // chunk satisfy the drain — small requests may arrive + // as a single read. + if let offset = headerEndOffset, + bytesWritten - Int64(offset) >= expectedContentLength { + _state = .complete + } else { + _state = .draining + } return } } diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index fdca22460..02f2f93e7 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -1,6 +1,5 @@ import Foundation import GutenbergKitHTTP -import Network import Testing @testable import GutenbergKit @@ -169,59 +168,24 @@ struct MediaUploadServerTests { let server = try await MediaUploadServer.start(maxRequestBodySize: 1024) defer { server.stop() } - // Build a raw HTTP request to avoid URLSession's connection-reset - // behavior when the server responds before the upload completes. let boundary = UUID().uuidString let oversizedData = Data(repeating: 0x42, count: 2048) let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData) - let headers = [ - "POST /upload HTTP/1.1", - "Host: 127.0.0.1:\(server.port)", - "Relay-Authorization: Bearer \(server.token)", - "Content-Type: multipart/form-data; boundary=\(boundary)", - "Content-Length: \(body.count)", - "", "" - ].joined(separator: "\r\n") - - let rawRequest = Data(headers.utf8) + body - let responseData = try await sendRawTCP(to: server.port, data: rawRequest) - let responseString = String(data: responseData, encoding: .utf8) ?? "" - - #expect(responseString.contains("HTTP/1.1 413")) - #expect(responseString.contains("Access-Control-Allow-Origin: *")) - #expect(responseString.contains("too large")) - } + 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 - /// Sends raw bytes over TCP and reads the response. - private func sendRawTCP(to port: UInt16, data: Data) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - let connection = NWConnection( - host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!, - using: .tcp - ) - connection.stateUpdateHandler = { state in - if case .ready = state { - connection.send(content: data, completion: .contentProcessed { error in - if let error { - continuation.resume(throwing: error) - return - } - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, _, recvError in - connection.cancel() - if let error = recvError { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: content ?? Data()) - } - } - }) - } else if case .failed(let error) = state { - continuation.resume(throwing: error) - } - } - connection.start(queue: .global()) - } + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 413) + #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*") + + let responseBody = String(data: data, encoding: .utf8) ?? "" + #expect(responseBody.contains("too large")) } private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data {