From 08766d47f4884719c4b27d8fec27f05a18bd0c1c Mon Sep 17 00:00:00 2001 From: Luke Wilde Date: Thu, 26 Mar 2026 17:04:33 +0000 Subject: [PATCH] LibWeb+LibHTTP+LibWebView: Implement HSTS When an HTTPS response carries a Strict-Transport-Security header, the received policy is now respected. Subsequent HTTP requests to a known HSTS host are upgraded to HTTPS before the fetch algorithm makes further decisions such as CORS and mixed content. Fixes tpexpress.co.uk, where an XHR redirects HTTPS -> HTTP -> HTTPS, relying on a HSTS policy received on the document response to avoid the CORS failure. --- Libraries/LibHTTP/CMakeLists.txt | 1 + Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.cpp | 178 ++++++++++++++++ Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.h | 37 ++++ Libraries/LibHTTP/HTTP.cpp | 7 + Libraries/LibHTTP/HTTP.h | 3 + Libraries/LibHTTP/Header.cpp | 2 +- Libraries/LibWeb/Fetch/Fetching/Fetching.cpp | 11 +- Libraries/LibWeb/Loader/ResourceLoader.cpp | 19 ++ Libraries/LibWeb/Page/Page.h | 3 + Libraries/LibWeb/Worker/WebWorkerClient.ipc | 3 + Libraries/LibWebView/Application.cpp | 3 + Libraries/LibWebView/Application.h | 2 + Libraries/LibWebView/CMakeLists.txt | 1 + Libraries/LibWebView/Forward.h | 1 + Libraries/LibWebView/HSTSStore.cpp | 205 +++++++++++++++++++ Libraries/LibWebView/HSTSStore.h | 81 ++++++++ Libraries/LibWebView/WebContentClient.cpp | 11 + Libraries/LibWebView/WebContentClient.h | 2 + Libraries/LibWebView/WebWorkerClient.cpp | 11 + Libraries/LibWebView/WebWorkerClient.h | 3 + Services/WebContent/PageClient.cpp | 15 ++ Services/WebContent/PageClient.h | 2 + Services/WebContent/WebContentClient.ipc | 4 + Services/WebWorker/PageHost.cpp | 10 + Services/WebWorker/PageHost.h | 2 + Tests/LibHTTP/CMakeLists.txt | 1 + Tests/LibHTTP/TestHSTSPolicy.cpp | 149 ++++++++++++++ 27 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.cpp create mode 100644 Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.h create mode 100644 Libraries/LibWebView/HSTSStore.cpp create mode 100644 Libraries/LibWebView/HSTSStore.h create mode 100644 Tests/LibHTTP/TestHSTSPolicy.cpp diff --git a/Libraries/LibHTTP/CMakeLists.txt b/Libraries/LibHTTP/CMakeLists.txt index 334550a2d9..3a4f1c0eaa 100644 --- a/Libraries/LibHTTP/CMakeLists.txt +++ b/Libraries/LibHTTP/CMakeLists.txt @@ -7,6 +7,7 @@ set(SOURCES Cache/Utilities.cpp Cookie/Cookie.cpp Cookie/ParsedCookie.cpp + HSTS/ParsedHSTSPolicy.cpp Header.cpp HeaderList.cpp HTTP.cpp diff --git a/Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.cpp b/Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.cpp new file mode 100644 index 0000000000..b7299f3363 --- /dev/null +++ b/Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.cpp @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include + +namespace HTTP::HSTS { + +// https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6 +static Optional consume_quoted_string(GenericLexer& lexer) +{ + if (lexer.is_eof() || lexer.peek() != '"') + return {}; + lexer.ignore(1); + + StringBuilder value; + while (!lexer.is_eof()) { + char ch = lexer.consume(); + if (ch == '"') { + // RFC 7230 quoted-string permits obs-text = %x80-FF, so the collected bytes are not + // guaranteed to be valid UTF-8. Treat a malformed encoding as a parse error rather + // than crashing. + auto result = value.to_string(); + if (result.is_error()) + return {}; + return result.release_value(); + } + if (ch == '\\') { + if (lexer.is_eof()) + return {}; + value.append(lexer.consume()); + continue; + } + value.append(ch); + } + return {}; +} + +// https://www.rfc-editor.org/rfc/rfc6797#section-6.1 +// Strict-Transport-Security = "Strict-Transport-Security" ":" +// [ directive ] *( ";" [ directive ] ) +// directive = directive-name [ "=" directive-value ] +// directive-name = token +// directive-value = token | quoted-string +Optional parse_header(StringView header_value) +{ + GenericLexer lexer(header_value); + + Optional max_age; + bool include_sub_domains = false; + + // 1. The order of appearance of directives is not significant. + while (!lexer.is_eof()) { + lexer.ignore_while(is_http_tab_or_space); + + if (lexer.is_eof()) + break; + + // 3. Directive names are case-insensitive. + auto directive_name = lexer.consume_until([](char ch) { + return ch == '=' || ch == ';'; + }); + directive_name = directive_name.trim(HTTP_TAB_OR_SPACE, TrimMode::Both); + + // directive-name = token. Empty is only valid when no value follows (i.e. successive ';' + // separators or trailing ';'); a non-empty name must be a valid token. + if (directive_name.is_empty()) { + if (!lexer.is_eof() && lexer.peek() == '=') + return {}; + } else if (!is_token(directive_name)) { + return {}; + } + + Optional directive_value; + if (!lexer.is_eof() && lexer.peek() == '=') { + lexer.ignore(1); + lexer.ignore_while(is_http_tab_or_space); + + if (!lexer.is_eof() && lexer.peek() == '"') { + directive_value = consume_quoted_string(lexer); + if (!directive_value.has_value()) + return {}; + + // NB: A quoted-string directive-value must end at a directive boundary; trailing + // token characters are malformed. + lexer.ignore_while(is_http_tab_or_space); + if (!lexer.is_eof() && lexer.peek() != ';') + return {}; + } else { + auto token_value = lexer.consume_until(';').trim(HTTP_TAB_OR_SPACE, TrimMode::Both); + if (!is_token(token_value)) + return {}; + // NB: is_token guarantees ASCII-only bytes, which are trivially valid UTF-8. + directive_value = MUST(String::from_utf8(token_value)); + } + } + + if (directive_name.equals_ignoring_ascii_case("max-age"sv)) { + // https://www.rfc-editor.org/rfc/rfc6797#section-6.1.1 + // The REQUIRED "max-age" directive specifies the number of seconds, after the reception of the STS header + // field, during which the UA regards the host (from whom the message was received) as a Known HSTS Host. + // max-age-value = delta-seconds + // delta-seconds = 1*DIGIT + if (!directive_value.has_value()) + return {}; + + auto parsed = directive_value->bytes_as_string_view().to_number(); + if (!parsed.has_value()) + return {}; + + // 2. All directives MUST appear only once in an STS header field. + if (max_age.has_value()) + return {}; + + max_age = parsed.value(); + } else if (directive_name.equals_ignoring_ascii_case("includeSubDomains"sv)) { + // https://www.rfc-editor.org/rfc/rfc6797#section-6.1.2 + // The OPTIONAL "includeSubDomains" directive is a valueless directive which, if present (i.e., it is + // "asserted"), signals the UA that the HSTS Policy applies to this HSTS Host as well as any subdomains + // of the host's domain name. + + // NB: An asserted value on a valueless directive (e.g. "includeSubDomains=anything") is malformed. + if (directive_value.has_value()) + return {}; + + // 2. All directives MUST appear only once in an STS header field. + if (include_sub_domains) + return {}; + + include_sub_domains = true; + } + // 5. If an STS header field contains directive(s) not recognized by the UA, the UA MUST ignore the + // unrecognized directives, and if the STS header field otherwise satisfies the above requirements + // (1 through 4), the UA MUST process the recognized directives. + + if (!lexer.is_eof() && lexer.peek() == ';') + lexer.ignore(1); + } + + // 4. UAs MUST ignore any STS header field containing directives, or other header field value data, + // that does not conform to the syntax defined in this specification. + // The max-age directive is REQUIRED. + if (!max_age.has_value()) + return {}; + + // NB: Clamp delta-seconds to i64::max so AK::Duration::from_seconds cannot overflow. + auto clamped_seconds = AK::min(*max_age, NumericLimits::max()); + return ParsedHSTSPolicy { + .max_age = AK::Duration::from_seconds(static_cast(clamped_seconds)), + .include_sub_domains = include_sub_domains, + }; +} + +} + +template<> +ErrorOr IPC::encode(Encoder& encoder, HTTP::HSTS::ParsedHSTSPolicy const& policy) +{ + TRY(encoder.encode(policy.max_age)); + TRY(encoder.encode(policy.include_sub_domains)); + return {}; +} + +template<> +ErrorOr IPC::decode(Decoder& decoder) +{ + auto max_age = TRY(decoder.decode()); + auto include_sub_domains = TRY(decoder.decode()); + return HTTP::HSTS::ParsedHSTSPolicy { max_age, include_sub_domains }; +} diff --git a/Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.h b/Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.h new file mode 100644 index 0000000000..23ae4f7235 --- /dev/null +++ b/Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.h @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include + +namespace HTTP::HSTS { + +// https://www.rfc-editor.org/rfc/rfc6797#section-6.1 +struct ParsedHSTSPolicy { + // https://www.rfc-editor.org/rfc/rfc6797#section-6.1.1 + AK::Duration max_age { AK::Duration::zero() }; + + // https://www.rfc-editor.org/rfc/rfc6797#section-6.1.2 + bool include_sub_domains { false }; +}; + +Optional parse_header(StringView header_value); + +} + +namespace IPC { + +template<> +ErrorOr encode(Encoder&, HTTP::HSTS::ParsedHSTSPolicy const&); + +template<> +ErrorOr decode(Decoder&); + +} diff --git a/Libraries/LibHTTP/HTTP.cpp b/Libraries/LibHTTP/HTTP.cpp index 941eaa9e6b..5455a5932c 100644 --- a/Libraries/LibHTTP/HTTP.cpp +++ b/Libraries/LibHTTP/HTTP.cpp @@ -4,12 +4,19 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include #include #include namespace HTTP { +// https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6 +bool is_token(StringView value) +{ + return !value.is_empty() && all_of(value, is_http_token_code_point); +} + // https://fetch.spec.whatwg.org/#collect-an-http-quoted-string String collect_an_http_quoted_string(GenericLexer& lexer, HttpQuotedStringExtractValue extract_value) { diff --git a/Libraries/LibHTTP/HTTP.h b/Libraries/LibHTTP/HTTP.h index 86599c53ab..c413df041b 100644 --- a/Libraries/LibHTTP/HTTP.h +++ b/Libraries/LibHTTP/HTTP.h @@ -69,6 +69,9 @@ constexpr bool is_http_token_code_point(u32 code_point) } } +// https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6 +[[nodiscard]] bool is_token(StringView); + enum class HttpQuotedStringExtractValue { No, Yes, diff --git a/Libraries/LibHTTP/Header.cpp b/Libraries/LibHTTP/Header.cpp index 7507d189d3..521162a4e1 100644 --- a/Libraries/LibHTTP/Header.cpp +++ b/Libraries/LibHTTP/Header.cpp @@ -83,7 +83,7 @@ Optional> Header::extract_header_values() const bool is_header_name(StringView header_name) { // A header name is a byte sequence that matches the field-name token production. - return !header_name.is_empty() && all_of(header_name, is_http_token_code_point); + return is_token(header_name); } // https://fetch.spec.whatwg.org/#header-value diff --git a/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp b/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp index eeefb0caee..380af54c89 100644 --- a/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp +++ b/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp @@ -453,11 +453,12 @@ GC::Ptr main_fetch(JS::Realm& realm, Infrastructure::FetchParam request->current_url().scheme() == "http"sv // - request’s current URL’s host is a domain && request->current_url().host().has_value() && request->current_url().host()->is_domain() - // FIXME: - Matching request’s current URL’s host per Known HSTS Host Domain Name Matching results in either a - // superdomain match with an asserted includeSubDomains directive or a congruent match (with or without an - // asserted includeSubDomains directive) [HSTS]; or DNS resolution for the request finds a matching HTTPS RR - // per section 9.5 of [SVCB]. - && false) { + // - Matching request’s current URL’s host per Known HSTS Host Domain Name Matching results in either a + // superdomain match with an asserted includeSubDomains directive or a congruent match (with or without an + // asserted includeSubDomains directive) [HSTS] + && Bindings::principal_host_defined_page(realm).client().page_did_is_known_hsts_host(request->current_url().host()->get()) + // FIXME: or DNS resolution for the request finds a matching HTTPS RR per section 9.5 of [SVCB]. + ) { request->current_url().set_scheme("https"_string); } diff --git a/Libraries/LibWeb/Loader/ResourceLoader.cpp b/Libraries/LibWeb/Loader/ResourceLoader.cpp index 7e0e0676b0..eaecf3e1a9 100644 --- a/Libraries/LibWeb/Loader/ResourceLoader.cpp +++ b/Libraries/LibWeb/Loader/ResourceLoader.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -502,6 +503,24 @@ void ResourceLoader::handle_network_response_headers(LoadRequest const& request, if (!request.page()) return; + // https://www.rfc-editor.org/rfc/rfc6797#section-8.1 + // If an HTTP response, received over a secure transport, includes an STS header field, conforming to the grammar + // specified in Section 6.1, and there are no underlying secure transport errors or warnings, the UA MUST either + // note the host as a Known HSTS Host or update the UA's cached information for the Known HSTS Host. + if (request.url().has_value() && request.url()->scheme() == "https"sv + && request.url()->host().has_value() && request.url()->host()->is_domain()) { + // If a UA receives more than one STS header field in an HTTP response message over secure transport, then + // the UA MUST process only the first such header field. + for (auto const& [header, value] : response_headers) { + if (header.equals_ignoring_ascii_case("Strict-Transport-Security"sv)) { + auto parsed_policy = HTTP::HSTS::parse_header(value); + if (parsed_policy.has_value()) + request.page()->client().page_did_store_hsts_policy(request.url()->host()->get(), parsed_policy.value()); + break; + } + } + } + if (request.include_credentials() == HTTP::Cookie::IncludeCredentials::Yes) { // From https://fetch.spec.whatwg.org/#concept-http-network-fetch: // 15. If includeCredentials is true, then the user agent should parse and store response diff --git a/Libraries/LibWeb/Page/Page.h b/Libraries/LibWeb/Page/Page.h index 5b96fbd954..a6233778ae 100644 --- a/Libraries/LibWeb/Page/Page.h +++ b/Libraries/LibWeb/Page/Page.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -480,6 +481,8 @@ public: virtual void page_did_set_cookie(URL::URL const&, HTTP::Cookie::ParsedCookie const&, HTTP::Cookie::Source) { } virtual void page_did_update_cookie(HTTP::Cookie::Cookie const&) { } virtual void page_did_expire_cookies_with_time_offset(AK::Duration) { } + virtual void page_did_store_hsts_policy(String const&, HTTP::HSTS::ParsedHSTSPolicy const&) { } + virtual bool page_did_is_known_hsts_host(String const&) { return false; } virtual Optional page_did_request_storage_item([[maybe_unused]] Web::StorageAPI::StorageEndpointType storage_endpoint, [[maybe_unused]] String const& storage_key, [[maybe_unused]] String const& bottle_key) { return {}; } virtual WebView::StorageSetResult page_did_set_storage_item([[maybe_unused]] Web::StorageAPI::StorageEndpointType storage_endpoint, [[maybe_unused]] String const& storage_key, [[maybe_unused]] String const& bottle_key, [[maybe_unused]] String const& value) { return WebView::StorageOperationError::QuotaExceededError; } virtual void page_did_remove_storage_item([[maybe_unused]] Web::StorageAPI::StorageEndpointType storage_endpoint, [[maybe_unused]] String const& storage_key, [[maybe_unused]] String const& bottle_key) { } diff --git a/Libraries/LibWeb/Worker/WebWorkerClient.ipc b/Libraries/LibWeb/Worker/WebWorkerClient.ipc index 4473492da2..241f6ab792 100644 --- a/Libraries/LibWeb/Worker/WebWorkerClient.ipc +++ b/Libraries/LibWeb/Worker/WebWorkerClient.ipc @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -12,6 +13,8 @@ endpoint WebWorkerClient { did_report_worker_exception(String message, String filename, u32 lineno, u32 colno) =| did_request_cookie(URL::URL url, HTTP::Cookie::Source source) => (HTTP::Cookie::VersionedCookie cookie) did_request_file(ByteString path, i32 request_id) =| + did_store_hsts_policy(String domain, HTTP::HSTS::ParsedHSTSPolicy policy) =| + did_is_known_hsts_host(String domain) => (bool result) did_post_broadcast_channel_message(Web::HTML::BroadcastChannelMessage message) =| start_worker_agent(Web::HTML::WorkerAgentStartRequest request) => (Web::HTML::WorkerAgentId agent_id) close_worker_agent(Web::HTML::WorkerAgentId agent_id, Web::HTML::WorkerAgentOwnerToken owner_token) =| diff --git a/Libraries/LibWebView/Application.cpp b/Libraries/LibWebView/Application.cpp index 56c685b601..57c2f54c28 100644 --- a/Libraries/LibWebView/Application.cpp +++ b/Libraries/LibWebView/Application.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -710,12 +711,14 @@ ErrorOr Application::launch_services() m_cookie_jar = TRY(CookieJar::create(*m_database)); m_history_store = TRY(HistoryStore::create(*m_history_database)); + m_hsts_store = TRY(HSTSStore::create(*m_database)); m_storage_jar = TRY(StorageJar::create(*m_database)); } else { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] SQL history is disabled, disabling browsing history"); m_cookie_jar = CookieJar::create(); m_history_store = HistoryStore::create_disabled(); + m_hsts_store = HSTSStore::create(); m_storage_jar = StorageJar::create(); } diff --git a/Libraries/LibWebView/Application.h b/Libraries/LibWebView/Application.h index 10c7d88611..5f5f3d111f 100644 --- a/Libraries/LibWebView/Application.h +++ b/Libraries/LibWebView/Application.h @@ -82,6 +82,7 @@ public: virtual void show_bookmark_context_menu(Gfx::IntPoint, Optional, [[maybe_unused]] Optional target_folder_id) { } static CookieJar& cookie_jar() { return *the().m_cookie_jar; } + static HSTSStore& hsts_store() { return *the().m_hsts_store; } static StorageJar& storage_jar() { return *the().m_storage_jar; } static ProcessManager& process_manager() { return *the().m_process_manager; } @@ -330,6 +331,7 @@ private: RefPtr m_database; RefPtr m_history_database; OwnPtr m_cookie_jar; + OwnPtr m_hsts_store; OwnPtr m_storage_jar; OwnPtr m_time_zone_watcher; diff --git a/Libraries/LibWebView/CMakeLists.txt b/Libraries/LibWebView/CMakeLists.txt index 535639c955..21c46c0d3c 100644 --- a/Libraries/LibWebView/CMakeLists.txt +++ b/Libraries/LibWebView/CMakeLists.txt @@ -11,6 +11,7 @@ set(SOURCES HeadlessWebView.cpp HistoryStore.cpp HelperProcess.cpp + HSTSStore.cpp Menu.cpp Mutation.cpp Plugins/ImageCodecPlugin.cpp diff --git a/Libraries/LibWebView/Forward.h b/Libraries/LibWebView/Forward.h index 86587663a3..edadde0adf 100644 --- a/Libraries/LibWebView/Forward.h +++ b/Libraries/LibWebView/Forward.h @@ -19,6 +19,7 @@ class BookmarkStore; class CompositorClient; class CookieJar; class HistoryStore; +class HSTSStore; class Menu; class OutOfProcessWebView; class ProcessManager; diff --git a/Libraries/LibWebView/HSTSStore.cpp b/Libraries/LibWebView/HSTSStore.cpp new file mode 100644 index 0000000000..d074fcc28e --- /dev/null +++ b/Libraries/LibWebView/HSTSStore.cpp @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include + +namespace WebView { + +static constexpr auto DATABASE_SYNCHRONIZATION_TIMER = AK::Duration::from_seconds(30); + +ErrorOr> HSTSStore::create(Database::Database& database) +{ + Statements statements {}; + + auto create_table = TRY(database.prepare_statement("CREATE TABLE IF NOT EXISTS HSTSPolicies (" + " domain TEXT PRIMARY KEY," + " expiry_time INTEGER NOT NULL," + " include_sub_domains BOOLEAN NOT NULL," + " last_observed_time INTEGER NOT NULL" + ");"sv)); + database.execute_statement(create_table, {}); + + statements.insert_policy = TRY(database.prepare_statement("INSERT OR REPLACE INTO HSTSPolicies VALUES (?, ?, ?, ?);"sv)); + statements.delete_expired = TRY(database.prepare_statement("DELETE FROM HSTSPolicies WHERE (expiry_time < ?);"sv)); + statements.select_all_policies = TRY(database.prepare_statement("SELECT * FROM HSTSPolicies;"sv)); + + return adopt_own(*new HSTSStore { PersistedStorage { database, statements } }); +} + +NonnullOwnPtr HSTSStore::create() +{ + return adopt_own(*new HSTSStore { OptionalNone {} }); +} + +HSTSStore::HSTSStore(Optional persisted_storage) + : m_persisted_storage(move(persisted_storage)) +{ + if (!m_persisted_storage.has_value()) + return; + + auto policies = m_persisted_storage->select_all_policies(); + m_transient_storage.set_policies(move(policies)); + + m_persisted_storage->synchronization_timer = Core::Timer::create_repeating( + static_cast(DATABASE_SYNCHRONIZATION_TIMER.to_milliseconds()), + [this]() { + for (auto const& it : m_transient_storage.take_dirty_policies()) + m_persisted_storage->insert_policy(it.key, it.value); + + auto now = m_transient_storage.purge_expired_policies(); + m_persisted_storage->database.execute_statement(m_persisted_storage->statements.delete_expired, {}, now.milliseconds_since_epoch()); + }); + m_persisted_storage->synchronization_timer->start(); +} + +HSTSStore::~HSTSStore() +{ + if (!m_persisted_storage.has_value()) + return; + + m_persisted_storage->synchronization_timer->stop(); + m_persisted_storage->synchronization_timer->on_timeout(); +} + +// https://www.rfc-editor.org/rfc/rfc6797#section-8.1 +void HSTSStore::store_policy(String const& domain, HTTP::HSTS::ParsedHSTSPolicy const& policy) +{ + // NB: The caller is responsible for ensuring this is only called for responses received over + // secure transport, and that the host is a domain (not an IP address). + + auto now = UnixDateTime::now(); + StoredPolicy stored_policy; + + // A max-age value of zero (i.e., "max-age=0") signals the UA to cease regarding the host as a Known HSTS Host, + // including the includeSubDomains directive (if asserted for that HSTS Host). + if (policy.max_age == AK::Duration::zero()) { + stored_policy = StoredPolicy { + .expiry = UnixDateTime::earliest(), + .include_sub_domains = false, + .last_observed_time = now, + }; + } else { + stored_policy = StoredPolicy { + .expiry = now + policy.max_age, + .include_sub_domains = policy.include_sub_domains, + .last_observed_time = now, + }; + } + + m_transient_storage.set_policy(domain.to_ascii_lowercase(), stored_policy); + m_transient_storage.purge_expired_policies(); +} + +// https://www.rfc-editor.org/rfc/rfc6797#section-8.2 +bool HSTSStore::is_known_hsts_host(StringView domain) +{ + m_transient_storage.purge_expired_policies(); + + // Compare the given domain name with the domain name of each of the UA's unexpired Known HSTS Hosts. + // For each Known HSTS Host's domain name, the comparison is done with the given domain name label-by-label + // (comparing only labels) using an ASCII case-insensitive comparison beginning with the rightmost label, + // and continuing right-to-left. + auto canonical = domain.to_ascii_lowercase_string(); + + // Congruent Match: If a label-for-label match between a Known HSTS Host's domain name and the given domain name + // is found -- i.e., there are no further labels to compare -- then the given domain name congruently matches + // this Known HSTS Host. + if (auto policy = m_transient_storage.get_policy(canonical); policy.has_value()) { + m_transient_storage.update_last_observed_time(canonical); + return true; + } + + // Superdomain Match: If a label-for-label match between an entire Known HSTS Host's domain name and a right-hand + // portion of the given domain name is found, then this Known HSTS Host's domain name is a superdomain match for + // the given domain name. There could be multiple superdomain matches for a given domain name. + auto remaining = canonical.bytes_as_string_view(); + while (true) { + auto dot = remaining.find('.'); + if (!dot.has_value()) + break; + remaining = remaining.substring_view(*dot + 1); + if (auto policy = m_transient_storage.get_policy(remaining); policy.has_value() && policy->include_sub_domains) { + m_transient_storage.update_last_observed_time(remaining); + return true; + } + } + + // Otherwise, if no matches are found, the given domain name does not represent a Known HSTS Host. + return false; +} + +void HSTSStore::TransientStorage::set_policies(Policies policies) +{ + m_policies = move(policies); + purge_expired_policies(); +} + +void HSTSStore::TransientStorage::set_policy(String const& domain, StoredPolicy const& policy) +{ + auto now = UnixDateTime::now(); + if (policy.expiry < now && !m_policies.contains(domain)) + return; + + m_policies.set(domain, policy); + m_dirty_policies.set(domain, policy); +} + +Optional HSTSStore::TransientStorage::get_policy(StringView domain) const +{ + auto it = m_policies.find(domain); + if (it == m_policies.end()) + return {}; + return it->value; +} + +void HSTSStore::TransientStorage::update_last_observed_time(StringView domain) +{ + auto it = m_policies.find(domain); + if (it == m_policies.end()) + return; + + it->value.last_observed_time = UnixDateTime::now(); + m_dirty_policies.set(it->key, it->value); +} + +UnixDateTime HSTSStore::TransientStorage::purge_expired_policies() +{ + // A Known HSTS Host is "expired" if its cache entry has an expiry date + // in the past. The UA MUST evict all expired Known HSTS Hosts from its + // cache if, at any time, an expired Known HSTS Host exists in the + // cache. + auto now = UnixDateTime::now(); + auto is_expired = [&](auto const&, auto const& policy) { return policy.expiry < now; }; + m_policies.remove_all_matching(is_expired); + return now; +} + +void HSTSStore::PersistedStorage::insert_policy(String const& domain, StoredPolicy const& policy) +{ + database.execute_statement(statements.insert_policy, {}, domain, policy.expiry, policy.include_sub_domains, policy.last_observed_time); +} + +HSTSStore::TransientStorage::Policies HSTSStore::PersistedStorage::select_all_policies() +{ + TransientStorage::Policies policies; + + database.execute_statement(statements.select_all_policies, [&](auto row) { + auto domain = database.result_column(row, 0); + StoredPolicy stored_policy { + .expiry = database.result_column(row, 1), + .include_sub_domains = database.result_column(row, 2), + .last_observed_time = database.result_column(row, 3), + }; + policies.set(move(domain), stored_policy); + }); + + return policies; +} + +} diff --git a/Libraries/LibWebView/HSTSStore.h b/Libraries/LibWebView/HSTSStore.h new file mode 100644 index 0000000000..ed3e316b72 --- /dev/null +++ b/Libraries/LibWebView/HSTSStore.h @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace WebView { + +class WEBVIEW_API HSTSStore { +public: + static ErrorOr> create(Database::Database&); + static NonnullOwnPtr create(); + + ~HSTSStore(); + + // https://www.rfc-editor.org/rfc/rfc6797#section-8.1 + void store_policy(String const& domain, HTTP::HSTS::ParsedHSTSPolicy const& policy); + + // https://www.rfc-editor.org/rfc/rfc6797#section-8.2 + bool is_known_hsts_host(StringView domain); + +private: + struct StoredPolicy { + UnixDateTime expiry; + bool include_sub_domains { false }; + UnixDateTime last_observed_time; + }; + + struct Statements { + Database::StatementID insert_policy { 0 }; + Database::StatementID delete_expired { 0 }; + Database::StatementID select_all_policies { 0 }; + }; + + class WEBVIEW_API TransientStorage { + public: + using Policies = HashMap; + + void set_policies(Policies); + void set_policy(String const& domain, StoredPolicy const& policy); + Optional get_policy(StringView domain) const; + void update_last_observed_time(StringView domain); + + UnixDateTime purge_expired_policies(); + + auto take_dirty_policies() { return move(m_dirty_policies); } + + private: + Policies m_policies; + Policies m_dirty_policies; + }; + + struct WEBVIEW_API PersistedStorage { + void insert_policy(String const& domain, StoredPolicy const& policy); + TransientStorage::Policies select_all_policies(); + + Database::Database& database; + Statements statements; + RefPtr synchronization_timer {}; + }; + + explicit HSTSStore(Optional); + + AK_MAKE_NONCOPYABLE(HSTSStore); + AK_MAKE_NONMOVABLE(HSTSStore); + + Optional m_persisted_storage; + TransientStorage m_transient_storage; +}; + +} diff --git a/Libraries/LibWebView/WebContentClient.cpp b/Libraries/LibWebView/WebContentClient.cpp index d259c5a31a..3825744714 100644 --- a/Libraries/LibWebView/WebContentClient.cpp +++ b/Libraries/LibWebView/WebContentClient.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -969,6 +970,16 @@ void WebContentClient::did_expire_cookies_with_time_offset(AK::Duration offset) Application::cookie_jar().expire_cookies_with_time_offset(offset); } +void WebContentClient::did_store_hsts_policy(String domain, HTTP::HSTS::ParsedHSTSPolicy policy) +{ + Application::hsts_store().store_policy(domain, policy); +} + +Messages::WebContentClient::DidIsKnownHstsHostResponse WebContentClient::did_is_known_hsts_host(String domain) +{ + return Application::hsts_store().is_known_hsts_host(domain); +} + Messages::WebContentClient::DidRequestStorageItemResponse WebContentClient::did_request_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String storage_key, String bottle_key) { return Application::storage_jar().get_item(storage_endpoint, storage_key, bottle_key); diff --git a/Libraries/LibWebView/WebContentClient.h b/Libraries/LibWebView/WebContentClient.h index e80e94a660..b89855b5c2 100644 --- a/Libraries/LibWebView/WebContentClient.h +++ b/Libraries/LibWebView/WebContentClient.h @@ -153,6 +153,8 @@ private: virtual void did_set_cookie(URL::URL, HTTP::Cookie::ParsedCookie, HTTP::Cookie::Source) override; virtual void did_update_cookie(HTTP::Cookie::Cookie) override; virtual void did_expire_cookies_with_time_offset(AK::Duration) override; + virtual void did_store_hsts_policy(String, HTTP::HSTS::ParsedHSTSPolicy) override; + virtual Messages::WebContentClient::DidIsKnownHstsHostResponse did_is_known_hsts_host(String) override; virtual Messages::WebContentClient::DidRequestStorageItemResponse did_request_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String storage_key, String bottle_key) override; virtual Messages::WebContentClient::DidSetStorageItemResponse did_set_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String storage_key, String bottle_key, String value) override; virtual void did_remove_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String storage_key, String bottle_key) override; diff --git a/Libraries/LibWebView/WebWorkerClient.cpp b/Libraries/LibWebView/WebWorkerClient.cpp index 6a9978c1bd..021fe51b8b 100644 --- a/Libraries/LibWebView/WebWorkerClient.cpp +++ b/Libraries/LibWebView/WebWorkerClient.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -51,6 +52,16 @@ void WebWorkerClient::did_request_file(ByteString path, i32 request_id) WorkerProcessManager::the().worker_did_request_file(m_agent_id, move(path), request_id); } +void WebWorkerClient::did_store_hsts_policy(String domain, HTTP::HSTS::ParsedHSTSPolicy policy) +{ + Application::hsts_store().store_policy(domain, policy); +} + +Messages::WebWorkerClient::DidIsKnownHstsHostResponse WebWorkerClient::did_is_known_hsts_host(String domain) +{ + return Application::hsts_store().is_known_hsts_host(domain); +} + void WebWorkerClient::did_post_broadcast_channel_message(Web::HTML::BroadcastChannelMessage message) { WorkerProcessManager::the().worker_did_post_broadcast_channel_message(m_agent_id, move(message)); diff --git a/Libraries/LibWebView/WebWorkerClient.h b/Libraries/LibWebView/WebWorkerClient.h index b30f127b97..0013b7ed27 100644 --- a/Libraries/LibWebView/WebWorkerClient.h +++ b/Libraries/LibWebView/WebWorkerClient.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,8 @@ public: virtual void did_report_worker_exception(String message, String filename, u32 lineno, u32 colno) override; virtual Messages::WebWorkerClient::DidRequestCookieResponse did_request_cookie(URL::URL, HTTP::Cookie::Source) override; virtual void did_request_file(ByteString path, i32 request_id) override; + virtual void did_store_hsts_policy(String domain, HTTP::HSTS::ParsedHSTSPolicy policy) override; + virtual Messages::WebWorkerClient::DidIsKnownHstsHostResponse did_is_known_hsts_host(String domain) override; virtual void did_post_broadcast_channel_message(Web::HTML::BroadcastChannelMessage) override; virtual Messages::WebWorkerClient::StartWorkerAgentResponse start_worker_agent(Web::HTML::WorkerAgentStartRequest request) override; virtual void close_worker_agent(Web::HTML::WorkerAgentId, Web::HTML::WorkerAgentOwnerToken) override; diff --git a/Services/WebContent/PageClient.cpp b/Services/WebContent/PageClient.cpp index e532226290..52ef9b3468 100644 --- a/Services/WebContent/PageClient.cpp +++ b/Services/WebContent/PageClient.cpp @@ -627,6 +627,21 @@ void PageClient::page_did_expire_cookies_with_time_offset(AK::Duration offset) document->reset_cookie_version(); } +void PageClient::page_did_store_hsts_policy(String const& domain, HTTP::HSTS::ParsedHSTSPolicy const& policy) +{ + client().async_did_store_hsts_policy(domain, policy); +} + +bool PageClient::page_did_is_known_hsts_host(String const& domain) +{ + auto response = client().send_sync_but_allow_failure(domain); + if (!response) { + dbgln("WebContent client disconnected during DidIsKnownHstsHost. Exiting peacefully."); + exit(0); + } + return response->result(); +} + Optional PageClient::page_did_request_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String const& storage_key, String const& bottle_key) { auto response = client().send_sync_but_allow_failure(storage_endpoint, storage_key, bottle_key); diff --git a/Services/WebContent/PageClient.h b/Services/WebContent/PageClient.h index badd9c436e..f04c9ad000 100644 --- a/Services/WebContent/PageClient.h +++ b/Services/WebContent/PageClient.h @@ -177,6 +177,8 @@ private: virtual void page_did_set_cookie(URL::URL const&, HTTP::Cookie::ParsedCookie const&, HTTP::Cookie::Source) override; virtual void page_did_update_cookie(HTTP::Cookie::Cookie const&) override; virtual void page_did_expire_cookies_with_time_offset(AK::Duration) override; + virtual void page_did_store_hsts_policy(String const&, HTTP::HSTS::ParsedHSTSPolicy const&) override; + virtual bool page_did_is_known_hsts_host(String const&) override; virtual Optional page_did_request_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String const& storage_key, String const& bottle_key) override; virtual WebView::StorageSetResult page_did_set_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String const& storage_key, String const& bottle_key, String const& value) override; virtual void page_did_remove_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String const& storage_key, String const& bottle_key) override; diff --git a/Services/WebContent/WebContentClient.ipc b/Services/WebContent/WebContentClient.ipc index 8ef79aa433..26b0ee8311 100644 --- a/Services/WebContent/WebContentClient.ipc +++ b/Services/WebContent/WebContentClient.ipc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -95,6 +96,9 @@ endpoint WebContentClient did_update_cookie(HTTP::Cookie::Cookie cookie) =| did_expire_cookies_with_time_offset(AK::Duration offset) =| + did_store_hsts_policy(String domain, HTTP::HSTS::ParsedHSTSPolicy policy) =| + did_is_known_hsts_host(String domain) => (bool result) + did_request_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String storage_key, String bottle_key) => (Optional value) did_set_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String storage_key, String bottle_key, String value) => (WebView::StorageSetResult result) did_remove_storage_item(Web::StorageAPI::StorageEndpointType storage_endpoint, String storage_key, String bottle_key) => () diff --git a/Services/WebWorker/PageHost.cpp b/Services/WebWorker/PageHost.cpp index 99e6cb7d58..517e45e364 100644 --- a/Services/WebWorker/PageHost.cpp +++ b/Services/WebWorker/PageHost.cpp @@ -93,6 +93,16 @@ HTTP::Cookie::VersionedCookie PageHost::page_did_request_cookie(URL::URL const& return m_client.did_request_cookie(url, source); } +void PageHost::page_did_store_hsts_policy(String const& domain, HTTP::HSTS::ParsedHSTSPolicy const& policy) +{ + m_client.async_did_store_hsts_policy(domain, policy); +} + +bool PageHost::page_did_is_known_hsts_host(String const& domain) +{ + return m_client.did_is_known_hsts_host(domain); +} + void PageHost::page_did_report_worker_exception(String const& message, String const& filename, u32 lineno, u32 colno) { m_client.async_did_report_worker_exception(message, filename, lineno, colno); diff --git a/Services/WebWorker/PageHost.h b/Services/WebWorker/PageHost.h index 65dba01982..c6ad190646 100644 --- a/Services/WebWorker/PageHost.h +++ b/Services/WebWorker/PageHost.h @@ -37,6 +37,8 @@ public: virtual Web::CSS::PreferredMotion preferred_motion() const override; virtual size_t screen_count() const override { return 1; } virtual HTTP::Cookie::VersionedCookie page_did_request_cookie(URL::URL const&, HTTP::Cookie::Source) override; + virtual void page_did_store_hsts_policy(String const&, HTTP::HSTS::ParsedHSTSPolicy const&) override; + virtual bool page_did_is_known_hsts_host(String const&) override; virtual void page_did_report_worker_exception(String const& message, String const& filename, u32 lineno, u32 colno) override; virtual void page_did_post_broadcast_channel_message(Web::HTML::BroadcastChannelMessage const& message) override; virtual void request_file(Web::FileRequest) override; diff --git a/Tests/LibHTTP/CMakeLists.txt b/Tests/LibHTTP/CMakeLists.txt index 671cc96561..fd6b3adbc6 100644 --- a/Tests/LibHTTP/CMakeLists.txt +++ b/Tests/LibHTTP/CMakeLists.txt @@ -1,5 +1,6 @@ set(TEST_SOURCES TestCacheUtilities.cpp + TestHSTSPolicy.cpp TestHTTPUtils.cpp ) diff --git a/Tests/LibHTTP/TestHSTSPolicy.cpp b/Tests/LibHTTP/TestHSTSPolicy.cpp new file mode 100644 index 0000000000..a92b7bbe15 --- /dev/null +++ b/Tests/LibHTTP/TestHSTSPolicy.cpp @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +#include + +TEST_CASE(parses_max_age_token) +{ + auto policy = HTTP::HSTS::parse_header("max-age=31536000"sv); + EXPECT(policy.has_value()); + EXPECT_EQ(policy->max_age, AK::Duration::from_seconds(31536000)); + EXPECT(!policy->include_sub_domains); +} + +TEST_CASE(parses_max_age_quoted) +{ + auto policy = HTTP::HSTS::parse_header("max-age=\"31536000\""sv); + EXPECT(policy.has_value()); + EXPECT_EQ(policy->max_age, AK::Duration::from_seconds(31536000)); +} + +TEST_CASE(parses_include_sub_domains) +{ + auto policy = HTTP::HSTS::parse_header("max-age=100; includeSubDomains"sv); + EXPECT(policy.has_value()); + EXPECT_EQ(policy->max_age, AK::Duration::from_seconds(100)); + EXPECT(policy->include_sub_domains); +} + +TEST_CASE(directive_order_does_not_matter) +{ + auto policy = HTTP::HSTS::parse_header("includeSubDomains; max-age=100"sv); + EXPECT(policy.has_value()); + EXPECT_EQ(policy->max_age, AK::Duration::from_seconds(100)); + EXPECT(policy->include_sub_domains); +} + +TEST_CASE(directive_names_are_case_insensitive) +{ + auto policy = HTTP::HSTS::parse_header("MAX-AGE=100; INCLUDESUBDOMAINS"sv); + EXPECT(policy.has_value()); + EXPECT_EQ(policy->max_age, AK::Duration::from_seconds(100)); + EXPECT(policy->include_sub_domains); +} + +TEST_CASE(unknown_directives_are_ignored) +{ + auto policy = HTTP::HSTS::parse_header("max-age=100; foo=bar; baz"sv); + EXPECT(policy.has_value()); + EXPECT_EQ(policy->max_age, AK::Duration::from_seconds(100)); +} + +TEST_CASE(quoted_value_in_unknown_directive_does_not_synthesise_directives) +{ + // A ';' inside an unknown directive's quoted-string value must not be mistaken for a directive + // separator. With a quote-blind parser the embedded "max-age=42" would be parsed as a real + // directive. + auto policy = HTTP::HSTS::parse_header("foo=\"x; max-age=42; y\""sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(missing_max_age_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("includeSubDomains"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(empty_header_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header(""sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(non_numeric_max_age_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=abc"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(duplicate_max_age_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=100; max-age=200"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(duplicate_include_sub_domains_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=100; includeSubDomains; includeSubDomains"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(value_on_include_sub_domains_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=100; includeSubDomains=false"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(unterminated_quoted_max_age_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=\"31536000"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(trailing_garbage_after_quoted_value_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=\"100\" trailing"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(empty_token_value_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("foo=; max-age=100"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(non_token_directive_name_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=100; @=bar"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(non_token_unquoted_value_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("foo=@; max-age=100"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(internal_whitespace_in_directive_name_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max age=100"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(internal_whitespace_in_unquoted_value_is_rejected) +{ + auto policy = HTTP::HSTS::parse_header("max-age=10 0"sv); + EXPECT(!policy.has_value()); +} + +TEST_CASE(consecutive_semicolons_are_tolerated) +{ + auto policy = HTTP::HSTS::parse_header(";; max-age=100;"sv); + EXPECT(policy.has_value()); + EXPECT_EQ(policy->max_age, AK::Duration::from_seconds(100)); +}