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:
parent
d4d9fc12f7
commit
854d9c7da4
11 changed files with 147 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -456,7 +456,7 @@ GC::Ptr<PendingResponse> 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<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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
set(TEST_SOURCES
|
||||
TestCacheUtilities.cpp
|
||||
TestHSTSPolicy.cpp
|
||||
TestHSTSPreloadData.cpp
|
||||
TestHTTPUtils.cpp
|
||||
)
|
||||
|
||||
|
|
|
|||
33
Tests/LibHTTP/TestHSTSPreloadData.cpp
Normal file
33
Tests/LibHTTP/TestHSTSPreloadData.cpp
Normal 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));
|
||||
}
|
||||
1
Tests/LibWeb/Text/expected/HSTS/hsts-preload.txt
Normal file
1
Tests/LibWeb/Text/expected/HSTS/hsts-preload.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
PASS
|
||||
35
Tests/LibWeb/Text/input/HSTS/hsts-preload.html
Normal file
35
Tests/LibWeb/Text/input/HSTS/hsts-preload.html
Normal 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>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
set(TEST_SOURCES
|
||||
TestHistoryStore.cpp
|
||||
TestHSTSStore.cpp
|
||||
TestWebViewURL.cpp
|
||||
)
|
||||
|
||||
|
|
|
|||
60
Tests/LibWebView/TestHSTSStore.cpp
Normal file
60
Tests/LibWebView/TestHSTSStore.cpp
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue