From 0829cfcc31a22124dbb30959bf075c64de381ed3 Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 28 Jul 2026 14:27:27 +0100 Subject: [PATCH 1/7] fix ; parsing in ContentDisposition --- .../Base/ContentDisposition.swift | 44 +++++++++++++++++-- .../Base/Test_ContentDisposition.swift | 18 ++++++++ .../Test_MultipartValidationSequence.swift | 24 ++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index c911c388..b828947b 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -102,12 +102,48 @@ struct ContentDisposition: Hashable { extension ContentDisposition: RawRepresentable { + /// Splits a value into top-level components on `;`, without splitting inside a quoted value. + private static func splitIntoTopLevelComponents(_ rawValue: String) -> [String] { + var components: [String] = [] + var current = "" + var isInsideQuotedString = false + var iterator = rawValue.makeIterator() + while let character = iterator.next() { + switch character { + case "\\" where isInsideQuotedString: + current.append(character) + if let escaped = iterator.next() { current.append(escaped) } + case "\"": + isInsideQuotedString.toggle() + current.append(character) + case ";" where !isInsideQuotedString: + if !current.isEmpty { components.append(current) } + current = "" + default: current.append(character) + } + } + if !current.isEmpty { components.append(current) } + return components.map { $0.trimmingLeadingAndTrailingSpaces } + } + + /// Removes surrounding quotes and resolves backslash-escaped characters in a parameter value. + private static func unquote(_ value: String) -> String { + guard value.count >= 2, value.first == "\"", value.last == "\"" else { return value } + return value.dropFirst().dropLast().replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + } + + /// Wraps a parameter value in quotes, escaping backslashes and double quotes. + private static func quote(_ value: String) -> String { + "\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" + } + /// Creates a new instance with the specified raw value. /// /// https://datatracker.ietf.org/doc/html/rfc6266#section-4.1 /// - Parameter rawValue: The raw value to use for the new instance. init?(rawValue: String) { - var components = rawValue.split(separator: ";").map { $0.trimmingLeadingAndTrailingSpaces } + var components = Self.splitIntoTopLevelComponents(rawValue) guard !components.isEmpty else { return nil } self.dispositionType = DispositionType(rawValue: components.removeFirst()) let parameterTuples: [(ParameterName, String)] = components.compactMap { @@ -115,8 +151,8 @@ extension ContentDisposition: RawRepresentable { let parameterComponents = component.split(separator: "=", maxSplits: 1) .map { $0.trimmingLeadingAndTrailingSpaces } guard parameterComponents.count == 2 else { return nil } - let valueWithoutQuotes = parameterComponents[1].trimming(while: { $0 == "\"" }) - return (.init(rawValue: parameterComponents[0]), valueWithoutQuotes) + let value = Self.unquote(parameterComponents[1]) + return (.init(rawValue: parameterComponents[0]), value) } self.parameters = Dictionary(parameterTuples, uniquingKeysWith: { a, b in a }) } @@ -127,7 +163,7 @@ extension ContentDisposition: RawRepresentable { string.append(dispositionType.rawValue) if !parameters.isEmpty { for (key, value) in parameters.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { - string.append("; \(key.rawValue)=\"\(value)\"") + string.append("; \(key.rawValue)=\(Self.quote(value))") } } return string diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift index 121c5fdd..b337b478 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift @@ -68,6 +68,24 @@ final class Test_ContentDisposition: Test_Runtime { // Empty _test(input: "", parsed: nil, output: nil) + + // A filename value that contains a quote and a semicolon must be a part + // of the quoted-string value, not be parsed as the start of another parameter. + _test( + input: #"form-data; filename="He said \"hi\"; then left.txt"; name="report""#, + parsed: ContentDisposition( + dispositionType: .formData, + parameters: [.name: "report", .filename: #"He said "hi"; then left.txt"#] + ), + output: #"form-data; filename="He said \"hi\"; then left.txt"; name="report""# + ) + + // A semicolon embedded in a quoted parameter value must stay as part of the value. + _test( + input: #"form-data; name="foo; bar""#, + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "foo; bar"]), + output: #"form-data; name="foo; bar""# + ) } func testAccessors() { var value = ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]) diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift index 8bb93a4e..2c40f3b8 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift @@ -271,6 +271,30 @@ final class Test_MultipartValidationSequenceStateMachine: Test_Runtime { XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) } + func testFilenameContainingQuoteAndSemicolon() throws { + // A filename value that contains a quote and a semicolon must be a part + // of the quoted-string value, not be parsed as the start of another parameter. + let parts: [MultipartRawPart] = [ + .init( + headerFields: [ + .contentDisposition: #"form-data; filename="He said \"hi\"; then left.txt"; name="report""# + ], + body: "file bytes" + ) + ] + XCTAssertEqual(parts[0].filename, #"He said "hi"; then left.txt"#) + XCTAssertEqual(parts[0].name, "report") + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: ["report"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.state.remainingExactlyOncePartNames, []) + } + func testMissingRequiredAtMostOnce() throws { let parts: [MultipartRawPart] = [ .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") From bf21959c822182e57707ab87cd00e337bac80a94 Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 1 Sep 2026 11:17:51 +0100 Subject: [PATCH 2/7] Update Sources/OpenAPIRuntime/Base/ContentDisposition.swift Co-authored-by: Honza Dvorsky --- Sources/OpenAPIRuntime/Base/ContentDisposition.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index b828947b..61a86e1d 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -135,7 +135,7 @@ extension ContentDisposition: RawRepresentable { /// Wraps a parameter value in quotes, escaping backslashes and double quotes. private static func quote(_ value: String) -> String { - "\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" + #"""# + value.replacingOccurrences(of: #"\"#, with: #"\\"#).replacingOccurrences(of: #"""#, with: #"\""#) + #"""# } /// Creates a new instance with the specified raw value. From d64b61fab6b0ac0a3cd7f14e0d233eaeb353909b Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 1 Sep 2026 11:18:36 +0100 Subject: [PATCH 3/7] Apply batched suggestions from code review Co-authored-by: Honza Dvorsky --- Sources/OpenAPIRuntime/Base/ContentDisposition.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index 61a86e1d..4a0c41d8 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -110,10 +110,10 @@ extension ContentDisposition: RawRepresentable { var iterator = rawValue.makeIterator() while let character = iterator.next() { switch character { - case "\\" where isInsideQuotedString: + case #"\"# where isInsideQuotedString: current.append(character) if let escaped = iterator.next() { current.append(escaped) } - case "\"": + case #"""#: isInsideQuotedString.toggle() current.append(character) case ";" where !isInsideQuotedString: @@ -128,9 +128,9 @@ extension ContentDisposition: RawRepresentable { /// Removes surrounding quotes and resolves backslash-escaped characters in a parameter value. private static func unquote(_ value: String) -> String { - guard value.count >= 2, value.first == "\"", value.last == "\"" else { return value } - return value.dropFirst().dropLast().replacingOccurrences(of: "\\\"", with: "\"") - .replacingOccurrences(of: "\\\\", with: "\\") + guard value.count >= 2, value.first == #"""#, value.last == #"""# else { return value } + return value.dropFirst().dropLast().replacingOccurrences(of: #"\""#, with: #"""#) + .replacingOccurrences(of: #"\\"#, with: #"\"#) } /// Wraps a parameter value in quotes, escaping backslashes and double quotes. From 88423dc72373a6d042f07d633115fafc10d3021e Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 1 Sep 2026 13:58:40 +0100 Subject: [PATCH 4/7] remove a very indirect test --- .../Test_MultipartValidationSequence.swift | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift index 2c40f3b8..8bb93a4e 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift @@ -271,30 +271,6 @@ final class Test_MultipartValidationSequenceStateMachine: Test_Runtime { XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) } - func testFilenameContainingQuoteAndSemicolon() throws { - // A filename value that contains a quote and a semicolon must be a part - // of the quoted-string value, not be parsed as the start of another parameter. - let parts: [MultipartRawPart] = [ - .init( - headerFields: [ - .contentDisposition: #"form-data; filename="He said \"hi\"; then left.txt"; name="report""# - ], - body: "file bytes" - ) - ] - XCTAssertEqual(parts[0].filename, #"He said "hi"; then left.txt"#) - XCTAssertEqual(parts[0].name, "report") - var stateMachine = newStateMachine( - allowsUnknownParts: false, - requiredExactlyOncePartNames: ["report"], - requiredAtLeastOncePartNames: [], - atMostOncePartNames: [], - zeroOrMoreTimesPartNames: [] - ) - XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) - XCTAssertEqual(stateMachine.state.remainingExactlyOncePartNames, []) - } - func testMissingRequiredAtMostOnce() throws { let parts: [MultipartRawPart] = [ .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") From 74b9ce53a46b6f9081e281c3605fd8e0a6e4a8e1 Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 1 Sep 2026 21:33:24 +0100 Subject: [PATCH 5/7] fix formatting --- Sources/OpenAPIRuntime/Base/ContentDisposition.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index 4a0c41d8..afc72422 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -135,7 +135,8 @@ extension ContentDisposition: RawRepresentable { /// Wraps a parameter value in quotes, escaping backslashes and double quotes. private static func quote(_ value: String) -> String { - #"""# + value.replacingOccurrences(of: #"\"#, with: #"\\"#).replacingOccurrences(of: #"""#, with: #"\""#) + #"""# + #"""# + value.replacingOccurrences(of: #"\"#, with: #"\\"#).replacingOccurrences(of: #"""#, with: #"\""#) + + #"""# } /// Creates a new instance with the specified raw value. From fdd40956f8cbf319a12889ad3b4dd2a8ab4244fa Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 1 Sep 2026 21:42:03 +0100 Subject: [PATCH 6/7] reject malformed strings --- .../OpenAPIRuntime/Base/ContentDisposition.swift | 15 +++++++++++---- .../Base/Test_ContentDisposition.swift | 6 ++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index afc72422..9fb73912 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -103,25 +103,32 @@ struct ContentDisposition: Hashable { extension ContentDisposition: RawRepresentable { /// Splits a value into top-level components on `;`, without splitting inside a quoted value. + /// + /// Returns an empty array if the value has an unterminated quoted string, or a trailing + /// unescaped backslash inside a quoted string. private static func splitIntoTopLevelComponents(_ rawValue: String) -> [String] { var components: [String] = [] var current = "" var isInsideQuotedString = false - var iterator = rawValue.makeIterator() - while let character = iterator.next() { + var isExpectingEscapedCharacter = false + for character in rawValue { switch character { + case _ where isExpectingEscapedCharacter: + current.append(character) + isExpectingEscapedCharacter = false case #"\"# where isInsideQuotedString: current.append(character) - if let escaped = iterator.next() { current.append(escaped) } + isExpectingEscapedCharacter = true case #"""#: - isInsideQuotedString.toggle() current.append(character) + isInsideQuotedString.toggle() case ";" where !isInsideQuotedString: if !current.isEmpty { components.append(current) } current = "" default: current.append(character) } } + guard !isInsideQuotedString, !isExpectingEscapedCharacter else { return [] } if !current.isEmpty { components.append(current) } return components.map { $0.trimmingLeadingAndTrailingSpaces } } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift index b337b478..939c9256 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift @@ -86,6 +86,12 @@ final class Test_ContentDisposition: Test_Runtime { parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "foo; bar"]), output: #"form-data; name="foo; bar""# ) + + // An unterminated quoted string must fail to parse, not silently swallow the rest of the header. + _test(input: #"form-data; name="foo"#, parsed: nil, output: nil) + + // A trailing unescaped backslash inside a quoted string must fail to parse. + _test(input: #"form-data; name="foo\"#, parsed: nil, output: nil) } func testAccessors() { var value = ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]) From 801ca958f14cbdfb954c84bffd0178f01da1b5b5 Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 1 Sep 2026 21:48:45 +0100 Subject: [PATCH 7/7] fixup! reject malformed strings --- Sources/OpenAPIRuntime/Base/ContentDisposition.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index 9fb73912..c4adfac7 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -104,8 +104,7 @@ extension ContentDisposition: RawRepresentable { /// Splits a value into top-level components on `;`, without splitting inside a quoted value. /// - /// Returns an empty array if the value has an unterminated quoted string, or a trailing - /// unescaped backslash inside a quoted string. + /// Returns an empty array if the value has an unterminated quoted string. private static func splitIntoTopLevelComponents(_ rawValue: String) -> [String] { var components: [String] = [] var current = "" @@ -128,7 +127,7 @@ extension ContentDisposition: RawRepresentable { default: current.append(character) } } - guard !isInsideQuotedString, !isExpectingEscapedCharacter else { return [] } + guard !isInsideQuotedString else { return [] } if !current.isEmpty { components.append(current) } return components.map { $0.trimmingLeadingAndTrailingSpaces } }