LibWeb/Fetch: Implement CORS-preflight cache

This commit is contained in:
Shannon Booth 2026-03-20 22:15:10 +01:00 committed by Shannon Booth
parent f965e8d094
commit 484f2dba79
5 changed files with 337 additions and 32 deletions

View file

@ -431,6 +431,7 @@ set(SOURCES
Fetch/Infrastructure/NetworkPartitionKey.cpp
Fetch/Infrastructure/NoSniffBlocking.cpp
Fetch/Infrastructure/PortBlocking.cpp
Fetch/Infrastructure/PreflightCache.cpp
Fetch/Infrastructure/Task.cpp
Fetch/Infrastructure/URL.cpp
Fetch/Request.cpp

View file

@ -45,6 +45,7 @@
#include <LibWeb/Fetch/Infrastructure/NetworkPartitionKey.h>
#include <LibWeb/Fetch/Infrastructure/NoSniffBlocking.h>
#include <LibWeb/Fetch/Infrastructure/PortBlocking.h>
#include <LibWeb/Fetch/Infrastructure/PreflightCache.h>
#include <LibWeb/Fetch/Infrastructure/Task.h>
#include <LibWeb/Fetch/Infrastructure/URL.h>
#include <LibWeb/FileAPI/Blob.h>
@ -575,11 +576,11 @@ GC::Ptr<PendingResponse> main_fetch(JS::Realm& realm, Infrastructure::FetchParam
// 2. Let corsWithPreflightResponse be the result of running HTTP fetch given fetchParams and true.
auto cors_with_preflight_response = http_fetch(realm, fetch_params, MakeCORSPreflight::Yes);
cors_with_preflight_response->when_loaded([returned_pending_response](GC::Ref<Infrastructure::Response> cors_with_preflight_response) {
cors_with_preflight_response->when_loaded([request, returned_pending_response](GC::Ref<Infrastructure::Response> cors_with_preflight_response) {
dbgln_if(WEB_FETCH_DEBUG, "Fetch: Running 'main fetch' cors_with_preflight_response load callback");
// 3. If corsWithPreflightResponse is a network error, then clear cache entries using request.
if (cors_with_preflight_response->is_network_error()) {
// FIXME: Clear cache entries
Infrastructure::PreflightCache::the().clear_cache_entries(request);
}
// 4. Return corsWithPreflightResponse.
@ -1312,25 +1313,31 @@ GC::Ref<PendingResponse> http_fetch(JS::Realm& realm, Infrastructure::FetchParam
// 4. If response is null, then:
if (!response) {
// 1. If makeCORSPreflight is true and one of these conditions is true:
// NOTE: This step checks the CORS-preflight cache and if there is no suitable entry it performs a
// CORS-preflight fetch which, if successful, populates the cache. The purpose of the CORS-preflight
// fetch is to ensure the fetched resource is familiar with the CORS protocol. The cache is there to
// minimize the number of CORS-preflight fetches.
GC::Ptr<PendingResponse> pending_preflight_response;
if (make_cors_preflight == MakeCORSPreflight::Yes && (
// - There is no method cache entry match for requests method using request, and either requests
// method is not a CORS-safelisted method or requests use-CORS-preflight flag is set.
// FIXME: We currently have no cache, so there will always be no method cache entry.
(!HTTP::is_cors_safelisted_method(request->method()) || request->use_cors_preflight())
// - There is at least one item in the CORS-unsafe request-header names with requests header list for
// which there is no header-name cache entry match using request.
// FIXME: We currently have no cache, so there will always be no header-name cache entry.
|| !Infrastructure::get_cors_unsafe_header_names(request->header_list()).is_empty())) {
auto& preflight_cache = Infrastructure::PreflightCache::the();
// - There is no method cache entry match for request's method using request,
// and either request's method is not a CORS-safelisted method or request's use-CORS-preflight flag is set.
bool method_needs_preflight = !preflight_cache.is_a_method_cache_entry_match(*request, request->method())
&& (!HTTP::is_cors_safelisted_method(request->method()) || request->use_cors_preflight());
// - There is at least one item in the CORS-unsafe request-header names with request's header list for
// which there is no header-name cache entry match using request.
bool headers_need_preflight = any_of(Infrastructure::get_cors_unsafe_header_names(request->header_list()),
[&](auto const& name) {
return !preflight_cache.is_a_header_name_cache_entry_match(*request, name);
});
if (make_cors_preflight == MakeCORSPreflight::Yes && (method_needs_preflight || headers_need_preflight)) {
// 1. Let preflightResponse be the result of running CORS-preflight fetch given request.
pending_preflight_response = cors_preflight_fetch(realm, request);
// NOTE: Step 2 is performed in pending_preflight_response's load callback below.
}
// NOTE: This step checks the CORS-preflight cache and if there is no suitable entry it performs a
// CORS-preflight fetch which, if successful, populates the cache. The purpose of the CORS-preflight
// fetch is to ensure the fetched resource is familiar with the CORS protocol. The cache is there to
// minimize the number of CORS-preflight fetches.
auto fetch_main_content = GC::create_function(realm.heap(), [request, realm = GC::Ref { realm }, fetch_params = GC::Ref { fetch_params }]() -> GC::Ref<PendingResponse> {
// 2. If requests redirect mode is "follow", then set requests service-workers mode to "none".
@ -2464,24 +2471,39 @@ GC::Ref<PendingResponse> cors_preflight_fetch(JS::Realm& realm, Infrastructure::
}
}
// FIXME: 8. Let max-age be the result of extracting header list values given `Access-Control-Max-Age` and responses header list.
// FIXME: 9. If max-age is failure or null, then set max-age to 5.
// FIXME: 10. If max-age is greater than an imposed limit on max-age, then set max-age to the imposed limit.
// 8. Let max-age be the result of extracting header list values given `Access-Control-Max-Age` and response's header list.
auto max_age_result = response->header_list()->extract_header_list_values("Access-Control-Max-Age"sv);
auto* maybe_max_age = max_age_result.get_pointer<Vector<ByteString>>();
// 9. If max-age is failure or null, then set max-age to 5.
// 10. If max-age is greater than an imposed limit on max-age, then set max-age to the imposed limit.
auto max_age = AK::Duration::from_seconds(5);
if (maybe_max_age && maybe_max_age->size() == 1) {
if (auto maybe_number = maybe_max_age->first().to_number<i32>(); maybe_number.has_value())
max_age = min(AK::Duration::from_seconds(maybe_number.value()), Infrastructure::PreflightCache::MAX_AGE_LIMIT);
}
// 11. If the user agent does not provide for a cache, then return response.
// NOTE: Since we don't currently have a cache, this is always true.
// NB: We do provide for a cache, so continue.
// 12. For each method in methods for which there is a method cache entry match using request, set matching entry's max-age
// to max-age.
// 13. For each method in methods for which there is no method cache entry match using request, create a new cache entry
// with request, max-age, method, and null.
auto& preflight_cache = Infrastructure::PreflightCache::the();
for (auto const& method : methods)
preflight_cache.cache_method(request, method, max_age);
// 14. For each headerName in headerNames for which there is a header-name cache entry match using request, set matching
// entry's max-age to max-age.
// 15. For each headerName in headerNames for which there is no header-name cache entry match using request, create a
// new cache entry with request, max-age, null, and headerName.
for (auto const& header_name : header_names)
preflight_cache.cache_header_name(request, header_name, max_age);
// 16. Return response.
returned_pending_response->resolve(response);
return;
// FIXME: 12. For each method in methods for which there is a method cache entry match using request, set matching entrys max-age
// to max-age.
// FIXME: 13. For each method in methods for which there is no method cache entry match using request, create a new cache entry
// with request, max-age, method, and null.
// FIXME: 14. For each headerName in headerNames for which there is a header-name cache entry match using request, set matching
// entrys max-age to max-age.
// FIXME: 15. For each headerName in headerNames for which there is no header-name cache entry match using request, create a
// new cache entry with request, max-age, null, and headerName.
// FIXME: 16. Return response.
}
// 8. Otherwise, return a network error.

View file

@ -0,0 +1,208 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Debug.h>
#include <AK/NeverDestroyed.h>
#include <LibWeb/Fetch/Infrastructure/HTTP/CORS.h>
#include <LibWeb/Fetch/Infrastructure/HTTP/Requests.h>
#include <LibWeb/Fetch/Infrastructure/PreflightCache.h>
namespace Web::Fetch::Infrastructure {
PreflightCache& PreflightCache::the()
{
static NeverDestroyed<PreflightCache> s_cache;
return *s_cache;
}
// https://fetch.spec.whatwg.org/#concept-cache-create-entry
void PreflightCache::create_a_new_cache_entry(Request const& request, AK::Duration max_age, Optional<ByteString> method, Optional<ByteString> header_name)
{
// Already expired, no need to store.
if (max_age.is_negative() || max_age.is_zero())
return;
auto partition_key = determine_the_network_partition_key(request);
VERIFY(partition_key.has_value());
// 1. Let entry be a cache entry, initialized as follows:
Entry entry {
.stored_at = MonotonicTime::now(),
// key
// The result of determining the network partition key given request
.key = partition_key.release_value(),
// byte-serialized origin
// The result of byte-serializing a request origin with request
.byte_serialized_origin = request.byte_serialize_origin(),
// URL
// requests current URL
.url = request.current_url(),
// max-age
// max-age
.max_age = max_age,
// credentials
// True if requests credentials mode is "include", and false otherwise
.credentials = request.credentials_mode() == Request::CredentialsMode::Include,
// method
// method
.method = move(method),
// header name
// headerName
.header_name = move(header_name),
};
// 2. Append entry to the user agents CORS-preflight cache.
m_entries.append(move(entry));
}
// https://fetch.spec.whatwg.org/#concept-cache-match-method
bool PreflightCache::is_a_method_cache_entry_match(Request const& request, ByteString const& method)
{
evict_expired_entries();
// There is a method cache entry match for method using request when there is a cache entry in the user agents
// CORS-preflight cache for which there is a cache entry match with request and its method is method or `*`.
return method_cache_entry_match(request, method) != nullptr;
}
PreflightCache::Entry* PreflightCache::method_cache_entry_match(Request const& request, ByteString const& method)
{
for (auto& entry : m_entries) {
if (!entry.method.has_value() || (!entry.method->equals_ignoring_ascii_case(method) && entry.method != "*"sv))
continue;
if (is_cache_entry_match(entry, request))
return &entry;
}
return nullptr;
}
void PreflightCache::cache_method(Request const& request, ByteString const& method, AK::Duration max_age)
{
evict_expired_entries();
if (auto* entry = method_cache_entry_match(request, method); entry != nullptr) {
entry->update_max_age(max_age);
return;
}
create_a_new_cache_entry(request, max_age, method, OptionalNone());
}
// https://fetch.spec.whatwg.org/#concept-cache-match-header
bool PreflightCache::is_a_header_name_cache_entry_match(Request const& request, ByteString const& header_name)
{
evict_expired_entries();
// There is a header-name cache entry match for headerName using request when there is a cache entry in the user
// agents CORS-preflight cache for which there is a cache entry match with request and one of:
// * its header name is a byte-case-insensitive match for headerName
// * its header name is `*` and headerName is not a CORS non-wildcard request-header name
// is true
return header_name_cache_entry_match(request, header_name) != nullptr;
}
PreflightCache::Entry* PreflightCache::header_name_cache_entry_match(Request const& request, ByteString const& header_name)
{
for (auto& entry : m_entries) {
if (!entry.header_name.has_value())
continue;
if (!(entry.header_name->equals_ignoring_ascii_case(header_name) || (entry.header_name.value() == "*"sv && !is_cors_non_wildcard_request_header_name(header_name))))
continue;
if (is_cache_entry_match(entry, request))
return &entry;
}
return nullptr;
}
void PreflightCache::cache_header_name(Request const& request, ByteString const& header_name, AK::Duration max_age)
{
evict_expired_entries();
if (auto* entry = header_name_cache_entry_match(request, header_name); entry != nullptr) {
entry->update_max_age(max_age);
return;
}
create_a_new_cache_entry(request, max_age, OptionalNone(), header_name);
}
// https://fetch.spec.whatwg.org/#concept-cache-match
bool PreflightCache::is_cache_entry_match(Entry const& entry, Request const& request)
{
if (entry.has_expired())
return false;
// There is a cache entry match for a cache entry entry with request if
// entrys key is the result of determining the network partition key given request,
if (entry.key != determine_the_network_partition_key(request))
return false;
// entrys byte-serialized origin is the result of byte-serializing a request origin with request,
if (entry.byte_serialized_origin != request.byte_serialize_origin())
return false;
// entrys URL is requests current URL,
if (entry.url != request.current_url())
return false;
// and one of
// * entrys credentials is true
// * entrys credentials is false and requests credentials mode is not "include".
return entry.credentials || request.credentials_mode() != Request::CredentialsMode::Include;
}
void PreflightCache::evict_expired_entries()
{
m_entries.remove_all_matching([](Entry const& entry) {
return entry.has_expired();
});
}
// https://fetch.spec.whatwg.org/#concept-cache-clear
void PreflightCache::clear_cache_entries(Request const& request)
{
auto request_key = determine_the_network_partition_key(request);
// To clear cache entries, given a request, remove any cache entries in the user agents CORS-preflight cache whose
m_entries.remove_all_matching([&](Entry const& entry) {
if (entry.has_expired())
return true;
// key is the result of determining the network partition key given request,
if (entry.key != request_key)
return false;
// byte-serialized origin is the result of byte-serializing a request origin with request,
if (entry.byte_serialized_origin != request.byte_serialize_origin())
return false;
// and URL is requests current URL.
if (entry.url != request.current_url())
return false;
return true;
});
}
bool PreflightCache::Entry::has_expired() const
{
return MonotonicTime::now() - stored_at >= max_age;
}
void PreflightCache::Entry::update_max_age(AK::Duration new_max_age)
{
max_age = new_max_age;
stored_at = MonotonicTime::now();
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Time.h>
#include <LibURL/URL.h>
#include <LibWeb/Fetch/Infrastructure/NetworkPartitionKey.h>
namespace Web::Fetch::Infrastructure {
// https://fetch.spec.whatwg.org/#concept-cache
class PreflightCache {
public:
static constexpr AK::Duration MAX_AGE_LIMIT = AK::Duration::from_seconds(7200); // 2 hours.
static PreflightCache& the();
void cache_method(Request const&, ByteString const& method, AK::Duration max_age);
void cache_header_name(Request const&, ByteString const& header_name, AK::Duration max_age);
bool is_a_method_cache_entry_match(Request const&, ByteString const& method);
bool is_a_header_name_cache_entry_match(Request const&, ByteString const& header_name);
void clear_cache_entries(Request const&);
void evict_expired_entries();
private:
// https://fetch.spec.whatwg.org/#cache-entry
struct Entry {
// "Cache entries must be removed after the seconds specified in their max-age field have passed since
// storing the entry. Cache entries may be removed before that moment arrives."
MonotonicTime stored_at;
// A cache entry consists of:
// key (a network partition key)
NetworkPartitionKey key;
// byte-serialized origin (a byte sequence)
ByteString byte_serialized_origin;
// URL (a URL)
URL::URL url;
// max-age (a number of seconds)
AK::Duration max_age;
// credentials (a boolean)
bool credentials { false };
// method (null, `*`, or a method)
Optional<ByteString> method;
// header name (null, `*`, or a header name)
Optional<ByteString> header_name;
bool has_expired() const;
void update_max_age(AK::Duration max_age);
};
void create_a_new_cache_entry(Request const&, AK::Duration max_age, Optional<ByteString> method, Optional<ByteString> header_name);
Entry* method_cache_entry_match(Request const&, ByteString const& method);
Entry* header_name_cache_entry_match(Request const&, ByteString const& header_name);
static bool is_cache_entry_match(Entry const&, Request const&);
// A CORS-preflight cache is a list of cache entries.
Vector<Entry> m_entries;
};
}

View file

@ -2,7 +2,6 @@ Harness status: OK
Found 2 tests
1 Pass
1 Fail
Fail CORS preflight cache reuses explicit header entries
2 Pass
Pass CORS preflight cache reuses explicit header entries
Pass CORS preflight cache does not reuse wildcard header entries for Authorization