diff --git a/Libraries/LibHTTP/CMakeLists.txt b/Libraries/LibHTTP/CMakeLists.txt index 3a4f1c0eaa..7efb10bac8 100644 --- a/Libraries/LibHTTP/CMakeLists.txt +++ b/Libraries/LibHTTP/CMakeLists.txt @@ -15,5 +15,8 @@ set(SOURCES Method.cpp ) +include(hsts_preload) +list(APPEND SOURCES ${HSTS_PRELOAD_SOURCES}) + ladybird_lib(LibHTTP http) target_link_libraries(LibHTTP PRIVATE LibCompress LibCore LibCrypto LibDatabase LibFileSystem LibIPC LibRegex LibTextCodec LibTLS LibUnicode LibURL) diff --git a/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp b/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp index 380af54c89..f17d04f75a 100644 --- a/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp +++ b/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp @@ -456,7 +456,7 @@ GC::Ptr main_fetch(JS::Realm& realm, Infrastructure::FetchParam // - 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()) + && ResourceLoader::is_known_hsts_host(Bindings::principal_host_defined_page(realm), 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/Internals/Internals.cpp b/Libraries/LibWeb/Internals/Internals.cpp index 86d4344096..0fd6fc3274 100644 --- a/Libraries/LibWeb/Internals/Internals.cpp +++ b/Libraries/LibWeb/Internals/Internals.cpp @@ -568,7 +568,7 @@ void Internals::ingest_hsts_header(String const& url, String const& header_value bool Internals::is_known_hsts_host(String const& domain) { - return page().client().page_did_is_known_hsts_host(domain); + return ResourceLoader::is_known_hsts_host(page(), domain); } void Internals::set_browser_zoom(double factor) diff --git a/Libraries/LibWeb/Loader/ResourceLoader.cpp b/Libraries/LibWeb/Loader/ResourceLoader.cpp index a457bcc72e..ffb3abb8cc 100644 --- a/Libraries/LibWeb/Loader/ResourceLoader.cpp +++ b/Libraries/LibWeb/Loader/ResourceLoader.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -139,6 +140,15 @@ void ResourceLoader::try_store_hsts_policy_for_url(Page& page, URL::URL const& u page.client().page_did_store_hsts_policy(url.host()->get(), parsed_policy.value()); } +// https://www.rfc-editor.org/rfc/rfc6797#section-8.2 +bool ResourceLoader::is_known_hsts_host(Page& page, String const& host) +{ + if (HTTP::HSTSPreloadData::the().is_known_preloaded_hsts_host(host.to_ascii_lowercase())) + return true; + + return page.client().page_did_is_known_hsts_host(host); +} + static NonnullRefPtr response_headers_for_file(StringView path, Optional const& modified_time) { // For file:// and resource:// URLs, we have to guess the MIME type, since there's no HTTP header to tell us what diff --git a/Libraries/LibWeb/Loader/ResourceLoader.h b/Libraries/LibWeb/Loader/ResourceLoader.h index 46bb633376..bfc4be2e27 100644 --- a/Libraries/LibWeb/Loader/ResourceLoader.h +++ b/Libraries/LibWeb/Loader/ResourceLoader.h @@ -50,6 +50,7 @@ public: int pending_loads() const { return m_pending_loads; } static void try_store_hsts_policy_for_url(Page&, URL::URL const&, StringView header_value); + static bool is_known_hsts_host(Page&, String const& host); String const& user_agent() const { return m_user_agent; } void set_user_agent(String user_agent) { m_user_agent = move(user_agent); } diff --git a/Tests/LibHTTP/CMakeLists.txt b/Tests/LibHTTP/CMakeLists.txt index fd6b3adbc6..f1d956e714 100644 --- a/Tests/LibHTTP/CMakeLists.txt +++ b/Tests/LibHTTP/CMakeLists.txt @@ -1,6 +1,7 @@ set(TEST_SOURCES TestCacheUtilities.cpp TestHSTSPolicy.cpp + TestHSTSPreloadData.cpp TestHTTPUtils.cpp ) diff --git a/Tests/LibHTTP/TestHSTSPreloadData.cpp b/Tests/LibHTTP/TestHSTSPreloadData.cpp new file mode 100644 index 0000000000..7bd93069d5 --- /dev/null +++ b/Tests/LibHTTP/TestHSTSPreloadData.cpp @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +// Hosts from Chromium's transport_security_state_static.json: +// - accounts.google.com: a preloaded force-https host (exact match). +// - dev: a force-https TLD with include_subdomains, so any *.dev matches as a subdomain. +// - example.test: a reserved TLD that is never preloaded. +// Stable enough to anchor tests; if upstream removes one the matching test fails loudly +// rather than silently regressing. +// +// is_known_preloaded_hsts_host expects an already-lowercased domain (callers canonicalize +// before querying), so these tests pass lowercased input. + +TEST_CASE(congruent_match) +{ + EXPECT(HTTP::HSTSPreloadData::the().is_known_preloaded_hsts_host("accounts.google.com"sv)); +} + +TEST_CASE(subdomain_match_via_include_subdomains) +{ + EXPECT(HTTP::HSTSPreloadData::the().is_known_preloaded_hsts_host("anything.dev"sv)); +} + +TEST_CASE(non_preloaded_host_is_not_known) +{ + EXPECT(!HTTP::HSTSPreloadData::the().is_known_preloaded_hsts_host("example.test"sv)); +} diff --git a/Tests/LibWeb/Text/expected/HSTS/hsts-preload.txt b/Tests/LibWeb/Text/expected/HSTS/hsts-preload.txt new file mode 100644 index 0000000000..7ef22e9a43 --- /dev/null +++ b/Tests/LibWeb/Text/expected/HSTS/hsts-preload.txt @@ -0,0 +1 @@ +PASS diff --git a/Tests/LibWeb/Text/input/HSTS/hsts-preload.html b/Tests/LibWeb/Text/input/HSTS/hsts-preload.html new file mode 100644 index 0000000000..6e2bb6d3da --- /dev/null +++ b/Tests/LibWeb/Text/input/HSTS/hsts-preload.html @@ -0,0 +1,35 @@ + + + diff --git a/Tests/LibWebView/CMakeLists.txt b/Tests/LibWebView/CMakeLists.txt index 12fcd1eeb8..6fa2f05a7d 100644 --- a/Tests/LibWebView/CMakeLists.txt +++ b/Tests/LibWebView/CMakeLists.txt @@ -1,5 +1,6 @@ set(TEST_SOURCES TestHistoryStore.cpp + TestHSTSStore.cpp TestWebViewURL.cpp ) diff --git a/Tests/LibWebView/TestHSTSStore.cpp b/Tests/LibWebView/TestHSTSStore.cpp new file mode 100644 index 0000000000..d2c3afcb95 --- /dev/null +++ b/Tests/LibWebView/TestHSTSStore.cpp @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +// HSTSStore is the dynamic, per-profile store only; the built-in preload list is consulted +// separately at the fetch layer (see Tests/LibHTTP/TestHSTSPreloadData.cpp). These tests use +// example.test, which is not a preloaded host, so they exercise the dynamic store in isolation. + +TEST_CASE(congruent_match) +{ + auto store = WebView::HSTSStore::create(); + store->store_policy("example.test"_string, HTTP::HSTS::ParsedHSTSPolicy { AK::Duration::from_seconds(3600), false }); + EXPECT(store->is_known_hsts_host("example.test"sv)); +} + +TEST_CASE(superdomain_match_via_include_subdomains) +{ + auto store = WebView::HSTSStore::create(); + store->store_policy("example.test"_string, HTTP::HSTS::ParsedHSTSPolicy { AK::Duration::from_seconds(3600), true }); + EXPECT(store->is_known_hsts_host("sub.example.test"sv)); +} + +TEST_CASE(superdomain_not_matched_without_include_subdomains) +{ + auto store = WebView::HSTSStore::create(); + store->store_policy("example.test"_string, HTTP::HSTS::ParsedHSTSPolicy { AK::Duration::from_seconds(3600), false }); + EXPECT(!store->is_known_hsts_host("sub.example.test"sv)); +} + +TEST_CASE(unknown_host_is_not_hsts_known) +{ + auto store = WebView::HSTSStore::create(); + EXPECT(!store->is_known_hsts_host("example.test"sv)); +} + +TEST_CASE(max_age_zero_removes_dynamic_policy) +{ + auto store = WebView::HSTSStore::create(); + store->store_policy("example.test"_string, HTTP::HSTS::ParsedHSTSPolicy { AK::Duration::from_seconds(3600), false }); + EXPECT(store->is_known_hsts_host("example.test"sv)); + + store->store_policy("example.test"_string, HTTP::HSTS::ParsedHSTSPolicy { AK::Duration::zero(), false }); + EXPECT(!store->is_known_hsts_host("example.test"sv)); +} + +TEST_CASE(remove_policies_observed_since_clears_dynamic_data) +{ + auto store = WebView::HSTSStore::create(); + store->store_policy("example.test"_string, HTTP::HSTS::ParsedHSTSPolicy { AK::Duration::from_seconds(3600), false }); + store->remove_policies_observed_since(UnixDateTime::earliest()); + EXPECT(!store->is_known_hsts_host("example.test"sv)); +}