LibWeb+LibHTTP: Consult HSTS preload list before dynamic-store IPC

The preload list is static, immutable data compiled into LibHTTP.
ResourceLoader consults it in-process at the fetch layer before
falling back to the dynamic, per-profile store in the browser
process, so a preloaded host is upgraded without a synchronous IPC.
HSTSStore stays the dynamic store only; the preload list cannot be
unset by a max-age=0 response because it is consulted first, before
the store is ever queried.
This commit is contained in:
Luke Wilde 2026-05-19 20:19:30 +01:00 committed by Shannon Booth
parent d4d9fc12f7
commit 854d9c7da4
11 changed files with 147 additions and 2 deletions

View file

@ -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)

View file

@ -456,7 +456,7 @@ GC::Ptr<PendingResponse> main_fetch(JS::Realm& realm, Infrastructure::FetchParam
// - Matching requests current URLs 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<String>())
&& ResourceLoader::is_known_hsts_host(Bindings::principal_host_defined_page(realm), request->current_url().host()->get<String>())
// 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);

View file

@ -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)

View file

@ -14,6 +14,7 @@
#include <LibHTTP/Cookie/Cookie.h>
#include <LibHTTP/Cookie/ParsedCookie.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibHTTP/HSTSPreloadData.h>
#include <LibRequests/Request.h>
#include <LibRequests/RequestClient.h>
#include <LibURL/Parser.h>
@ -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<String>(), 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<HTTP::HeaderList> response_headers_for_file(StringView path, Optional<time_t> 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

View file

@ -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); }

View file

@ -1,6 +1,7 @@
set(TEST_SOURCES
TestCacheUtilities.cpp
TestHSTSPolicy.cpp
TestHSTSPreloadData.cpp
TestHTTPUtils.cpp
)

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2026, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibHTTP/HSTSPreloadData.h>
#include <LibTest/TestCase.h>
// 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));
}

View file

@ -0,0 +1 @@
PASS

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<script>
// Test the built-in HSTS preload list: known hosts and subdomains report as HSTS-known on
// a fresh profile, unknown hosts do not, and a max-age=0 response cannot override preload.
test(() => {
// Hosts from Chromium's transport_security_state_static.json:
// - accounts.google.com: a preloaded force-https host (exact-match coverage).
// - dev: a force-https TLD with include_subdomains, so any *.dev is a subdomain match.
// - paypal.com: a preloaded force-https host, used for the max-age=0 override check.
if (!internals.isKnownHSTSHost("accounts.google.com")) {
println("FAIL - preloaded host should be HSTS-known");
return;
}
if (!internals.isKnownHSTSHost("anything.dev")) {
println("FAIL - subdomain of preloaded include_subdomains TLD should be HSTS-known");
return;
}
if (internals.isKnownHSTSHost("example.test")) {
println("FAIL - non-preloaded host should not be HSTS-known on a fresh profile");
return;
}
// max-age=0 from a preloaded host must not unset its preload bit.
internals.setHSTSPolicy("paypal.com", 0, false);
if (!internals.isKnownHSTSHost("paypal.com")) {
println("FAIL - max-age=0 should not override the preload list");
return;
}
println("PASS");
});
</script>

View file

@ -1,5 +1,6 @@
set(TEST_SOURCES
TestHistoryStore.cpp
TestHSTSStore.cpp
TestWebViewURL.cpp
)

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2026, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/NonnullOwnPtr.h>
#include <AK/Time.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibTest/TestCase.h>
#include <LibWebView/HSTSStore.h>
// 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));
}