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.
This commit is contained in:
Luke Wilde 2026-03-26 17:04:33 +00:00 committed by Shannon Booth
parent e5e3099ce7
commit 08766d47f4
27 changed files with 761 additions and 6 deletions

View file

@ -7,6 +7,7 @@ set(SOURCES
Cache/Utilities.cpp
Cookie/Cookie.cpp
Cookie/ParsedCookie.cpp
HSTS/ParsedHSTSPolicy.cpp
Header.cpp
HeaderList.cpp
HTTP.cpp

View file

@ -0,0 +1,178 @@
/*
* Copyright (c) 2026, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/GenericLexer.h>
#include <AK/NumericLimits.h>
#include <AK/StringBuilder.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibHTTP/HTTP.h>
#include <LibIPC/Decoder.h>
#include <LibIPC/Encoder.h>
namespace HTTP::HSTS {
// https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6
static Optional<String> 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<ParsedHSTSPolicy> parse_header(StringView header_value)
{
GenericLexer lexer(header_value);
Optional<u64> 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<String> 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<u64>();
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<u64>(*max_age, NumericLimits<i64>::max());
return ParsedHSTSPolicy {
.max_age = AK::Duration::from_seconds(static_cast<i64>(clamped_seconds)),
.include_sub_domains = include_sub_domains,
};
}
}
template<>
ErrorOr<void> 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<HTTP::HSTS::ParsedHSTSPolicy> IPC::decode(Decoder& decoder)
{
auto max_age = TRY(decoder.decode<AK::Duration>());
auto include_sub_domains = TRY(decoder.decode<bool>());
return HTTP::HSTS::ParsedHSTSPolicy { max_age, include_sub_domains };
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2026, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Optional.h>
#include <AK/StringView.h>
#include <AK/Time.h>
#include <LibIPC/Forward.h>
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<ParsedHSTSPolicy> parse_header(StringView header_value);
}
namespace IPC {
template<>
ErrorOr<void> encode(Encoder&, HTTP::HSTS::ParsedHSTSPolicy const&);
template<>
ErrorOr<HTTP::HSTS::ParsedHSTSPolicy> decode(Decoder&);
}

View file

@ -4,12 +4,19 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/AllOf.h>
#include <AK/GenericLexer.h>
#include <AK/StringBuilder.h>
#include <LibHTTP/HTTP.h>
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)
{

View file

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

View file

@ -83,7 +83,7 @@ Optional<Vector<ByteString>> 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

View file

@ -453,11 +453,12 @@ GC::Ptr<PendingResponse> main_fetch(JS::Realm& realm, Infrastructure::FetchParam
request->current_url().scheme() == "http"sv
// - requests current URLs host is a domain
&& request->current_url().host().has_value() && request->current_url().host()->is_domain()
// FIXME: - 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]; or DNS resolution for the request finds a matching HTTPS RR
// per section 9.5 of [SVCB].
&& false) {
// - 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>())
// 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

@ -13,6 +13,7 @@
#include <LibGC/Function.h>
#include <LibHTTP/Cookie/Cookie.h>
#include <LibHTTP/Cookie/ParsedCookie.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibRequests/Request.h>
#include <LibRequests/RequestClient.h>
#include <LibURL/Parser.h>
@ -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<String>(), 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

View file

@ -23,6 +23,7 @@
#include <LibGfx/Size.h>
#include <LibHTTP/Cookie/Cookie.h>
#include <LibHTTP/Forward.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibHTTP/Header.h>
#include <LibIPC/Forward.h>
#include <LibIPC/TransportHandle.h>
@ -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<String> 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) { }

View file

@ -1,4 +1,5 @@
#include <LibHTTP/Cookie/Cookie.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibIPC/TransportHandle.h>
#include <LibURL/URL.h>
#include <LibWeb/Bindings/AgentType.h>
@ -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) =|

View file

@ -28,6 +28,7 @@
#include <LibWebView/Application.h>
#include <LibWebView/CompositorClient.h>
#include <LibWebView/CookieJar.h>
#include <LibWebView/HSTSStore.h>
#include <LibWebView/HeadlessWebView.h>
#include <LibWebView/HelperProcess.h>
#include <LibWebView/HistoryStore.h>
@ -710,12 +711,14 @@ ErrorOr<void> 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();
}

View file

@ -82,6 +82,7 @@ public:
virtual void show_bookmark_context_menu(Gfx::IntPoint, Optional<BookmarkItem const&>, [[maybe_unused]] Optional<String const&> 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<Database::Database> m_database;
RefPtr<Database::Database> m_history_database;
OwnPtr<CookieJar> m_cookie_jar;
OwnPtr<HSTSStore> m_hsts_store;
OwnPtr<StorageJar> m_storage_jar;
OwnPtr<Core::TimeZoneWatcher> m_time_zone_watcher;

View file

@ -11,6 +11,7 @@ set(SOURCES
HeadlessWebView.cpp
HistoryStore.cpp
HelperProcess.cpp
HSTSStore.cpp
Menu.cpp
Mutation.cpp
Plugins/ImageCodecPlugin.cpp

View file

@ -19,6 +19,7 @@ class BookmarkStore;
class CompositorClient;
class CookieJar;
class HistoryStore;
class HSTSStore;
class Menu;
class OutOfProcessWebView;
class ProcessManager;

View file

@ -0,0 +1,205 @@
/*
* Copyright (c) 2026, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/NonnullOwnPtr.h>
#include <AK/Time.h>
#include <LibDatabase/Database.h>
#include <LibWebView/HSTSStore.h>
namespace WebView {
static constexpr auto DATABASE_SYNCHRONIZATION_TIMER = AK::Duration::from_seconds(30);
ErrorOr<NonnullOwnPtr<HSTSStore>> 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> HSTSStore::create()
{
return adopt_own(*new HSTSStore { OptionalNone {} });
}
HSTSStore::HSTSStore(Optional<PersistedStorage> 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<int>(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::StoredPolicy const&> 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<String>(row, 0);
StoredPolicy stored_policy {
.expiry = database.result_column<UnixDateTime>(row, 1),
.include_sub_domains = database.result_column<bool>(row, 2),
.last_observed_time = database.result_column<UnixDateTime>(row, 3),
};
policies.set(move(domain), stored_policy);
});
return policies;
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2026, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/HashMap.h>
#include <AK/String.h>
#include <AK/Time.h>
#include <LibCore/Timer.h>
#include <LibDatabase/Forward.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibWebView/Forward.h>
namespace WebView {
class WEBVIEW_API HSTSStore {
public:
static ErrorOr<NonnullOwnPtr<HSTSStore>> create(Database::Database&);
static NonnullOwnPtr<HSTSStore> 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<String, StoredPolicy>;
void set_policies(Policies);
void set_policy(String const& domain, StoredPolicy const& policy);
Optional<StoredPolicy const&> 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<Core::Timer> synchronization_timer {};
};
explicit HSTSStore(Optional<PersistedStorage>);
AK_MAKE_NONCOPYABLE(HSTSStore);
AK_MAKE_NONMOVABLE(HSTSStore);
Optional<PersistedStorage> m_persisted_storage;
TransientStorage m_transient_storage;
};
}

View file

@ -17,6 +17,7 @@
#include <LibWeb/Page/InputEvent.h>
#include <LibWebView/Application.h>
#include <LibWebView/CookieJar.h>
#include <LibWebView/HSTSStore.h>
#include <LibWebView/HelperProcess.h>
#include <LibWebView/HistoryStore.h>
#include <LibWebView/SourceHighlighter.h>
@ -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);

View file

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

View file

@ -6,6 +6,7 @@
#include <LibWebView/Application.h>
#include <LibWebView/CookieJar.h>
#include <LibWebView/HSTSStore.h>
#include <LibWebView/WebWorkerClient.h>
#include <LibWebView/WorkerProcessManager.h>
@ -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));

View file

@ -9,6 +9,7 @@
#include <AK/ByteString.h>
#include <AK/Types.h>
#include <LibHTTP/Cookie/Cookie.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibIPC/ConnectionToServer.h>
#include <LibIPC/TransportHandle.h>
#include <LibWeb/HTML/BroadcastChannelMessage.h>
@ -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;

View file

@ -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<Messages::WebContentClient::DidIsKnownHstsHost>(domain);
if (!response) {
dbgln("WebContent client disconnected during DidIsKnownHstsHost. Exiting peacefully.");
exit(0);
}
return response->result();
}
Optional<String> 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<Messages::WebContentClient::DidRequestStorageItem>(storage_endpoint, storage_key, bottle_key);

View file

@ -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<String> 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;

View file

@ -5,6 +5,7 @@
#include <LibGfx/ShareableBitmap.h>
#include <LibHTTP/Cookie/Cookie.h>
#include <LibHTTP/Cookie/ParsedCookie.h>
#include <LibHTTP/HSTS/ParsedHSTSPolicy.h>
#include <LibHTTP/Header.h>
#include <LibRequests/NetworkError.h>
#include <LibRequests/RequestTimingInfo.h>
@ -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<String> 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) => ()

View file

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

View file

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

View file

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

View file

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