LibWeb: Let site-compatibility rules expose experimental interfaces

Problem: Al Jazeera article pages rendered only the page footer. The
site’s browser gate feature-tests navigator.permissions, and refused to
hydrate the page when it wasn’t there. And in Ladybird, it wasn’t —
because the Permissions API sits behind [Experimental]. Exposing the API
to the web at large has to wait for its UI to be wired up — so the site
stayed unusable by Ladybird users for as long as that takes.

Cause: [Experimental] was a process-wide switch and nothing more: The
--expose-experimental-interfaces flag, which the generated bindings read
via WindowOrWorkerGlobalScopeMixin::expose_experimental_interfaces() —
once for a global’s constructor names, and once per gated attribute or
operation. The site-compatibility rules that already tailor the
User-Agent string per site had no say in it — so, nothing could hand
just one site the one API it wanted, without handing it to *every* site.

Fix: Give site-compatibility rules an “expose_experimental_interfaces”
key, listing the gated names a matching site gets: An interface name for
a constructor (PermissionStatus), or Interface.member for an attribute
or operation (Navigator.permissions). The generated bindings now call
expose_experimental_interface(realm, name) on that mixin at both gating
sites — which is true under the global flag, as before. It otherwise
asks ResourceLoader if a rule matching the URL the realm’s environment
was created with lists that name. So, the decision holds for the life
of the global — exactly like the flag. Internals gains a switch for the
process-wide flag — since test-web turns it on for everything.
This commit is contained in:
sideshowbarker authored and Callum Law committed 2026-09-05 12:55:36 +12:00
1 parent 62d2b76120
commit 7173b6f1dd
16 files changed
+235 -19
Notes: github-actions[bot] 2026-09-05 00:56:53 +00:00

No files matched your search

