diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html index abcb0a795..0f78a259e 100644 --- a/src/browser/tests/net/request.html +++ b/src/browser/tests/net/request.html @@ -274,6 +274,7 @@ const src = new FormData(); src.append('username', 'alice'); src.append('note', 'two\r\nlines'); + src.append('a\tb', 'tabbed'); src.append('username', 'bob'); const req2 = new Request('https://example.com', { method: 'POST', body: src }); const fd2 = await req2.formData(); @@ -340,6 +341,7 @@ testing.expectEqual('alice', fd2.getAll('username')[0]); testing.expectEqual('bob', fd2.getAll('username')[1]); testing.expectEqual('two\r\nlines', fd2.get('note')); + testing.expectEqual('tabbed', fd2.get('a\tb')); testing.expectEqual('y z', fd3.get('x')); diff --git a/src/browser/tests/net/response.html b/src/browser/tests/net/response.html index 0a7ea102c..6fd33d006 100644 --- a/src/browser/tests/net/response.html +++ b/src/browser/tests/net/response.html @@ -99,6 +99,7 @@ const src = new FormData(); src.append('username', 'alice'); src.append('note', 'two\r\nlines'); + src.append('a\tb', 'tabbed'); src.append('username', 'bob'); const res2 = new Response(src); const fd2 = await res2.formData(); @@ -123,6 +124,28 @@ const promised6 = p6 instanceof Promise; try { await p6; } catch (e) { rejected6 = e instanceof TypeError; } + // A null body with a form Content-Type acts as an empty byte sequence and + // resolves with an empty FormData. + const res7 = new Response(null, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + const fd7 = await res7.formData(); + + // A multipart Content-Type without a boundary rejects with a TypeError. + let rejected8 = false; + const res8 = new Response('x', { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + try { await res8.formData(); } catch (e) { rejected8 = e instanceof TypeError; } + + // A part with an unquoted RFC 5987 filename* parameter — which real + // servers emit — parses; the parameter itself is skipped. + const res9 = new Response( + '--B\r\nContent-Disposition: form-data; filename*=UTF-8\'\'a.jpg; name="n"\r\n\r\nv\r\n--B--\r\n', + { headers: { 'Content-Type': 'multipart/form-data; boundary=B' } }, + ); + const fd9 = await res9.formData(); + state.resolve(); await state.done(() => { testing.expectEqual(true, fd1 instanceof FormData); @@ -136,6 +159,7 @@ testing.expectEqual('alice', fd2.getAll('username')[0]); testing.expectEqual('bob', fd2.getAll('username')[1]); testing.expectEqual('two\r\nlines', fd2.get('note')); + testing.expectEqual('tabbed', fd2.get('a\tb')); testing.expectEqual('y z', fd3.get('x')); @@ -144,6 +168,11 @@ testing.expectEqual(true, promised6); testing.expectEqual(true, rejected6); + + testing.expectEqual(true, fd7 instanceof FormData); + testing.expectEqual(0, [...fd7].length); + testing.expectEqual(true, rejected8); + testing.expectEqual('v', fd9.get('n')); }); diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 947086bc0..04ba28d2d 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -1314,6 +1314,9 @@ test "FormData: multipart round-trip" { try src.appendText("username", "alice"); try src.appendText("username", "bob"); try src.appendText("a\"b\r\nc", "quoted \"value\""); + // HTAB in a name survives the trip: the encoder writes it raw into the + // Content-Disposition value and the parser accepts it (RFC 9110). + try src.appendText("a\tb", "tabbed"); var buf = std.Io.Writer.Allocating.init(allocator); try src.write(.{ @@ -1328,10 +1331,11 @@ test "FormData: multipart round-trip" { }; try fd.parseMultipart(frame._page, buf.written(), "BOUNDARY"); - try testing.expectEqual(3, fd._entries.items.len); + try testing.expectEqual(4, fd._entries.items.len); try testing.expectString("username", fd._entries.items[0].name.str()); try testing.expectString("alice", fd._entries.items[0].value.asString()); try testing.expectString("username", fd._entries.items[1].name.str()); try testing.expectString("bob", fd._entries.items[1].value.asString()); try testing.expectString("quoted \"value\"", fd.get(.wrap("a\"b\r\nc")).?); + try testing.expectString("tabbed", fd.get(.wrap("a\tb")).?); } diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig index 4c866482f..54e672d03 100644 --- a/src/network/header_parser.zig +++ b/src/network/header_parser.zig @@ -390,16 +390,22 @@ test "header_parser: parse HTTP header" { try testing.expectString("def", header.value); try testing.expectEqual(true, cursor.reachedEnd()); - // Leading whitespaces of the value are skipped, trailing ones are kept. - cursor = initCursor("Key: value \r\n"); + // Leading whitespaces of the value — spaces and HTABs — are skipped, + // trailing ones are kept. + cursor = initCursor("Key: \t value \t\r\n"); try header.parse(&cursor); - try testing.expectString("value ", header.value); + try testing.expectString("value \t", header.value); // 0 length values are fine. cursor = initCursor("Key:\r\n"); try header.parse(&cursor); try testing.expectString("Key", header.key); try testing.expectString("", header.value); + + // HTAB is legal field content, including next to spaces. + cursor = initCursor("Key: a\tb\t c\r\n"); + try header.parse(&cursor); + try testing.expectString("a\tb\t c", header.value); } test "header_parser: parse HTTP header incomplete" { @@ -490,8 +496,8 @@ test "header_parser: cursor" { try testing.expectEqual(true, cursor.reachedEnd()); try testing.expectString("", cursor.remaining()); - // Skipping spaces stops at the end of the buffer. - cursor = initCursor(" "); + // Skipping spaces covers HTABs too and stops at the end of the buffer. + cursor = initCursor(" \t \t"); cursor.skipSpaces(); try testing.expectEqual(true, cursor.reachedEnd()); } @@ -525,6 +531,20 @@ test "header_parser: match functions against scalar reference" { } } + // HTAB is the one valid byte below space; pair it with every byte at + // every position to prove it never disturbs its neighbor's verdict + // (a cross-lane borrow in the SWAR path would). + for (2..buf.len + 1) |len| { + for (0..len - 1) |pos| { + for (0..256) |c| { + @memset(buf[0..len], 'a'); + buf[pos] = '\t'; + buf[pos + 1] = @intCast(c); + try expectMatchesReference(buf[0..len]); + } + } + } + // Randomized: fully random buffers to exercise multiple invalid bytes per // chunk at once. var prng = std.Random.DefaultPrng.init(0x5eed);