Support CSP report-only header (#36623)

This turned out to be a full rabbit hole. The new header
is parsed in the new `parse_csp_list_from_metadata` which
sets `disposition` to `report.

I was testing this with
`script-src-report-only-policy-works-with-external-hash-policy.html`
which was blocking the script incorrectly. Turns out that there
were multiple bugs in the CSP library, as well as a missing
check in `fetch` to report violations.

Additionally, in several locations we were manually reporting csp
violations, instead of the new `global.report_csp_violations`. As
a result of that, they would double report, since the report-only
header would be appended as a policy and now would report twice.

Now, all callsides use `global.report_csp_violations`. As a nice
side-effect, I added the code to set source file information,
since that was already present for the `eval` check, but nowhere
else.

Part of #36437

Requires servo/rust-content-security-policy#5

---------

Signed-off-by: Tim van der Lippe <tvanderlippe@gmail.com>
Signed-off-by: Tim van der Lippe <TimvdLippe@users.noreply.github.com>
This commit is contained in:
Tim van der Lippe 2025-04-25 21:59:44 +02:00 committed by GitHub
parent 4ff45f86b9
commit baa18e18af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 113 additions and 208 deletions

View file

@ -2422,7 +2422,8 @@ impl GlobalScope {
headers: &Option<Serde<HeaderMap>>,
) -> Option<CspList> {
// TODO: Implement step 1 (local scheme special case)
let mut csp = headers.as_ref()?.get_all("content-security-policy").iter();
let headers = headers.as_ref()?;
let mut csp = headers.get_all("content-security-policy").iter();
// This silently ignores the CSP if it contains invalid Unicode.
// We should probably report an error somewhere.
let c = csp.next().and_then(|c| c.to_str().ok())?;
@ -2435,6 +2436,19 @@ impl GlobalScope {
PolicyDisposition::Enforce,
));
}
let csp_report = headers
.get_all("content-security-policy-report-only")
.iter();
// This silently ignores the CSP if it contains invalid Unicode.
// We should probably report an error somewhere.
for c in csp_report {
let c = c.to_str().ok()?;
csp_list.append(CspList::parse(
c,
PolicySource::Header,
PolicyDisposition::Report,
));
}
Some(csp_list)
}
@ -2822,36 +2836,16 @@ impl GlobalScope {
}))
}
#[allow(unsafe_code)]
pub(crate) fn is_js_evaluation_allowed(&self, cx: SafeJSContext) -> bool {
pub(crate) fn is_js_evaluation_allowed(&self, source: &str) -> bool {
let Some(csp_list) = self.get_csp_list() else {
return true;
};
let scripted_caller = unsafe { describe_scripted_caller(*cx) }.unwrap_or_default();
let is_js_evaluation_allowed = csp_list.is_js_evaluation_allowed() == CheckResult::Allowed;
let (is_js_evaluation_allowed, violations) = csp_list.is_js_evaluation_allowed(source);
if !is_js_evaluation_allowed {
// FIXME: Don't fire event if `script-src` and `default-src`
// were not passed.
for policy in csp_list.0 {
let report = CSPViolationReportBuilder::default()
.resource("eval".to_owned())
.effective_directive("script-src".to_owned())
.report_only(policy.disposition == PolicyDisposition::Report)
.source_file(scripted_caller.filename.clone())
.line_number(scripted_caller.line)
.column_number(scripted_caller.col)
.build(self);
let task = CSPViolationReportTask::new(self, report);
self.report_csp_violations(violations);
self.task_manager()
.dom_manipulation_task_source()
.queue(task);
}
}
is_js_evaluation_allowed
is_js_evaluation_allowed == CheckResult::Allowed
}
pub(crate) fn create_image_bitmap(
@ -3464,10 +3458,13 @@ impl GlobalScope {
unreachable!();
}
#[allow(unsafe_code)]
pub(crate) fn report_csp_violations(&self, violations: Vec<Violation>) {
let scripted_caller =
unsafe { describe_scripted_caller(*GlobalScope::get_cx()) }.unwrap_or_default();
for violation in violations {
let (sample, resource) = match violation.resource {
ViolationResource::Inline { .. } => (None, "inline".to_owned()),
ViolationResource::Inline { sample } => (sample, "inline".to_owned()),
ViolationResource::Url(url) => (None, url.into()),
ViolationResource::TrustedTypePolicy { sample } => {
(Some(sample), "trusted-types-policy".to_owned())
@ -3475,6 +3472,8 @@ impl GlobalScope {
ViolationResource::TrustedTypeSink { sample } => {
(Some(sample), "trusted-types-sink".to_owned())
},
ViolationResource::Eval { sample } => (sample, "eval".to_owned()),
ViolationResource::WasmEval => (None, "wasm-eval".to_owned()),
};
let report = CSPViolationReportBuilder::default()
.resource(resource)
@ -3482,6 +3481,9 @@ impl GlobalScope {
.effective_directive(violation.directive.name)
.original_policy(violation.policy.to_string())
.report_only(violation.policy.disposition == PolicyDisposition::Report)
.source_file(scripted_caller.filename.clone())
.line_number(scripted_caller.line)
.column_number(scripted_caller.col + 1)
.build(self);
let task = CSPViolationReportTask::new(self, report);
self.task_manager()

View file

@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
use std::{os, ptr, thread};
use background_hang_monitor_api::ScriptHangAnnotation;
use content_security_policy::{CheckResult, PolicyDisposition};
use content_security_policy::CheckResult;
use js::conversions::jsstr_to_string;
use js::glue::{
CollectServoSizes, CreateJobQueue, DeleteJobQueue, DispatchableRun, JobQueueTraps,
@ -45,7 +45,7 @@ pub(crate) use js::rust::ThreadSafeJSContext;
use js::rust::wrappers::{GetPromiseIsHandled, JS_GetPromiseResult};
use js::rust::{
Handle, HandleObject as RustHandleObject, IntoHandle, JSEngine, JSEngineHandle, ParentRuntime,
Runtime as RustRuntime, describe_scripted_caller,
Runtime as RustRuntime,
};
use malloc_size_of::MallocSizeOfOps;
use malloc_size_of_derive::MallocSizeOf;
@ -82,7 +82,6 @@ use crate::microtask::{EnqueuedPromiseCallback, Microtask, MicrotaskQueue};
use crate::realms::{AlreadyInRealm, InRealm};
use crate::script_module::EnsureModuleHooksInitialized;
use crate::script_thread::trace_thread;
use crate::security_manager::{CSPViolationReportBuilder, CSPViolationReportTask};
use crate::task_source::SendableTaskSource;
static JOB_QUEUE_TRAPS: JobQueueTraps = JobQueueTraps {
@ -373,10 +372,6 @@ unsafe extern "C" fn content_security_policy_allows(
let cx = JSContext::from_ptr(cx);
wrap_panic(&mut || {
// SpiderMonkey provides null pointer when executing webassembly.
let sample = match sample {
sample if !sample.is_null() => Some(jsstr_to_string(*cx, *sample)),
_ => None,
};
let in_realm_proof = AlreadyInRealm::assert_for_cx(cx);
let global = GlobalScope::from_context(*cx, InRealm::Already(&in_realm_proof));
let Some(csp_list) = global.get_csp_list() else {
@ -384,43 +379,19 @@ unsafe extern "C" fn content_security_policy_allows(
return;
};
let is_js_evaluation_allowed = csp_list.is_js_evaluation_allowed() == CheckResult::Allowed;
let is_wasm_evaluation_allowed =
csp_list.is_wasm_evaluation_allowed() == CheckResult::Allowed;
let scripted_caller = describe_scripted_caller(*cx).unwrap_or_default();
let resource = match runtime_code {
RuntimeCode::JS => "eval".to_owned(),
RuntimeCode::WASM => "wasm-eval".to_owned(),
let (is_evaluation_allowed, violations) = match runtime_code {
RuntimeCode::JS => {
let source = match sample {
sample if !sample.is_null() => &jsstr_to_string(*cx, *sample),
_ => "",
};
csp_list.is_js_evaluation_allowed(source)
},
RuntimeCode::WASM => csp_list.is_wasm_evaluation_allowed(),
};
allowed = match runtime_code {
RuntimeCode::JS if is_js_evaluation_allowed => true,
RuntimeCode::WASM if is_wasm_evaluation_allowed => true,
_ => false,
};
if !allowed {
// FIXME: Don't fire event if `script-src` and `default-src`
// were not passed.
for policy in csp_list.0 {
let report = CSPViolationReportBuilder::default()
.resource(resource.clone())
.sample(sample.clone())
.report_only(policy.disposition == PolicyDisposition::Report)
.source_file(scripted_caller.filename.clone())
.line_number(scripted_caller.line)
.column_number(scripted_caller.col)
.effective_directive("script-src".to_owned())
.build(&global);
let task = CSPViolationReportTask::new(&global, report);
global
.task_manager()
.dom_manipulation_task_source()
.queue(task);
}
}
global.report_csp_violations(violations);
allowed = is_evaluation_allowed == CheckResult::Allowed;
});
allowed
}

View file

@ -421,8 +421,7 @@ impl JsTimers {
) -> i32 {
let callback = match callback {
TimerCallback::StringTimerCallback(code_str) => {
let cx = GlobalScope::get_cx();
if global.is_js_evaluation_allowed(cx) {
if global.is_js_evaluation_allowed(code_str.as_ref()) {
InternalTimerCallback::StringTimerCallback(code_str)
} else {
return 0;