@@ -58,6 +58,7 @@
#include <LibWeb/IndexedDB/Internal/Algorithms.h>
#include <LibWeb/Infra/SerializedURL.h>
#include <LibWeb/Infra/Strings.h>
#include <LibWeb/Loader/ResourceLoader.h>
#include <LibWeb/NavigationTiming/PerformanceNavigationTiming.h>
#include <LibWeb/Page/Page.h>
#include <LibWeb/PerformanceTimeline/EntryTypes.h>
@@ -1569,6 +1570,21 @@ bool WindowOrWorkerGlobalScopeMixin::expose_experimental_interfaces()
return s_experimental_interfaces_exposed;
}
// A site-compatibility rule can expose a gated interface or member to the documents and workers it matches, without
// exposing it to the web at large. The rule is keyed by the URL the environment was created with, so it holds for the
// lifetime of that global, the same way the global switch does.
bool WindowOrWorkerGlobalScopeMixin::expose_experimental_interface(EnvironmentSettingsObject& settings, StringView name)
{
if (s_experimental_interfaces_exposed)
return true;
// The first global of a page is set up before the loader exists, and holds nothing a rule could match anyway.
if (!ResourceLoader::is_initialized())
return false;
return ResourceLoader::the().site_compatibility_exposes_experimental_interface(settings.creation_url, name);
}
}
namespace Web::Bindings {
@@ -121,6 +121,7 @@ public:
static void set_experimental_interfaces_exposed(bool);
static bool expose_experimental_interfaces();
static bool expose_experimental_interface(EnvironmentSettingsObject&, StringView name);
[[nodiscard]] GC::Ref<Crypto::Crypto> crypto();
[[nodiscard]] GC::Ref<ServiceWorker::CacheStorage> caches();
+7
View File
@@ -77,6 +77,7 @@
#include <LibWeb/HTML/SessionHistoryEntry.h>
#include <LibWeb/HTML/SharedResourceRequest.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/HTML/WindowOrWorkerGlobalScope.h>
#include <LibWeb/Internals/InternalGamepad.h>
#include <LibWeb/Internals/Internals.h>
#include <LibWeb/Layout/NodeArena.h>
@@ -847,6 +848,12 @@ WebIDL::ExceptionOr<void> Internals::set_site_compatibility_data(Utf16String con
return {};
}
// Tests run with every [Experimental] interface exposed, so this is the only way one can watch the gate itself.
void Internals::set_experimental_interfaces_exposed(bool exposed)
{
HTML::WindowOrWorkerGlobalScopeMixin::set_experimental_interfaces_exposed(exposed);
}
void Internals::set_content_blocking_enabled(bool enabled)
{
page().set_content_blocking_enabled(enabled);
+1
View File
@@ -129,6 +129,7 @@ public:
void simulate_worker_request_server_connection_loss();
WebIDL::ExceptionOr<void> set_content_blockers(Utf16String const& patterns);
WebIDL::ExceptionOr<void> set_site_compatibility_data(Utf16String const& source);
void set_experimental_interfaces_exposed(bool exposed);
void set_content_blocking_enabled(bool enabled);
WebIDL::UnsignedLongLong partial_layout_count();
WebIDL::UnsignedLongLong full_layout_count();
+1
View File
@@ -114,6 +114,7 @@ interface Internals {
undefined simulateWorkerRequestServerConnectionLoss();
undefined setContentBlockers(DOMString patterns);
undefined setSiteCompatibilityData(DOMString source);
undefined setExperimentalInterfacesExposed(boolean exposed);
undefined setContentBlockingEnabled(boolean enabled);
unsigned long long partialLayoutCount();
unsigned long long fullLayoutCount();
+1
View File
@@ -55,6 +55,7 @@ public:
String user_agent_for_websocket_url(URL::URL const& url) const { return m_site_compatibility_data.user_agent_for_websocket_url(url, m_user_agent); }
void set_user_agent(String user_agent) { m_user_agent = move(user_agent); }
void set_site_compatibility_data(SiteCompatibilityData data) { m_site_compatibility_data = move(data); }
bool site_compatibility_exposes_experimental_interface(URL::URL const& url, StringView name) const { return m_site_compatibility_data.exposes_experimental_interface(url, name); }
String const& platform() const { return m_platform; }
void set_platform(String platform) { m_platform = move(platform); }
+59 -10
View File
@@ -15,14 +15,14 @@ static ErrorOr<Vector<String>> parse_patterns(JsonObject const& object)
{
auto patterns = object.get_array("matches"sv);
if (!patterns.has_value() || patterns->is_empty())
return Error::from_string_literal("Site compatibility rule must contain a non-empty 'matches' array");
return Error::from_string_literal("Site-compatibility rule must contain a non-empty 'matches' array");
Vector<String> result;
TRY(result.try_ensure_capacity(patterns->size()));
for (size_t i = 0; i < patterns->size(); ++i) {
auto const& pattern = patterns->at(i);
if (!pattern.is_string() || pattern.as_string().is_empty())
return Error::from_string_literal("Site compatibility rule patterns must be non-empty strings");
return Error::from_string_literal("Site-compatibility rule patterns must be non-empty strings");
TRY(result.try_append(pattern.as_string()));
}
return result;
@@ -30,9 +30,14 @@ static ErrorOr<Vector<String>> parse_patterns(JsonObject const& object)
static ErrorOr<Vector<UserAgentTransformation>> parse_user_agent_transformations(JsonObject const& object)
{
if (!object.has("user_agent"sv))
return Vector<UserAgentTransformation> {};
// A present key must hold a non-empty array: JsonObject::get_array() alone can't tell a missing key from a value
// of the wrong type, and a rule with a broken half must fail, not run with the half that parsed.
auto transformations = object.get_array("user_agent"sv);
if (!transformations.has_value() || transformations->is_empty())
return Error::from_string_literal("Site compatibility rule must contain a non-empty 'user_agent' array");
return Error::from_string_literal("Site-compatibility rule 'user_agent' must be a non-empty array");
Vector<UserAgentTransformation> result;
TRY(result.try_ensure_capacity(transformations->size()));
@@ -48,16 +53,45 @@ static ErrorOr<Vector<UserAgentTransformation>> parse_user_agent_transformations
return result;
}
// The names are the ones the IDL bindings gate behind [Experimental]: an interface name for a constructor the global
// would otherwise hide, or Interface.member for an attribute or operation. A name nothing gates simply never matches.
static ErrorOr<Vector<String>> parse_exposed_interfaces(JsonObject const& object)
{
if (!object.has("expose_experimental_interfaces"sv))
return Vector<String> {};
auto names = object.get_array("expose_experimental_interfaces"sv);
if (!names.has_value() || names->is_empty())
return Error::from_string_literal("Site-compatibility rule 'expose_experimental_interfaces' must be a non-empty array");
Vector<String> result;
TRY(result.try_ensure_capacity(names->size()));
for (size_t i = 0; i < names->size(); ++i) {
auto const& name = names->at(i);
if (!name.is_string() || name.as_string().is_empty())
return Error::from_string_literal("Site-compatibility rule 'expose_experimental_interfaces' entries must be non-empty strings");
TRY(result.try_append(name.as_string()));
}
return result;
}
ErrorOr<SiteCompatibilityRule> SiteCompatibilityRule::from_json(JsonValue const& value)
{
if (!value.is_object())
return Error::from_string_literal("Site compatibility rule must be a JSON object");
return Error::from_string_literal("Site-compatibility rule must be a JSON object");
auto const& object = value.as_object();
return create(TRY(parse_patterns(object)), TRY(parse_user_agent_transformations(object)));
auto transformations = TRY(parse_user_agent_transformations(object));
auto exposed_interfaces = TRY(parse_exposed_interfaces(object));
if (transformations.is_empty() && exposed_interfaces.is_empty())
return Error::from_string_literal("Site-compatibility rule must contain a 'user_agent' or an 'expose_experimental_interfaces' array");
return create(TRY(parse_patterns(object)), move(transformations), move(exposed_interfaces));
}
ErrorOr<SiteCompatibilityRule> SiteCompatibilityRule::create(Vector<String> patterns, Vector<UserAgentTransformation> transformations)
ErrorOr<SiteCompatibilityRule> SiteCompatibilityRule::create(Vector<String> patterns, Vector<UserAgentTransformation> transformations, Vector<String> exposed_interfaces)
{
Vector<URL::RustIntegration::URLPattern> compiled_patterns;
TRY(compiled_patterns.try_ensure_capacity(patterns.size()));
@@ -65,20 +99,26 @@ ErrorOr<SiteCompatibilityRule> SiteCompatibilityRule::create(Vector<String> patt
for (auto const& pattern : patterns) {
auto compiled_pattern = URL::RustIntegration::URLPattern::create(pattern);
if (compiled_pattern.is_error())
return Error::from_string_literal("Site compatibility rule contains an invalid URL pattern");
return Error::from_string_literal("Site-compatibility rule contains an invalid URL pattern");
TRY(compiled_patterns.try_append(compiled_pattern.release_value()));
}
return SiteCompatibilityRule { move(patterns), move(compiled_patterns), move(transformations) };
return SiteCompatibilityRule { move(patterns), move(compiled_patterns), move(transformations), move(exposed_interfaces) };
}
SiteCompatibilityRule::SiteCompatibilityRule(Vector<String> patterns, Vector<URL::RustIntegration::URLPattern> compiled_patterns, Vector<UserAgentTransformation> transformations)
SiteCompatibilityRule::SiteCompatibilityRule(Vector<String> patterns, Vector<URL::RustIntegration::URLPattern> compiled_patterns, Vector<UserAgentTransformation> transformations, Vector<String> exposed_interfaces)
: m_patterns(move(patterns))
, m_compiled_patterns(move(compiled_patterns))
, m_user_agent_transformations(move(transformations))
, m_exposed_interfaces(move(exposed_interfaces))
{
}
bool SiteCompatibilityRule::exposes_experimental_interface(StringView name) const
{
return m_exposed_interfaces.contains_slow(name);
}
bool SiteCompatibilityRule::matches(URL::URL const& url) const
{
auto serialized_url = url.serialize();
@@ -138,10 +178,19 @@ String SiteCompatibilityData::user_agent_for_websocket_url(URL::URL const& url,
return user_agent_for_url(http_url, default_user_agent);
}
bool SiteCompatibilityData::exposes_experimental_interface(URL::URL const& url, StringView name) const
{
for (auto const& rule : m_rules) {
if (rule.exposes_experimental_interface(name) && rule.matches(url))
return true;
}
return false;
}
ErrorOr<SiteCompatibilityData> SiteCompatibilityData::from_json(JsonValue const& value)
{
if (!value.is_array())
return Error::from_string_literal("Site compatibility data must be a JSON array");
return Error::from_string_literal("Site-compatibility data must be a JSON array");
SiteCompatibilityData data;
auto const& rules = value.as_array();
+6 -2
View File
@@ -23,20 +23,23 @@ enum class UserAgentTransformation : u8 {
class WEB_API SiteCompatibilityRule {
public:
static ErrorOr<SiteCompatibilityRule> from_json(JsonValue const&);
static ErrorOr<SiteCompatibilityRule> create(Vector<String> patterns, Vector<UserAgentTransformation> transformations);
static ErrorOr<SiteCompatibilityRule> create(Vector<String> patterns, Vector<UserAgentTransformation> transformations, Vector<String> exposed_interfaces = {});
Vector<String> const& patterns() const { return m_patterns; }
Vector<UserAgentTransformation> const& user_agent_transformations() const { return m_user_agent_transformations; }
Vector<String> const& exposed_interfaces() const { return m_exposed_interfaces; }
bool matches(URL::URL const&) const;
String apply_user_agent_transformations(StringView user_agent) const;
bool exposes_experimental_interface(StringView name) const;
private:
SiteCompatibilityRule(Vector<String> patterns, Vector<URL::RustIntegration::URLPattern> compiled_patterns, Vector<UserAgentTransformation> transformations);
SiteCompatibilityRule(Vector<String> patterns, Vector<URL::RustIntegration::URLPattern> compiled_patterns, Vector<UserAgentTransformation> transformations, Vector<String> exposed_interfaces);
Vector<String> m_patterns;
Vector<URL::RustIntegration::URLPattern> m_compiled_patterns;
Vector<UserAgentTransformation> m_user_agent_transformations;
Vector<String> m_exposed_interfaces;
};
class WEB_API SiteCompatibilityData {
@@ -48,6 +51,7 @@ public:
Vector<SiteCompatibilityRule> const& rules() const { return m_rules; }
String user_agent_for_url(URL::URL const&, StringView default_user_agent) const;
String user_agent_for_websocket_url(URL::URL const&, StringView default_user_agent) const;
bool exposes_experimental_interface(URL::URL const&, StringView name) const;
private:
Vector<SiteCompatibilityRule> m_rules;
@@ -294,6 +294,7 @@ def define_the_attributes(
includes,
attribute.extended_attributes,
definition.getvalue(),
f"{interface.name}.{attribute.name}",
)
)
@@ -305,7 +306,11 @@ def define_the_static_attributes(out: TextIO, includes: GeneratedIncludes, inter
out.write(wrap_with_extended_attribute_exposure_checks(includes, attribute.extended_attributes, definition))
continue
definition = f' object.define_native_accessor(realm, "{attribute.name}"_utf16_fly_string, {attribute_getter_callback_name(attribute)}, nullptr, default_attributes);\n'
out.write(wrap_with_extended_attribute_exposure_checks(includes, attribute.extended_attributes, definition))
out.write(
wrap_with_extended_attribute_exposure_checks(
includes, attribute.extended_attributes, definition, f"{interface.name}.{attribute.name}"
)
)
def write_attribute_getters(
@@ -2,11 +2,16 @@
#
# SPDX-License-Identifier: BSD-2-Clause
from __future__ import annotations
from Generators.libweb_bindings.includes import GeneratedIncludes
def wrap_with_extended_attribute_exposure_checks(
includes: GeneratedIncludes, extended_attributes: dict[str, str], text: str
includes: GeneratedIncludes,
extended_attributes: dict[str, str],
text: str,
experimental_name: str | None = None,
) -> str:
if "SecureContext" in extended_attributes:
includes.add("LibWeb/Bindings/PrincipalHostDefined.h")
@@ -23,9 +28,13 @@ def wrap_with_extended_attribute_exposure_checks(
"""
if "Experimental" in extended_attributes:
# The name is what a site-compatibility rule lists to expose this member for the sites it matches.
if experimental_name is None:
raise ValueError("[Experimental] members need a name for site-compatibility rules")
includes.add("LibWeb/Bindings/PrincipalHostDefined.h")
includes.add("LibWeb/HTML/WindowOrWorkerGlobalScope.h")
text = text.replace("\n", "\n ")
text = f""" if (HTML::WindowOrWorkerGlobalScopeMixin::expose_experimental_interfaces()) {{
text = f""" if (HTML::WindowOrWorkerGlobalScopeMixin::expose_experimental_interface(Bindings::principal_host_defined_environment_settings_object(realm), "{experimental_name}"sv)) {{
{text} }}
"""
+28 -4
View File
@@ -251,6 +251,7 @@ namespace Web::Bindings {
write_secure_context_switch(out, interface_sets.intrinsics)
write_experimental_switch(out, interface_sets.intrinsics)
write_experimental_name_switch(out, interface_sets.intrinsics)
write_global_exposed_switch(out, "window", interface_sets.window_exposed)
write_global_exposed_switch(out, "dedicated_worker", interface_sets.dedicated_worker_exposed)
write_global_exposed_switch(out, "shared_worker", interface_sets.shared_worker_exposed)
@@ -281,8 +282,8 @@ bool is_exposed(InterfaceName name, JS::Realm& realm)
if (is_secure_context_interface(name) && HTML::is_non_secure_context(principal_host_defined_environment_settings_object(realm)))
return false;
// AD-HOC: Do not expose experimental interfaces unless instructed to do so.
if (!HTML::WindowOrWorkerGlobalScopeMixin::expose_experimental_interfaces() && is_experimental_interface(name))
// AD-HOC: Do not expose experimental interfaces unless the global switch or a site-compatibility rule says so.
if (is_experimental_interface(name) && !HTML::WindowOrWorkerGlobalScopeMixin::expose_experimental_interface(principal_host_defined_environment_settings_object(realm), experimental_interface_name(name)))
return false;
// FIXME: 3. If realm’s settings object’s cross-origin isolated capability is false, and construct is
@@ -329,6 +330,29 @@ def write_secure_context_switch(out: TextIO, interfaces: List[Interface]) -> Non
)
def write_experimental_name_switch(out: TextIO, interfaces: List[Interface]) -> None:
# The name a site-compatibility rule lists to expose the interface for the sites it matches.
out.write(
"""static constexpr StringView experimental_interface_name(InterfaceName name)
{
switch (name) {
"""
)
for interface in interfaces:
if "Experimental" not in interface.extended_attributes:
continue
out.write(f' case InterfaceName::{interface.name}:\n return "{interface.name}"sv;\n')
out.write(
""" default:
return {};
}
}
"""
)
def write_experimental_switch(out: TextIO, interfaces: List[Interface]) -> None:
out.write(
"""static constexpr bool is_experimental_interface(InterfaceName name)
@@ -602,7 +626,7 @@ void add_{snake_name}_exposed_interfaces(JS::Object& global)
static constexpr u8 attr = JS::Attribute::Writable | JS::Attribute::Configurable;
[[maybe_unused]] bool is_secure_context = HTML::is_secure_context(HTML::relevant_settings_object(global));
[[maybe_unused]] bool expose_experimental_interfaces = HTML::WindowOrWorkerGlobalScopeMixin::expose_experimental_interfaces();
[[maybe_unused]] auto& global_settings = HTML::relevant_settings_object(global);
"""
)
@@ -647,7 +671,7 @@ def write_interface_global_accessor(out: TextIO, class_name: str, interface: Int
if "Experimental" in interface.extended_attributes:
out.write(
f"""{indentation}if (expose_experimental_interfaces) {{
f"""{indentation}if (HTML::WindowOrWorkerGlobalScopeMixin::expose_experimental_interface(global_settings, "{interface.name}"sv)) {{
"""
)
indentation += " "
@@ -178,6 +178,7 @@ def define_the_regular_operations(
f""" object.define_native_function(realm, "{name}"_utf16_fly_string, {idl_identifier_cpp_name(operation)}, {overload_resolution.operation_overload_set_length(operations)}, default_attributes);
""",
f"{interface.name}.{name}",
)
)
@@ -212,6 +213,7 @@ def define_the_static_operations(out: TextIO, includes: GeneratedIncludes, inter
f""" object.define_native_function(realm, "{name}"_utf16_fly_string, {idl_identifier_cpp_name(operation)}, {overload_resolution.operation_overload_set_length(operations)}, JS::Attribute::Enumerable | JS::Attribute::Configurable | JS::Attribute::Writable);
""",
f"{interface.name}.{name}",
)
)
@@ -281,6 +283,7 @@ def define_the_stringifier(
extended_attributes,
""" object.define_native_function(realm, "toString"_utf16_fly_string, to_string, 0, default_attributes);
""",
f"{interface.name}.toString",
)
)
out.write(
@@ -700,6 +703,7 @@ def collect_attribute_values(
// 3. Perform ! CreateDataPropertyOrThrow(result, k, v).
MUST(result->create_data_property({key_name}, {js_value_name}));
""",
f"{interface.name}.{attribute.name}",
)
)
@@ -0,0 +1,4 @@
expose_experimental_interfaces as a string: rejected (InternalError)
user_agent as a string: rejected (InternalError)
matched document: navigator.permissions is object, PermissionStatus is function
unmatched document: navigator.permissions is undefined, PermissionStatus is undefined
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<script>
// A site-compatibility rule can hand named [Experimental] interfaces and members to the documents it matches — and
// to nothing else. Tests run with every experimental interface exposed, so the gate is switched off first; only
// the documents created after that see the real gate, and only the one a rule matches gets navigator.permissions
// back. (Workers live in a process this switch can't reach, so they aren't probed here; they take the same path
// with their own creation URL.)
asyncTest(async done => {
const server = httpTestServer();
const testID = crypto.randomUUID();
async function createDocument(name) {
const script = `parent.postMessage({ permissions: typeof navigator.permissions, status: typeof PermissionStatus }, "*");`;
return server.createEcho("GET", `/site-compatibility-expose-${name}-${testID}`, {
status: 200,
headers: { "Content-Type": "text/html" },
body: `<!DOCTYPE html><script>${script}<\/script>`,
});
}
function loadAndReport(name, url) {
return new Promise(resolve => {
const frame = document.createElement("iframe");
window.addEventListener("message", function handler(event) {
if (event.source !== frame.contentWindow)
return;
window.removeEventListener("message", handler);
frame.remove();
println(`${name}: navigator.permissions is ${event.data.permissions}, PermissionStatus is ${event.data.status}`);
resolve();
});
frame.src = url;
document.body.appendChild(frame);
});
}
const matchedURL = await createDocument("matched");
const unmatchedURL = await createDocument("unmatched");
// A rule with a field of the wrong type is rejected as a whole — not run with the half that parsed.
for (const [label, rule] of [
["expose_experimental_interfaces as a string", {
matches: [matchedURL],
user_agent: ["hide_Ladybird"],
expose_experimental_interfaces: "PermissionStatus",
}],
["user_agent as a string", {
matches: [matchedURL],
expose_experimental_interfaces: ["PermissionStatus"],
user_agent: "hide_Ladybird",
}],
]) {
try {
internals.setSiteCompatibilityData(JSON.stringify([rule]));
println(`${label}: accepted`);
} catch (error) {
println(`${label}: rejected (${error.name})`);
}
}
internals.setSiteCompatibilityData(JSON.stringify([{
matches: [matchedURL],
expose_experimental_interfaces: ["Navigator.permissions", "PermissionStatus"],
}]));
internals.setExperimentalInterfacesExposed(false);
try {
await loadAndReport("matched document", matchedURL);
await loadAndReport("unmatched document", unmatchedURL);
} finally {
internals.setExperimentalInterfacesExposed(true);
internals.setSiteCompatibilityData("[]");
}
done();
});
</script>
+1
View File
@@ -30,6 +30,7 @@ set(INTERNAL_RESOURCES
list(TRANSFORM INTERNAL_RESOURCES PREPEND "${LADYBIRD_SOURCE_DIR}/Base/res/ladybird/")
set(SITE_COMPATIBILITY_RESOURCES
aljazeera.com.json
cnn.com.json
nytimes.com.json
)
+11
View File
@@ -0,0 +1,11 @@
{
"matches": [
"https://aljazeera.com/*",
"https://*.aljazeera.com/*"
],
"expose_experimental_interfaces": [
"Navigator.permissions",
"WorkerNavigator.permissions",
"PermissionStatus"
]
}