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/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/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift index e2778bef0..07f188ea3 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 { @@ -268,15 +268,23 @@ 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 } } 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 diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index b67af18db..02f2f93e7 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: 1024) + defer { server.stop() } + + 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 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")