Skip to content

Commit dfefb4e

Browse files
committed
review-comment: Don't use URLComponent to parse application/x-www-form-urlencoded
based on https://github.com/kula-app/Postie/blob/3698c01e4c2df68d81a23b4dc428416cea9e4e5f/Sources/URLEncodedFormCoding/URLEncodedFormDecoder.swift#L19-L49 extended unit tests
1 parent bbe46cd commit dfefb4e

2 files changed

Lines changed: 129 additions & 30 deletions

File tree

Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,38 @@ enum NetworkBodyWarning: String {
147147
}
148148
}
149149

150+
/// Parses `application/x-www-form-urlencoded` data into a dictionary.
150151
private static func parseFormEncoded(_ data: Data, encoding: String.Encoding, warnings: inout [NetworkBodyWarning]) -> Body {
151-
guard let string = String(data: data, encoding: encoding) ?? String(data: data, encoding: .utf8),
152-
let components = URLComponents(string: "http://x?" + string),
153-
let items = components.queryItems else {
152+
guard let urlEncodedFormData = String(data: data, encoding: encoding) ?? String(data: data, encoding: .utf8) else {
154153
warnings.append(.bodyParseError)
155154
return parseText(data, encoding: encoding, warnings: &warnings)
156155
}
157156

158-
var formData = [String: String]()
159-
for item in items where item.name.isEmpty == false {
160-
formData[item.name] = item.value ?? ""
157+
var formData = [String: Any]()
158+
for rawElement in urlEncodedFormData.components(separatedBy: "&") where !rawElement.isEmpty {
159+
let comps = rawElement.components(separatedBy: "=")
160+
if comps.count < 2 {
161+
warnings.append(.bodyParseError)
162+
return parseText(data, encoding: encoding, warnings: &warnings)
163+
}
164+
// In form-urlencoded, + means space (rdar://40751862).
165+
let key = comps[0]
166+
.replacingOccurrences(of: "+", with: " ")
167+
.removingPercentEncoding ?? comps[0]
168+
let value = comps.dropFirst().joined(separator: "=")
169+
.replacingOccurrences(of: "+", with: " ")
170+
.removingPercentEncoding ?? comps[1]
171+
guard !key.isEmpty else { continue }
172+
if let existing = formData[key] {
173+
if var list = existing as? [String] {
174+
list.append(value)
175+
formData[key] = list
176+
} else if let text = existing as? String {
177+
formData[key] = [text, value]
178+
}
179+
} else {
180+
formData[key] = value
181+
}
161182
}
162183
return Body(content: formData, warnings: warnings)
163184
}

Tests/SentryTests/Networking/SentryReplayNetworkDetailsBodyTests.swift

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ class SentryReplayNetworkDetailsBodyTests: XCTestCase {
2121
let body = Body(data: bodyData, contentType: "application/json")
2222

2323
// -- Assert --
24-
XCTAssertNotNil(body)
2524
if case .json(let value) = body?.content {
2625
let dict = value as? [String: Any]
2726
XCTAssertEqual(dict?["key"] as? String, "value")
@@ -45,7 +44,6 @@ class SentryReplayNetworkDetailsBodyTests: XCTestCase {
4544
let body = Body(data: bodyData, contentType: "application/json")
4645

4746
// -- Assert --
48-
XCTAssertNotNil(body)
4947
if case .json(let value) = body?.content {
5048
let array = value as? [String]
5149
XCTAssertEqual(array?.count, 3)
@@ -84,28 +82,6 @@ class SentryReplayNetworkDetailsBodyTests: XCTestCase {
8482
XCTAssertNil(body)
8583
}
8684

87-
func testInit_withFormURLEncoded_shouldParseAsForm() {
88-
// -- Arrange --
89-
let formString = "key1=value1&key2=value2&key3=value%20with%20spaces"
90-
guard let bodyData = formString.data(using: .utf8) else {
91-
return XCTFail("Failed to create form data")
92-
}
93-
94-
// -- Act --
95-
let body = Body(data: bodyData, contentType: "application/x-www-form-urlencoded")
96-
97-
// -- Assert --
98-
XCTAssertNotNil(body)
99-
if case .json(let value) = body?.content {
100-
let dict = value as? [String: String]
101-
XCTAssertEqual(dict?["key1"], "value1")
102-
XCTAssertEqual(dict?["key2"], "value2")
103-
XCTAssertEqual(dict?["key3"], "value with spaces")
104-
} else {
105-
XCTFail("Expected .json content for form data")
106-
}
107-
}
108-
10985
func testInit_withInvalidJSON_shouldFallbackToString() {
11086
// -- Arrange --
11187
let invalidJSON = "{ invalid json }"
@@ -216,6 +192,108 @@ class SentryReplayNetworkDetailsBodyTests: XCTestCase {
216192
XCTFail("Expected .text content with placeholder description")
217193
}
218194
}
195+
196+
// MARK: - Form URL-Encoded Parsing
197+
198+
func testInit_withFormURLEncoded_shouldParseAsForm() {
199+
// -- Arrange --
200+
let formString = "key1=value1&key2=value2&key3=value%20with%20spaces"
201+
guard let bodyData = formString.data(using: .utf8) else {
202+
return XCTFail("Failed to create form data")
203+
}
204+
205+
// -- Act --
206+
let body = Body(data: bodyData, contentType: "application/x-www-form-urlencoded")
207+
208+
// -- Assert --
209+
if case .json(let value) = body?.content {
210+
let dict = value as? [String: String]
211+
XCTAssertEqual(dict?["key1"], "value1")
212+
XCTAssertEqual(dict?["key2"], "value2")
213+
XCTAssertEqual(dict?["key3"], "value with spaces")
214+
} else {
215+
XCTFail("Expected .json content for form data")
216+
}
217+
}
218+
219+
func testInit_withFormURLEncoded_duplicateKeys_shouldPromoteToArray() throws {
220+
// -- Act --
221+
let body = try XCTUnwrap(Body(
222+
data: "color=red&color=blue&color=green".data(using: .utf8)!,
223+
contentType: "application/x-www-form-urlencoded; charset=utf-8"
224+
))
225+
226+
// -- Assert --
227+
let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any])
228+
XCTAssertEqual(dict["color"] as? [String], ["red", "blue", "green"])
229+
}
230+
231+
func testInit_withFormURLEncoded_emptyValue_shouldParseAsEmptyString() throws {
232+
// -- Act --
233+
let body = try XCTUnwrap(Body(
234+
data: "key1=&key2=value2".data(using: .utf8)!,
235+
contentType: "application/x-www-form-urlencoded; charset=utf-8"
236+
))
237+
238+
// -- Assert --
239+
let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any])
240+
XCTAssertEqual(dict["key1"] as? String, "")
241+
XCTAssertEqual(dict["key2"] as? String, "value2")
242+
}
243+
244+
func testInit_withFormURLEncoded_missingEquals_shouldFallbackToText() throws {
245+
// -- Act --
246+
let body = try XCTUnwrap(Body(
247+
data: "key1=value1&malformed&key2=value2".data(using: .utf8)!,
248+
contentType: "application/x-www-form-urlencoded; charset=utf-8"
249+
))
250+
251+
// -- Assert --
252+
let serialized = body.serialize()
253+
let text = try XCTUnwrap(serialized["body"] as? String)
254+
XCTAssertEqual(text, "key1=value1&malformed&key2=value2")
255+
let warnings = try XCTUnwrap(serialized["warnings"] as? [String])
256+
XCTAssertTrue(warnings.contains("BODY_PARSE_ERROR"))
257+
}
258+
259+
func testInit_withFormURLEncoded_emptyKeys_shouldBeSkipped() throws {
260+
// -- Act --
261+
let body = try XCTUnwrap(Body(
262+
data: "=value1&key2=value2".data(using: .utf8)!,
263+
contentType: "application/x-www-form-urlencoded; charset=utf-8"
264+
))
265+
266+
// -- Assert --
267+
let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any])
268+
XCTAssertNil(dict[""])
269+
XCTAssertEqual(dict["key2"] as? String, "value2")
270+
}
271+
272+
func testInit_withFormURLEncoded_equalsInValue_shouldPreserve() throws {
273+
// -- Act --
274+
let body = try XCTUnwrap(Body(
275+
data: "query=a=1&token=abc".data(using: .utf8)!,
276+
contentType: "application/x-www-form-urlencoded; charset=utf-8"
277+
))
278+
279+
// -- Assert --
280+
let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any])
281+
XCTAssertEqual(dict["query"] as? String, "a=1")
282+
XCTAssertEqual(dict["token"] as? String, "abc")
283+
}
284+
285+
func testInit_withFormURLEncoded_plusAsSpace_shouldDecode() throws {
286+
// -- Act --
287+
let body = try XCTUnwrap(Body(
288+
data: "greeting=hello+world&name=Jane+Doe".data(using: .utf8)!,
289+
contentType: "application/x-www-form-urlencoded; charset=utf-8"
290+
))
291+
292+
// -- Assert --
293+
let dict = try XCTUnwrap(body.serialize()["body"] as? [String: Any])
294+
XCTAssertEqual(dict["greeting"] as? String, "hello world")
295+
XCTAssertEqual(dict["name"] as? String, "Jane Doe")
296+
}
219297

220298
// MARK: - Serialization Tests
221299

0 commit comments

Comments
 (0)