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 @@ -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
}

Expand Down Expand Up @@ -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
}
}
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
28 changes: 18 additions & 10 deletions ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down