LibURL: Optimize parsing of URLs in authority state

Previously the authority state was parsed character-by-character in a
loop, appending each character to a buffer. When an '@' character was
encountered, the entire buffer would be re-processed to extract and
percent-encode the username and password portions.

This created O(n^2) behavior for URLs with multiple '@' characters, as
each '@' would trigger reprocessing of all previously buffered content.

This commit changes the authority state parser to process the authority
section in chunks rather than character-by-character:

1. Find the next delimiter ('@', '/', '?', '#', or '\' for special URLs)
2. Process the entire chunk up to that delimiter at once
3. Directly extract username/password from the chunk without buffering

With an additional change of switching to:
iterator_at_byte_offset_without_validation (which does not have any
loops), this reduces the time complexity to O(n) and the included
test found by fuzzing to actually complete parsing :^)
This commit is contained in:
Shannon Booth 2026-01-23 17:56:01 +01:00 committed by Shannon Booth
parent add381fe60
commit c8dc5ea27c
2 changed files with 55 additions and 48 deletions

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2021, Max Wipfli <mail@maxwipfli.ch>
* Copyright (c) 2023-2025, Shannon Booth <shannon@serenityos.org>
* Copyright (c) 2023-2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -769,16 +769,25 @@ Optional<URL> Parser::basic_parse(StringView raw_input, Optional<URL const&> bas
bool at_sign_seen = false;
bool inside_brackets = false;
bool password_token_seen = false;
StringBuilder username_builder;
StringBuilder password_builder;
Utf8View input(processed_input);
// 8. Let pointer be a pointer for input.
Utf8CodePointIterator iterator = input.begin();
u32 code_point = end_of_file;
auto get_remaining = [&input, &iterator] {
return input.substring_view(iterator - input.begin() + iterator.underlying_code_point_length_in_bytes()).as_string();
};
auto get_remaining_including_code_point = [&input, &iterator, &code_point] {
if (code_point == end_of_file)
return StringView {};
return input.substring_view(iterator - input.begin() + iterator.underlying_code_point_length_in_bytes() - AK::UnicodeUtils::code_unit_length_for_code_point(code_point)).as_string();
};
auto remaining_starts_with_two_ascii_hex_digits = [&]() {
return is_ascii_hex_digit(iterator.peek(1).value_or(end_of_file)) && is_ascii_hex_digit(iterator.peek(2).value_or(end_of_file));
};
@ -788,7 +797,7 @@ Optional<URL> Parser::basic_parse(StringView raw_input, Optional<URL const&> bas
// ++iterator : "increase pointer by 1"
// continue : "decrease pointer by 1"
for (;;) {
u32 code_point = end_of_file;
code_point = end_of_file;
if (!iterator.done())
code_point = *iterator;
@ -1064,7 +1073,16 @@ Optional<URL> Parser::basic_parse(StringView raw_input, Optional<URL const&> bas
}
break;
// -> authority state, https://url.spec.whatwg.org/#authority-state
case State::Authority:
case State::Authority: {
auto remaining = get_remaining_including_code_point();
// Authority is delimited by '@/?#' and additionally '\' for special URLs
auto delimiter = url->is_special() ? "@/?#\\"sv : "@/?#"sv;
auto authority_end = remaining.find_any_of(delimiter);
auto authority_length = authority_end.value_or(remaining.length());
auto authority = remaining.substring_view(0, authority_length);
code_point = authority_length < remaining.length() ? remaining[authority_length] : end_of_file;
// 1. If c is U+0040 (@), then:
if (code_point == '@') {
// 1. Invalid-credentials validation error.
@ -1072,77 +1090,59 @@ Optional<URL> Parser::basic_parse(StringView raw_input, Optional<URL const&> bas
// 2. If atSignSeen is true, then prepend "%40" to buffer.
if (at_sign_seen) {
auto content = buffer.to_byte_string();
buffer.clear();
buffer.append("%40"sv);
buffer.append(content);
if (password_token_seen)
password_builder.append("%40"sv);
else
username_builder.append("%40"sv);
}
// 3. Set atSignSeen to true.
at_sign_seen = true;
StringBuilder username_builder;
StringBuilder password_builder;
// 4. For each codePoint in buffer:
for (auto c : Utf8View(buffer.string_view())) {
// 1. If codePoint is U+003A (:) and passwordTokenSeen is false, then set passwordTokenSeen to true and continue.
if (c == ':' && !password_token_seen) {
// 1. If codePoint is U+003A (:) and passwordTokenSeen is false, then set passwordTokenSeen to true and continue.
// 2. Let encodedCodePoints be the result of running UTF-8 percent-encode codePoint using the userinfo percent-encode set.
// 3. If passwordTokenSeen is true, then append encodedCodePoints to urls password.
// 4. Otherwise, append encodedCodePoints to urls username.
if (password_token_seen) {
password_builder.append(percent_encode(authority, PercentEncodeSet::Userinfo));
} else {
if (auto password_end = authority.find(':'); password_end.has_value()) {
password_token_seen = true;
continue;
}
// 2. Let encodedCodePoints be the result of running UTF-8 percent-encode codePoint using the userinfo percent-encode set.
// NOTE: This is done inside of step 3 and 4 implementation
// 3. If passwordTokenSeen is true, then append encodedCodePoints to urls password.
if (password_token_seen) {
if (password_builder.is_empty())
password_builder.append(url->m_data->password);
append_percent_encoded_if_necessary(password_builder, c, PercentEncodeSet::Userinfo);
}
// 4. Otherwise, append encodedCodePoints to urls username.
else {
if (username_builder.is_empty())
username_builder.append(url->m_data->username);
append_percent_encoded_if_necessary(username_builder, c, PercentEncodeSet::Userinfo);
username_builder.append(percent_encode(authority.substring_view(0, password_end.value()), PercentEncodeSet::Userinfo));
password_builder.append(percent_encode(authority.substring_view(password_end.value() + 1), PercentEncodeSet::Userinfo));
} else {
username_builder.append(percent_encode(authority, PercentEncodeSet::Userinfo));
}
}
if (username_builder.string_view().length() > url->m_data->username.bytes().size())
url->m_data->username = username_builder.to_string().release_value_but_fixme_should_propagate_errors();
if (password_builder.string_view().length() > url->m_data->password.bytes().size())
url->m_data->password = password_builder.to_string().release_value_but_fixme_should_propagate_errors();
// 5. Set buffer to the empty string.
buffer.clear();
// NB: Since we have batch processed the username/password, we need to move the iterator past those code points.
iterator = input.iterator_at_byte_offset_without_validation(iterator - input.begin() + authority.length());
}
// 2. Otherwise, if one of the following is true:
// * c is the EOF code point, U+002F (/), U+003F (?), or U+0023 (#)
// * url is special and c is U+005C (\)
else if ((code_point == end_of_file || code_point == '/' || code_point == '?' || code_point == '#')
|| (url->is_special() && code_point == '\\')) {
// NB: This is always the case per our search over authority above.
else {
// then:
// 1. If atSignSeen is true and buffer is the empty string, host-missing validation error, return failure.
if (at_sign_seen && buffer.is_empty()) {
if (at_sign_seen && authority.is_empty()) {
report_validation_error();
return {};
}
// 2. Decrease pointer by buffers code point length + 1, set buffer to the empty string, and set state to host state.
iterator = input.iterator_at_byte_offset(iterator - input.begin() - buffer.length() - 1);
iterator = input.iterator_at_byte_offset_without_validation(iterator - input.begin() - 1);
buffer.clear();
state = State::Host;
url->m_data->password = password_builder.to_string_without_validation();
url->m_data->username = username_builder.to_string_without_validation();
}
// 3. Otherwise, append c to buffer.
else {
buffer.append_code_point(code_point);
}
// NB: This is not needed as we do not process authority per codepoint over the buffer.
break;
}
// -> host state, https://url.spec.whatwg.org/#host-state
// -> hostname state, https://url.spec.whatwg.org/#hostname-state
case State::Host:

View file

@ -1,7 +1,7 @@
/*
* Copyright (c) 2018-2022, Andreas Kling <andreas@ladybird.org>
* Copyright (c) 2021, the SerenityOS developers.
* Copyright (c) 2025, Shannon Booth <shannon@serenityos.org>
* Copyright (c) 2025-2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -785,3 +785,10 @@ TEST_CASE(same_origin_domain)
EXPECT(!a_relaxed.is_same_origin_domain(http_relaxed));
EXPECT(!opaque1.is_same_origin_domain(a_relaxed));
}
TEST_CASE(authority_state_lots_of_at_symbols)
{
auto many_at_symbols = MUST(String::repeated('@', 500'000));
auto horror_url = MUST(String::formatted("ws::{}", many_at_symbols));
EXPECT(!URL::Parser::basic_parse(horror_url).has_value());
}