ladybird/Libraries/LibWeb/ContentSecurityPolicy/Violation.cpp
Luke Wilde 86170f4bfd LibWeb/CSP: Introduce the ability to create and report a violation
A violation provides several details about an enforcement failing, such
as the URL of the document, the directive that returned "Blocked", etc.
2025-03-19 00:55:14 +01:00

480 lines
22 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright (c) 2025, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/ByteBuffer.h>
#include <LibURL/Parser.h>
#include <LibURL/URL.h>
#include <LibWeb/Bindings/PrincipalHostDefined.h>
#include <LibWeb/ContentSecurityPolicy/Directives/DirectiveOperations.h>
#include <LibWeb/ContentSecurityPolicy/Directives/Names.h>
#include <LibWeb/ContentSecurityPolicy/SecurityPolicyViolationEvent.h>
#include <LibWeb/ContentSecurityPolicy/Violation.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOMURL/DOMURL.h>
#include <LibWeb/Fetch/Fetching/Fetching.h>
#include <LibWeb/Fetch/Infrastructure/URL.h>
#include <LibWeb/HTML/Scripting/Environments.h>
#include <LibWeb/HTML/Scripting/TemporaryExecutionContext.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/HTML/WorkerGlobalScope.h>
#include <LibWeb/Infra/JSON.h>
namespace Web::ContentSecurityPolicy {
GC_DEFINE_ALLOCATOR(Violation);
Violation::Violation(GC::Ptr<JS::Object> global_object, GC::Ref<Policy const> policy, String directive)
: m_global_object(global_object)
, m_policy(policy)
, m_effective_directive(directive)
{
}
// https://w3c.github.io/webappsec-csp/#create-violation-for-global
GC::Ref<Violation> Violation::create_a_violation_object_for_global_policy_and_directive(JS::Realm& realm, GC::Ptr<JS::Object> global_object, GC::Ref<Policy const> policy, String directive)
{
// 1. Let violation be a new violation whose global object is global, policy is policy, effective directive is
// directive, and resource is null.
auto violation = realm.create<Violation>(global_object, policy, directive);
// FIXME: 2. If the user agent is currently executing script, and can extract a source files URL, line number,
// and column number from the global, set violations source file, line number, and column number
// accordingly.
// SPEC ISSUE 1: Is this kind of thing specified anywhere? I didnt see anything that looked useful in [ECMA262].
// 3. If global is a Window object, set violations referrer to globals document's referrer.
if (global_object) {
if (auto* window = dynamic_cast<HTML::Window*>(global_object.ptr())) {
violation->m_referrer = URL::Parser::basic_parse(window->associated_document().referrer());
}
}
// FIXME: 4. Set violations status to the HTTP status code for the resource associated with violations global object.
// SPEC ISSUE 2: How, exactly, do we get the status code? We dont actually store it anywhere.
// 5. Return violation.
return violation;
}
// https://w3c.github.io/webappsec-csp/#create-violation-for-request
GC::Ref<Violation> Violation::create_a_violation_object_for_request_and_policy(JS::Realm& realm, GC::Ref<Fetch::Infrastructure::Request> request, GC::Ref<Policy const> policy)
{
// 1. Let directive be the result of executing § 6.8.1 Get the effective directive for request on request.
auto directive = Directives::get_the_effective_directive_for_request(request);
// NOTE: The spec assumes that the effective directive of a Violation is a non-empty string.
// See the definition of m_effective_directive.
VERIFY(directive.has_value());
// 2. Let violation be the result of executing § 2.4.1 Create a violation object for global, policy, and directive
// on requests clients global object, policy, and directive.
auto violation = create_a_violation_object_for_global_policy_and_directive(realm, request->client()->global_object(), policy, directive->to_string());
// 3. Set violations resource to requests url.
// Spec Note: We use requests url, and not its current url, as the latter might contain information about redirect
// targets to which the page MUST NOT be given access.
violation->m_resource = request->url();
// 4. Return violation.
return violation;
}
void Violation::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
visitor.visit(m_global_object);
visitor.visit(m_policy);
visitor.visit(m_element);
}
// https://w3c.github.io/webappsec-csp/#violation-url
URL::URL Violation::url() const
{
// Each violation has a url which is its global objects URL.
if (!m_global_object) {
// FIXME: What do we return here?
dbgln("FIXME: Figure out URL for violation with null global object.");
return URL::URL {};
}
// FIXME: File a spec issue about what to do for ShadowRealms here.
auto* universal_scope = dynamic_cast<HTML::UniversalGlobalScopeMixin*>(m_global_object.ptr());
VERIFY(universal_scope);
auto& principal_global = HTML::relevant_principal_global_object(universal_scope->this_impl());
if (auto* window = dynamic_cast<HTML::Window*>(&principal_global)) {
return window->associated_document().url();
}
if (auto* worker = dynamic_cast<HTML::WorkerGlobalScope*>(&principal_global)) {
return worker->url();
}
TODO();
}
// https://w3c.github.io/webappsec-csp/#strip-url-for-use-in-reports
[[nodiscard]] static String strip_url_for_use_in_reports(URL::URL url)
{
// 1. If urls scheme is not an HTTP(S) scheme, then return urls scheme.
if (!Fetch::Infrastructure::is_http_or_https_scheme(url.scheme()))
return url.scheme();
// 2. Set urls fragment to the empty string.
// FIXME: File spec issue about potentially meaning `null` here, as using empty string leaves a stray # at the end.
url.set_fragment(OptionalNone {});
// 3. Set urls username to the empty string.
url.set_username(String {});
// 4. Set urls password to the empty string.
url.set_password(String {});
// 5. Return the result of executing the URL serializer on url.
return url.serialize();
}
// https://w3c.github.io/webappsec-csp/#obtain-violation-blocked-uri
String Violation::obtain_the_blocked_uri_of_resource() const
{
// 1. Assert: resource is a URL or a string.
VERIFY(m_resource.has<URL::URL>() || m_resource.has<Resource>());
// 2. If resource is a URL, return the result of executing § 5.4 Strip URL for use in reports on resource.
if (m_resource.has<URL::URL>()) {
auto const& url = m_resource.get<URL::URL>();
return strip_url_for_use_in_reports(url);
}
// 3. Return resource.
auto resource = m_resource.get<Resource>();
switch (resource) {
#define __ENUMERATE_RESOURCE_TYPE(type, value) \
case Resource::type: \
return value##_string;
ENUMERATE_RESOURCE_TYPES
#undef __ENUMERATE_RESOURCE_TYPE
default:
VERIFY_NOT_REACHED();
}
}
[[nodiscard]] static String original_disposition_to_string(Policy::Disposition disposition)
{
switch (disposition) {
#define __ENUMERATE_DISPOSITION_TYPE(type, value) \
case Policy::Disposition::type: \
return value##_string;
ENUMERATE_DISPOSITION_TYPES
#undef __ENUMERATE_DISPOSITION_TYPE
default:
VERIFY_NOT_REACHED();
}
}
// https://w3c.github.io/webappsec-csp/#deprecated-serialize-violation
ByteBuffer Violation::obtain_the_deprecated_serialization(JS::Realm& realm) const
{
// 1. Let body be a map with its keys initialized as follows:
Infra::JSONObject body;
// "document-uri"
// The result of executing § 5.4 Strip URL for use in reports on violation's url.
body.value.set("document-uri"_string, Infra::JSONValue { strip_url_for_use_in_reports(url()) });
// "referrer"
// The result of executing § 5.4 Strip URL for use in reports on violation's referrer.
// FIXME: File spec issue that referrer can be null here.
Infra::JSONValue referrer = m_referrer.has_value()
? Infra::JSONValue { strip_url_for_use_in_reports(m_referrer.value()) }
: Infra::JSONValue { Empty {} };
body.value.set("referrer"_string, referrer);
// "blocked-uri"
// The result of executing § 5.2 Obtain the blockedURI of a violations resource on violations resource.
body.value.set("blocked_uri"_string, Infra::JSONValue { obtain_the_blocked_uri_of_resource() });
// "effective-directive"
// violation's effective directive
body.value.set("effective-directive"_string, Infra::JSONValue { m_effective_directive });
// "violated-directive"
// violation's effective directive
body.value.set("violated-directive"_string, Infra::JSONValue { m_effective_directive });
// "original-policy"
// The serialization of violation's policy
body.value.set("original-policy"_string, Infra::JSONValue { m_policy->pre_parsed_policy_string({}) });
// "disposition"
// The disposition of violation's policy
body.value.set("disposition"_string, Infra::JSONValue { original_disposition_to_string(disposition()) });
// "status-code"
// violation's status
body.value.set("status-code"_string, Infra::JSONValue { m_status });
// "script-sample"
// violation's sample
// Spec Note: The name script-sample was chosen for compatibility with an earlier iteration of this feature which
// has shipped in Firefox since its initial implementation of CSP. Despite the name, this field will
// contain samples for non-script violations, like stylesheets. The data contained in a
// SecurityPolicyViolationEvent object, and in reports generated via the new report-to directive, is
// named in a more encompassing fashion: sample.
body.value.set("script-sample"_string, Infra::JSONValue { m_sample });
// 2. If violations source file is not null:
if (m_source_file.has_value()) {
// 1. Set body["source-file'] to the result of executing § 5.4 Strip URL for use in reports on violations
// source file.
body.value.set("source-file"_string, Infra::JSONValue { strip_url_for_use_in_reports(m_source_file.value()) });
// 2. Set body["line-number"] to violations line number.
body.value.set("line-number"_string, Infra::JSONValue { m_line_number });
// 3. Set body["column-number"] to violations column number.
body.value.set("column-number"_string, Infra::JSONValue { m_column_number });
}
// 3. Assert: If body["blocked-uri"] is not "inline", then body["sample"] is the empty string.
// FIXME: File spec issue that body["sample"] should be body["script-sample"]
if (m_resource.has<Resource>() && m_resource.get<Resource>() != Resource::Inline) {
VERIFY(m_sample.is_empty());
}
// 4. Return the result of serialize an infra value to JSON bytes given «[ "csp-report" → body ]».
Infra::JSONObject csp_report;
csp_report.value.set("csp-report"_string, Infra::JSONObject { move(body) });
HTML::TemporaryExecutionContext execution_context { realm };
return Infra::serialize_an_infra_value_to_json_bytes(realm, move(csp_report));
}
[[nodiscard]] static Bindings::SecurityPolicyViolationEventDisposition original_disposition_to_bindings_disposition(Policy::Disposition disposition)
{
switch (disposition) {
#define __ENUMERATE_DISPOSITION_TYPE(type, _) \
case Policy::Disposition::type: \
return Bindings::SecurityPolicyViolationEventDisposition::type;
ENUMERATE_DISPOSITION_TYPES
#undef __ENUMERATE_DISPOSITION_TYPE
default:
VERIFY_NOT_REACHED();
}
}
// https://w3c.github.io/webappsec-csp/#report-violation
void Violation::report_a_violation(JS::Realm& realm)
{
dbgln("Content Security Policy violation{}: Refusing access to resource '{}' because it does not appear in the '{}' directive.",
disposition() == Policy::Disposition::Report ? " (report only)"sv : ""sv,
obtain_the_blocked_uri_of_resource(),
m_effective_directive);
// 1. Let global be violations global object.
auto global = m_global_object;
// 2. Let target be violations element.
auto target = m_element;
// 3. Queue a task to run the following steps:
// Spec Note: We "queue a task" here to ensure that the event targeting and dispatch happens after JavaScript
// completes execution of the task responsible for a given violation (which might manipulate the DOM).
HTML::queue_a_task(HTML::Task::Source::Unspecified, nullptr, nullptr, GC::create_function(realm.heap(), [this, global, target, &realm] {
auto& vm = realm.vm();
GC::Ptr<JS::Object> target_as_object = target;
// 1. If target is not null, and global is a Window, and targets shadow-including root is not globals
// associated Document, set target to null.
// Spec Note: This ensures that we fire events only at elements connected to violations policys Document.
// If a violation is caused by an element which isnt connected to that document, well fire the
// event at the document rather than the element in order to ensure that the violation is visible
// to the documents listeners.
if (target && is<HTML::Window>(global.ptr())) {
auto const& window = static_cast<HTML::Window const&>(*global.ptr());
if (&target->shadow_including_root() != &window.associated_document())
target_as_object = nullptr;
}
// 2. If target is null:
if (!target_as_object) {
// 1. Set target to violations global object.
target_as_object = m_global_object;
// 2. If target is a Window, set target to targets associated Document.
if (is<HTML::Window>(target_as_object.ptr())) {
auto& window = static_cast<HTML::Window&>(*target_as_object.ptr());
target_as_object = window.associated_document();
}
}
// 3. If target implements EventTarget, fire an event named securitypolicyviolation that uses the
// SecurityPolicyViolationEvent interface at target with its attributes initialized as follows:
if (is<DOM::EventTarget>(target_as_object.ptr())) {
auto& event_target = static_cast<DOM::EventTarget&>(*target_as_object.ptr());
SecurityPolicyViolationEventInit event_init {};
// bubbles
// true
event_init.bubbles = true;
// composed
// true
// Spec Note: We set the composed attribute, which means that this event can be captured on its way
// into, and will bubble its way out of a shadow tree. target, et al will be automagically
// scoped correctly for the main tree.
event_init.composed = true;
// documentURI
// The result of executing § 5.4 Strip URL for use in reports on violation's url.
event_init.document_uri = strip_url_for_use_in_reports(url());
// referrer
// The result of executing § 5.4 Strip URL for use in reports on violation's referrer.
// FIXME: File spec issue for referrer being potentially null.
event_init.referrer = m_referrer.has_value() ? strip_url_for_use_in_reports(m_referrer.value()) : String {};
// blockedURI
// The result of executing § 5.2 Obtain the blockedURI of a violation's resource on violations
// resource.
event_init.blocked_uri = obtain_the_blocked_uri_of_resource();
// effectiveDirective
// violation's effective directive
event_init.effective_directive = m_effective_directive;
// violatedDirective
// violation's effective directive
// Spec Note: Both effectiveDirective and violatedDirective are the same value. This is intentional
// to maintain backwards compatibility.
event_init.violated_directive = m_effective_directive;
// originalPolicy
// The serialization of violation's policy
event_init.original_policy = m_policy->pre_parsed_policy_string({});
// disposition
// violation's disposition
event_init.disposition = original_disposition_to_bindings_disposition(disposition());
// sourceFile
// The result of executing § 5.4 Strip URL for use in reports on violations source file, if
// violation's source file is not null, or null otherwise.
event_init.source_file = m_source_file.has_value() ? strip_url_for_use_in_reports(m_source_file.value()) : String {};
// statusCode
// violation's status
event_init.status_code = m_status;
// lineNumber
// violations line number
event_init.line_number = m_line_number;
// columnNumber
// violations column number
event_init.column_number = m_column_number;
// sample
// violation's sample
event_init.sample = m_sample;
auto event = SecurityPolicyViolationEvent::create(realm, HTML::EventNames::securitypolicyviolation, event_init);
event->set_is_trusted(true);
event_target.dispatch_event(event);
}
// 4. If violations policys directive set contains a directive named "report-uri" directive:
if (auto report_uri_directive = m_policy->get_directive_by_name(Directives::Names::ReportUri)) {
// 1. If violations policys directive set contains a directive named "report-to", skip the remaining
// substeps.
if (!m_policy->contains_directive_with_name(Directives::Names::ReportTo)) {
// 1. For each token of directives value:
for (auto const& token : report_uri_directive->value()) {
// 1. Let endpoint be the result of executing the URL parser with token as the input, and
// violations url as the base URL.
auto endpoint = DOMURL::parse(token, url());
// 2. If endpoint is not a valid URL, skip the remaining substeps.
if (endpoint.has_value()) {
// 3. Let request be a new request, initialized as follows:
auto request = Fetch::Infrastructure::Request::create(vm);
// method
// "POST"
request->set_method(MUST(ByteBuffer::copy("POST"sv.bytes())));
// url
// violations url
// FIXME: File spec issue that this is incorrect, it should be `endpoint` instead.
request->set_url(endpoint.value());
// origin
// violation's global object's relevant settings object's origin
// FIXME: File spec issue that global object can be null, so we use the realm to get the ESO
// instead, and cross ShadowRealm boundaries with the principal realm.
auto& environment_settings_object = Bindings::principal_host_defined_environment_settings_object(HTML::principal_realm(realm));
request->set_origin(environment_settings_object.origin());
// window
// "no-window"
request->set_window(Fetch::Infrastructure::Request::Window::NoWindow);
// client
// violation's global object's relevant settings object
request->set_client(&environment_settings_object);
// destination
// "report"
request->set_destination(Fetch::Infrastructure::Request::Destination::Report);
// initiator
// ""
request->set_initiator(OptionalNone {});
// credentials mode
// "same-origin"
request->set_credentials_mode(Fetch::Infrastructure::Request::CredentialsMode::SameOrigin);
// keepalive
// "true"
request->set_keepalive(true);
// header list
// A header list containing a single header whose name is "Content-Type", and value is
// "application/csp-report"
auto header_list = Fetch::Infrastructure::HeaderList::create(vm);
auto content_type_header = Fetch::Infrastructure::Header::from_string_pair("Content-Type"sv, "application/csp-report"sv);
header_list->append(move(content_type_header));
request->set_header_list(header_list);
// body
// The result of executing § 5.3 Obtain the deprecated serialization of violation on
// violation
request->set_body(obtain_the_deprecated_serialization(realm));
// redirect mode
// "error"
request->set_redirect_mode(Fetch::Infrastructure::Request::RedirectMode::Error);
// 4. Fetch request. The result will be ignored.
(void)Fetch::Fetching::fetch(realm, request, Fetch::Infrastructure::FetchAlgorithms::create(vm, {}));
}
}
}
// 5. If violation's policy's directive set contains a directive named "report-to" directive:
if (auto report_to_directive = m_policy->get_directive_by_name(Directives::Names::ReportTo)) {
(void)report_to_directive;
dbgln("FIXME: Implement report-to directive in violation reporting");
}
}
}));
}
}