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:
parent
e5e3099ce7
commit
08766d47f4
27 changed files with 761 additions and 6 deletions
|
|
@ -7,6 +7,7 @@ set(SOURCES
|
|||
Cache/Utilities.cpp
|
||||
Cookie/Cookie.cpp
|
||||
Cookie/ParsedCookie.cpp
|
||||
HSTS/ParsedHSTSPolicy.cpp
|
||||
Header.cpp
|
||||
HeaderList.cpp
|
||||
HTTP.cpp
|
||||
|
|
|
|||
178
Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.cpp
Normal file
178
Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.cpp
Normal 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 };
|
||||
}
|
||||
37
Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.h
Normal file
37
Libraries/LibHTTP/HSTS/ParsedHSTSPolicy.h
Normal 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&);
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -453,11 +453,12 @@ GC::Ptr<PendingResponse> 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<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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
|
|
|
|||
|
|
@ -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) =|
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ set(SOURCES
|
|||
HeadlessWebView.cpp
|
||||
HistoryStore.cpp
|
||||
HelperProcess.cpp
|
||||
HSTSStore.cpp
|
||||
Menu.cpp
|
||||
Mutation.cpp
|
||||
Plugins/ImageCodecPlugin.cpp
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class BookmarkStore;
|
|||
class CompositorClient;
|
||||
class CookieJar;
|
||||
class HistoryStore;
|
||||
class HSTSStore;
|
||||
class Menu;
|
||||
class OutOfProcessWebView;
|
||||
class ProcessManager;
|
||||
|
|
|
|||
205
Libraries/LibWebView/HSTSStore.cpp
Normal file
205
Libraries/LibWebView/HSTSStore.cpp
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
81
Libraries/LibWebView/HSTSStore.h
Normal file
81
Libraries/LibWebView/HSTSStore.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => ()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
set(TEST_SOURCES
|
||||
TestCacheUtilities.cpp
|
||||
TestHSTSPolicy.cpp
|
||||
TestHTTPUtils.cpp
|
||||
)
|
||||
|
||||
|
|
|
|||
149
Tests/LibHTTP/TestHSTSPolicy.cpp
Normal file
149
Tests/LibHTTP/TestHSTSPolicy.cpp
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue