use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt::{self, Write};
use chrono::{DateTime, Utc};
use relay_protocol::{Annotated, Array, Empty, FromValue, IntoValue, Object, Value};
use serde::de::{self, Error, IgnoredAny};
use serde::{Deserialize, Deserializer, Serialize};
use url::Url;
use crate::processor::ProcessValue;
use crate::protocol::{
Event, HeaderName, HeaderValue, Headers, LogEntry, PairList, Request, TagEntry, Tags,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct InvalidSecurityError;
impl fmt::Display for InvalidSecurityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid security report")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CspDirective {
BaseUri,
ChildSrc,
ConnectSrc,
DefaultSrc,
FontSrc,
FormAction,
FrameAncestors,
FrameSrc,
ImgSrc,
ManifestSrc,
MediaSrc,
ObjectSrc,
PluginTypes,
PrefetchSrc,
Referrer,
ScriptSrc,
ScriptSrcAttr,
ScriptSrcElem,
StyleSrc,
StyleSrcElem,
StyleSrcAttr,
UpgradeInsecureRequests,
WorkerSrc,
Sandbox,
NavigateTo,
ReportUri,
ReportTo,
BlockAllMixedContent,
RequireSriFor,
RequireTrustedTypesFor,
TrustedTypes,
}
relay_common::derive_fromstr_and_display!(CspDirective, InvalidSecurityError, {
CspDirective::BaseUri => "base-uri",
CspDirective::ChildSrc => "child-src",
CspDirective::ConnectSrc => "connect-src",
CspDirective::DefaultSrc => "default-src",
CspDirective::FontSrc => "font-src",
CspDirective::FormAction => "form-action",
CspDirective::FrameAncestors => "frame-ancestors",
CspDirective::FrameSrc => "frame-src",
CspDirective::ImgSrc => "img-src",
CspDirective::ManifestSrc => "manifest-src",
CspDirective::MediaSrc => "media-src",
CspDirective::ObjectSrc => "object-src",
CspDirective::PluginTypes => "plugin-types",
CspDirective::PrefetchSrc => "prefetch-src",
CspDirective::Referrer => "referrer",
CspDirective::ScriptSrc => "script-src",
CspDirective::ScriptSrcAttr => "script-src-attr",
CspDirective::ScriptSrcElem => "script-src-elem",
CspDirective::StyleSrc => "style-src",
CspDirective::StyleSrcElem => "style-src-elem",
CspDirective::StyleSrcAttr => "style-src-attr",
CspDirective::UpgradeInsecureRequests => "upgrade-insecure-requests",
CspDirective::WorkerSrc => "worker-src",
CspDirective::Sandbox => "sandbox",
CspDirective::NavigateTo => "navigate-to",
CspDirective::ReportUri => "report-uri",
CspDirective::ReportTo => "report-to",
CspDirective::BlockAllMixedContent => "block-all-mixed-content",
CspDirective::RequireSriFor => "require-sri-for",
CspDirective::RequireTrustedTypesFor => "require-trusted-types-for",
CspDirective::TrustedTypes => "trusted-types",
});
relay_common::impl_str_serde!(CspDirective, "a csp directive");
fn is_local(uri: &str) -> bool {
matches!(uri, "" | "self" | "'self'")
}
fn schema_uses_host(schema: &str) -> bool {
matches!(
schema,
"ftp"
| "http"
| "gopher"
| "nntp"
| "telnet"
| "imap"
| "wais"
| "file"
| "mms"
| "https"
| "shttp"
| "snews"
| "prospero"
| "rtsp"
| "rtspu"
| "rsync"
| "svn"
| "svn+ssh"
| "sftp"
| "nfs"
| "git"
| "git+ssh"
| "ws"
| "wss"
)
}
fn unsplit_uri(schema: &str, host: &str) -> String {
if !host.is_empty() || schema_uses_host(schema) {
format!("{schema}://{host}")
} else if !schema.is_empty() {
format!("{schema}:{host}")
} else {
String::new()
}
}
fn normalize_uri(value: &str) -> Cow<'_, str> {
if is_local(value) {
return Cow::Borrowed("'self'");
}
if !value.contains(':') {
return Cow::Owned(unsplit_uri(value, ""));
}
let url = match Url::parse(value) {
Ok(url) => url,
Err(_) => return Cow::Borrowed(value),
};
let normalized = match url.scheme() {
"http" | "https" => Cow::Borrowed(url.host_str().unwrap_or_default()),
scheme => Cow::Owned(unsplit_uri(scheme, url.host_str().unwrap_or_default())),
};
Cow::Owned(match url.port() {
Some(port) => format!("{normalized}:{port}"),
None => normalized.into_owned(),
})
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
struct CspRaw {
#[serde(
skip_serializing_if = "Option::is_none",
alias = "effective-directive",
alias = "effectiveDirective"
)]
effective_directive: Option<String>,
#[serde(
default = "CspRaw::default_blocked_uri",
alias = "blockedURL",
alias = "blocked-uri"
)]
blocked_uri: String,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "documentURL",
alias = "document-uri"
)]
document_uri: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "originalPolicy",
alias = "original-policy"
)]
original_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
referrer: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "statusCode",
alias = "status-code",
deserialize_with = "de_opt_num_or_str"
)]
status_code: Option<u64>,
#[serde(
default = "String::new",
alias = "violatedDirective",
alias = "violated-directive"
)]
violated_directive: String,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "sourceFile",
alias = "source-file"
)]
source_file: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "lineNumber",
alias = "line-number",
deserialize_with = "de_opt_num_or_str"
)]
line_number: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "columnNumber",
alias = "column-number",
deserialize_with = "de_opt_num_or_str"
)]
column_number: Option<u64>,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "scriptSample",
alias = "script-sample"
)]
script_sample: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
disposition: Option<String>,
#[serde(flatten)]
other: BTreeMap<String, serde_json::Value>,
}
fn de_opt_num_or_str<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr<'a> {
Num(u64),
Str(Cow<'a, str>),
}
Option::<NumOrStr>::deserialize(deserializer)?
.map(|status_code| match status_code {
NumOrStr::Num(num) => Ok(num),
NumOrStr::Str(s) => s.parse(),
})
.transpose()
.map_err(de::Error::custom)
}
impl CspRaw {
fn default_blocked_uri() -> String {
"self".to_string()
}
fn effective_directive(&self) -> Result<CspDirective, InvalidSecurityError> {
if let Some(directive) = &self.effective_directive {
if let Ok(parsed_directive) = directive
.split_once(' ')
.map_or(directive.as_str(), |s| s.0)
.parse()
{
return Ok(parsed_directive);
}
}
if let Ok(parsed_directive) = self
.violated_directive
.split_once(' ')
.map_or(self.violated_directive.as_str(), |s| s.0)
.parse()
{
Ok(parsed_directive)
} else {
Err(InvalidSecurityError)
}
}
fn get_message(&self, effective_directive: CspDirective) -> String {
if is_local(&self.blocked_uri) {
match effective_directive {
CspDirective::ChildSrc => "Blocked inline 'child'".to_string(),
CspDirective::ConnectSrc => "Blocked inline 'connect'".to_string(),
CspDirective::FontSrc => "Blocked inline 'font'".to_string(),
CspDirective::ImgSrc => "Blocked inline 'image'".to_string(),
CspDirective::ManifestSrc => "Blocked inline 'manifest'".to_string(),
CspDirective::MediaSrc => "Blocked inline 'media'".to_string(),
CspDirective::ObjectSrc => "Blocked inline 'object'".to_string(),
CspDirective::ScriptSrcAttr => "Blocked unsafe 'script' element".to_string(),
CspDirective::ScriptSrcElem => "Blocked inline script attribute".to_string(),
CspDirective::StyleSrc => "Blocked inline 'style'".to_string(),
CspDirective::StyleSrcElem => "Blocked 'style' or 'link' element".to_string(),
CspDirective::StyleSrcAttr => "Blocked style attribute".to_string(),
CspDirective::ScriptSrc => {
if self.violated_directive.contains("'unsafe-inline'") {
"Blocked unsafe inline 'script'".to_string()
} else if self.violated_directive.contains("'unsafe-eval'") {
"Blocked unsafe eval() 'script'".to_string()
} else {
"Blocked unsafe (eval() or inline) 'script'".to_string()
}
}
directive => format!("Blocked inline '{directive}'"),
}
} else {
let uri = normalize_uri(&self.blocked_uri);
match effective_directive {
CspDirective::ChildSrc => format!("Blocked 'child' from '{uri}'"),
CspDirective::ConnectSrc => format!("Blocked 'connect' from '{uri}'"),
CspDirective::FontSrc => format!("Blocked 'font' from '{uri}'"),
CspDirective::FormAction => format!("Blocked 'form' action to '{uri}'"),
CspDirective::ImgSrc => format!("Blocked 'image' from '{uri}'"),
CspDirective::ManifestSrc => format!("Blocked 'manifest' from '{uri}'"),
CspDirective::MediaSrc => format!("Blocked 'media' from '{uri}'"),
CspDirective::ObjectSrc => format!("Blocked 'object' from '{uri}'"),
CspDirective::ScriptSrc => format!("Blocked 'script' from '{uri}'"),
CspDirective::ScriptSrcAttr => {
format!("Blocked inline script attribute from '{uri}'")
}
CspDirective::ScriptSrcElem => format!("Blocked 'script' from '{uri}'"),
CspDirective::StyleSrc => format!("Blocked 'style' from '{uri}'"),
CspDirective::StyleSrcElem => format!("Blocked 'style' from '{uri}'"),
CspDirective::StyleSrcAttr => format!("Blocked style attribute from '{uri}'"),
directive => format!("Blocked '{directive}' from '{uri}'"),
}
}
}
fn into_protocol(self, effective_directive: CspDirective) -> Csp {
Csp {
effective_directive: Annotated::from(effective_directive.to_string()),
blocked_uri: Annotated::from(self.blocked_uri),
document_uri: Annotated::from(self.document_uri),
original_policy: Annotated::from(self.original_policy),
referrer: Annotated::from(self.referrer),
status_code: Annotated::from(self.status_code),
violated_directive: Annotated::from(self.violated_directive),
source_file: Annotated::from(self.source_file),
line_number: Annotated::from(self.line_number),
column_number: Annotated::from(self.column_number),
script_sample: Annotated::from(self.script_sample),
disposition: Annotated::from(self.disposition),
other: self
.other
.into_iter()
.map(|(k, v)| (k, Annotated::from(v)))
.collect(),
}
}
fn sanitized_blocked_uri(&self) -> String {
let mut uri = self.blocked_uri.clone();
if uri.starts_with("https://api.stripe.com/") {
if let Some(index) = uri.find(&['#', '?'][..]) {
uri.truncate(index);
}
}
uri
}
fn normalize_value<'a>(&self, value: &'a str, document_uri: &str) -> Cow<'a, str> {
if let "'none'" | "'self'" | "'unsafe-inline'" | "'unsafe-eval'" = value {
return Cow::Borrowed(value);
}
if value.starts_with("data:")
|| value.starts_with("mediastream:")
|| value.starts_with("blob:")
|| value.starts_with("filesystem:")
|| value.starts_with("http:")
|| value.starts_with("https:")
|| value.starts_with("file:")
{
if document_uri == normalize_uri(value) {
return Cow::Borrowed("'self'");
}
return Cow::Borrowed(value);
}
if value == document_uri {
return Cow::Borrowed("'self'");
}
let original_uri = self.document_uri.as_deref().unwrap_or_default();
match original_uri.split_once(':').map(|x| x.0) {
None | Some("http" | "https") => Cow::Borrowed(value),
Some(scheme) => Cow::Owned(unsplit_uri(scheme, value)),
}
}
fn get_culprit(&self) -> String {
if self.violated_directive.is_empty() {
return String::new();
}
let mut bits = self.violated_directive.split_ascii_whitespace();
let mut culprit = bits.next().unwrap_or_default().to_owned();
let document_uri = self.document_uri.as_deref().unwrap_or("");
let normalized_uri = normalize_uri(document_uri);
for bit in bits {
write!(culprit, " {}", self.normalize_value(bit, &normalized_uri)).ok();
}
culprit
}
fn get_tags(&self, effective_directive: CspDirective) -> Tags {
Tags(PairList::from(vec![
Annotated::new(TagEntry(
Annotated::new("effective-directive".to_string()),
Annotated::new(effective_directive.to_string()),
)),
Annotated::new(TagEntry(
Annotated::new("blocked-uri".to_string()),
Annotated::new(self.sanitized_blocked_uri()),
)),
]))
}
fn get_request(&self) -> Request {
let headers = match self.referrer {
Some(ref referrer) if !referrer.is_empty() => {
Annotated::new(Headers(PairList(vec![Annotated::new((
Annotated::new(HeaderName::new("Referer")),
Annotated::new(HeaderValue::new(referrer.clone())),
))])))
}
Some(_) | None => Annotated::empty(),
};
Request {
url: Annotated::from(self.document_uri.clone()),
headers,
..Request::default()
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
struct CspReportRaw {
csp_report: CspRaw,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
enum CspVariant {
Csp {
#[serde(rename = "csp-report")]
csp_report: CspRaw,
},
CspViolation { body: CspRaw },
}
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum CspViolationType {
CspViolation,
#[serde(other)]
Other,
}
#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
pub struct Csp {
#[metastructure(pii = "true")]
pub effective_directive: Annotated<String>,
#[metastructure(pii = "true")]
pub blocked_uri: Annotated<String>,
#[metastructure(pii = "true")]
pub document_uri: Annotated<String>,
pub original_policy: Annotated<String>,
#[metastructure(pii = "true")]
pub referrer: Annotated<String>,
pub status_code: Annotated<u64>,
pub violated_directive: Annotated<String>,
pub source_file: Annotated<String>,
pub line_number: Annotated<u64>,
pub column_number: Annotated<u64>,
pub script_sample: Annotated<String>,
pub disposition: Annotated<String>,
#[metastructure(pii = "true", additional_properties)]
pub other: Object<Value>,
}
impl Csp {
pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
let variant = serde_json::from_slice::<CspVariant>(data)?;
match variant {
CspVariant::Csp { csp_report } => Csp::extract_report(event, csp_report)?,
CspVariant::CspViolation { body } => Csp::extract_report(event, body)?,
}
Ok(())
}
fn extract_report(event: &mut Event, raw_csp: CspRaw) -> Result<(), serde_json::Error> {
let effective_directive = raw_csp
.effective_directive()
.map_err(serde::de::Error::custom)?;
event.logentry = Annotated::new(LogEntry::from(raw_csp.get_message(effective_directive)));
event.culprit = Annotated::new(raw_csp.get_culprit());
event.tags = Annotated::new(raw_csp.get_tags(effective_directive));
event.request = Annotated::new(raw_csp.get_request());
event.csp = Annotated::new(raw_csp.into_protocol(effective_directive));
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExpectCtStatus {
Unknown,
Valid,
Invalid,
}
relay_common::derive_fromstr_and_display!(ExpectCtStatus, InvalidSecurityError, {
ExpectCtStatus::Unknown => "unknown",
ExpectCtStatus::Valid => "valid",
ExpectCtStatus::Invalid => "invalid",
});
relay_common::impl_str_serde!(ExpectCtStatus, "an expect-ct status");
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExpectCtSource {
TlsExtension,
Ocsp,
Embedded,
}
relay_common::derive_fromstr_and_display!(ExpectCtSource, InvalidSecurityError, {
ExpectCtSource::TlsExtension => "tls-extension",
ExpectCtSource::Ocsp => "ocsp",
ExpectCtSource::Embedded => "embedded",
});
relay_common::impl_str_serde!(ExpectCtSource, "an expect-ct source");
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
struct SingleCertificateTimestampRaw {
version: Option<i64>,
status: Option<ExpectCtStatus>,
source: Option<ExpectCtSource>,
serialized_sct: Option<String>, }
impl SingleCertificateTimestampRaw {
fn into_protocol(self) -> SingleCertificateTimestamp {
SingleCertificateTimestamp {
version: Annotated::from(self.version),
status: Annotated::from(self.status.map(|s| s.to_string())),
source: Annotated::from(self.source.map(|s| s.to_string())),
serialized_sct: Annotated::from(self.serialized_sct),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
struct ExpectCtRaw {
#[serde(with = "serde_date_time_3339")]
date_time: Option<DateTime<Utc>>,
hostname: String,
port: Option<i64>,
scheme: Option<String>,
#[serde(with = "serde_date_time_3339")]
effective_expiration_date: Option<DateTime<Utc>>,
served_certificate_chain: Option<Vec<String>>,
validated_certificate_chain: Option<Vec<String>>,
scts: Option<Vec<SingleCertificateTimestampRaw>>,
failure_mode: Option<String>,
test_report: Option<bool>,
}
mod serde_date_time_3339 {
use serde::de::Visitor;
use super::*;
pub fn serialize<S>(date_time: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match date_time {
None => serializer.serialize_none(),
Some(d) => serializer.serialize_str(&d.to_rfc3339()),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct DateTimeVisitor;
impl Visitor<'_> for DateTimeVisitor {
type Value = Option<DateTime<Utc>>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("expected a date-time in RFC 3339 format")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
DateTime::parse_from_rfc3339(s)
.map(|d| Some(d.with_timezone(&Utc)))
.map_err(serde::de::Error::custom)
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: Error,
{
Ok(None)
}
}
deserializer.deserialize_any(DateTimeVisitor)
}
}
impl ExpectCtRaw {
fn get_message(&self) -> String {
format!("Expect-CT failed for '{}'", self.hostname)
}
fn into_protocol(self) -> ExpectCt {
ExpectCt {
date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
hostname: Annotated::from(self.hostname),
port: Annotated::from(self.port),
scheme: Annotated::from(self.scheme),
effective_expiration_date: Annotated::from(
self.effective_expiration_date.map(|d| d.to_rfc3339()),
),
served_certificate_chain: Annotated::new(
self.served_certificate_chain
.map(|s| s.into_iter().map(Annotated::from).collect())
.unwrap_or_default(),
),
validated_certificate_chain: Annotated::new(
self.validated_certificate_chain
.map(|v| v.into_iter().map(Annotated::from).collect())
.unwrap_or_default(),
),
scts: Annotated::from(self.scts.map(|scts| {
scts.into_iter()
.map(|elm| Annotated::from(elm.into_protocol()))
.collect()
})),
failure_mode: Annotated::from(self.failure_mode),
test_report: Annotated::from(self.test_report),
}
}
fn get_culprit(&self) -> String {
self.hostname.clone()
}
fn get_tags(&self) -> Tags {
let mut tags = vec![Annotated::new(TagEntry(
Annotated::new("hostname".to_string()),
Annotated::new(self.hostname.clone()),
))];
if let Some(port) = self.port {
tags.push(Annotated::new(TagEntry(
Annotated::new("port".to_string()),
Annotated::new(port.to_string()),
)));
}
Tags(PairList::from(tags))
}
fn get_request(&self) -> Request {
Request {
url: Annotated::from(self.hostname.clone()),
..Request::default()
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
struct ExpectCtReportRaw {
expect_ct_report: ExpectCtRaw,
}
#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
pub struct SingleCertificateTimestamp {
pub version: Annotated<i64>,
pub status: Annotated<String>,
pub source: Annotated<String>,
pub serialized_sct: Annotated<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
pub struct ExpectCt {
pub date_time: Annotated<String>,
pub hostname: Annotated<String>,
pub port: Annotated<i64>,
pub scheme: Annotated<String>,
pub effective_expiration_date: Annotated<String>,
pub served_certificate_chain: Annotated<Array<String>>,
pub validated_certificate_chain: Annotated<Array<String>>,
pub scts: Annotated<Array<SingleCertificateTimestamp>>,
pub failure_mode: Annotated<String>,
pub test_report: Annotated<bool>,
}
impl ExpectCt {
pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
let raw_report = serde_json::from_slice::<ExpectCtReportRaw>(data)?;
let raw_expect_ct = raw_report.expect_ct_report;
event.logentry = Annotated::new(LogEntry::from(raw_expect_ct.get_message()));
event.culprit = Annotated::new(raw_expect_ct.get_culprit());
event.tags = Annotated::new(raw_expect_ct.get_tags());
event.request = Annotated::new(raw_expect_ct.get_request());
event.expectct = Annotated::new(raw_expect_ct.into_protocol());
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
struct HpkpRaw {
#[serde(skip_serializing_if = "Option::is_none")]
date_time: Option<DateTime<Utc>>,
hostname: String,
#[serde(skip_serializing_if = "Option::is_none")]
port: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
effective_expiration_date: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
include_subdomains: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
noted_hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
served_certificate_chain: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
validated_certificate_chain: Option<Vec<String>>,
known_pins: Vec<String>,
#[serde(flatten)]
other: BTreeMap<String, serde_json::Value>,
}
impl HpkpRaw {
fn get_message(&self) -> String {
format!(
"Public key pinning validation failed for '{}'",
self.hostname
)
}
fn into_protocol(self) -> Hpkp {
Hpkp {
date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
hostname: Annotated::new(self.hostname),
port: Annotated::from(self.port),
effective_expiration_date: Annotated::from(
self.effective_expiration_date.map(|d| d.to_rfc3339()),
),
include_subdomains: Annotated::from(self.include_subdomains),
noted_hostname: Annotated::from(self.noted_hostname),
served_certificate_chain: Annotated::from(
self.served_certificate_chain
.map(|chain| chain.into_iter().map(Annotated::from).collect()),
),
validated_certificate_chain: Annotated::from(
self.validated_certificate_chain
.map(|chain| chain.into_iter().map(Annotated::from).collect()),
),
known_pins: Annotated::new(self.known_pins.into_iter().map(Annotated::from).collect()),
other: self
.other
.into_iter()
.map(|(k, v)| (k, Annotated::from(v)))
.collect(),
}
}
fn get_tags(&self) -> Tags {
let mut tags = vec![Annotated::new(TagEntry(
Annotated::new("hostname".to_string()),
Annotated::new(self.hostname.clone()),
))];
if let Some(port) = self.port {
tags.push(Annotated::new(TagEntry(
Annotated::new("port".to_string()),
Annotated::new(port.to_string()),
)));
}
if let Some(include_subdomains) = self.include_subdomains {
tags.push(Annotated::new(TagEntry(
Annotated::new("include-subdomains".to_string()),
Annotated::new(include_subdomains.to_string()),
)));
}
Tags(PairList::from(tags))
}
fn get_request(&self) -> Request {
Request {
url: Annotated::from(self.hostname.clone()),
..Request::default()
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
pub struct Hpkp {
pub date_time: Annotated<String>,
pub hostname: Annotated<String>,
pub port: Annotated<u64>,
pub effective_expiration_date: Annotated<String>,
pub include_subdomains: Annotated<bool>,
pub noted_hostname: Annotated<String>,
pub served_certificate_chain: Annotated<Array<String>>,
pub validated_certificate_chain: Annotated<Array<String>>,
#[metastructure(required = true)]
pub known_pins: Annotated<Array<String>>,
#[metastructure(pii = "true", additional_properties)]
pub other: Object<Value>,
}
impl Hpkp {
pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
let raw_hpkp = serde_json::from_slice::<HpkpRaw>(data)?;
event.logentry = Annotated::new(LogEntry::from(raw_hpkp.get_message()));
event.tags = Annotated::new(raw_hpkp.get_tags());
event.request = Annotated::new(raw_hpkp.get_request());
event.hpkp = Annotated::new(raw_hpkp.into_protocol());
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
struct ExpectStapleReportRaw {
expect_staple_report: ExpectStapleRaw,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExpectStapleResponseStatus {
Missing,
Provided,
ErrorResponse,
BadProducedAt,
NoMatchingResponse,
InvalidDate,
ParseResponseError,
ParseResponseDataError,
}
relay_common::derive_fromstr_and_display!(ExpectStapleResponseStatus, InvalidSecurityError, {
ExpectStapleResponseStatus::Missing => "MISSING",
ExpectStapleResponseStatus::Provided => "PROVIDED",
ExpectStapleResponseStatus::ErrorResponse => "ERROR_RESPONSE",
ExpectStapleResponseStatus::BadProducedAt => "BAD_PRODUCED_AT",
ExpectStapleResponseStatus::NoMatchingResponse => "NO_MATCHING_RESPONSE",
ExpectStapleResponseStatus::InvalidDate => "INVALID_DATE",
ExpectStapleResponseStatus::ParseResponseError => "PARSE_RESPONSE_ERROR",
ExpectStapleResponseStatus::ParseResponseDataError => "PARSE_RESPONSE_DATA_ERROR",
});
relay_common::impl_str_serde!(ExpectStapleResponseStatus, "an expect-ct response status");
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExpectStapleCertStatus {
Good,
Revoked,
Unknown,
}
relay_common::derive_fromstr_and_display!(ExpectStapleCertStatus, InvalidSecurityError, {
ExpectStapleCertStatus::Good => "GOOD",
ExpectStapleCertStatus::Revoked => "REVOKED",
ExpectStapleCertStatus::Unknown => "UNKNOWN",
});
relay_common::impl_str_serde!(ExpectStapleCertStatus, "an expect-staple cert status");
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
struct ExpectStapleRaw {
#[serde(skip_serializing_if = "Option::is_none")]
date_time: Option<DateTime<Utc>>,
hostname: String,
#[serde(skip_serializing_if = "Option::is_none")]
port: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
effective_expiration_date: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
response_status: Option<ExpectStapleResponseStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
ocsp_response: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
cert_status: Option<ExpectStapleCertStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
served_certificate_chain: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
validated_certificate_chain: Option<Vec<String>>,
}
impl ExpectStapleRaw {
fn get_message(&self) -> String {
format!("Expect-Staple failed for '{}'", self.hostname)
}
fn into_protocol(self) -> ExpectStaple {
ExpectStaple {
date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
hostname: Annotated::from(self.hostname),
port: Annotated::from(self.port),
effective_expiration_date: Annotated::from(
self.effective_expiration_date.map(|d| d.to_rfc3339()),
),
response_status: Annotated::from(self.response_status.map(|rs| rs.to_string())),
cert_status: Annotated::from(self.cert_status.map(|cs| cs.to_string())),
served_certificate_chain: Annotated::from(
self.served_certificate_chain
.map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
),
validated_certificate_chain: Annotated::from(
self.validated_certificate_chain
.map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
),
ocsp_response: Annotated::from(self.ocsp_response),
}
}
fn get_culprit(&self) -> String {
self.hostname.clone()
}
fn get_tags(&self) -> Tags {
let mut tags = vec![Annotated::new(TagEntry(
Annotated::new("hostname".to_string()),
Annotated::new(self.hostname.clone()),
))];
if let Some(port) = self.port {
tags.push(Annotated::new(TagEntry(
Annotated::new("port".to_string()),
Annotated::new(port.to_string()),
)));
}
if let Some(response_status) = self.response_status {
tags.push(Annotated::new(TagEntry(
Annotated::new("response_status".to_string()),
Annotated::new(response_status.to_string()),
)));
}
if let Some(cert_status) = self.cert_status {
tags.push(Annotated::new(TagEntry(
Annotated::new("cert_status".to_string()),
Annotated::new(cert_status.to_string()),
)));
}
Tags(PairList::from(tags))
}
fn get_request(&self) -> Request {
Request {
url: Annotated::from(self.hostname.clone()),
..Request::default()
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
pub struct ExpectStaple {
date_time: Annotated<String>,
hostname: Annotated<String>,
port: Annotated<i64>,
effective_expiration_date: Annotated<String>,
response_status: Annotated<String>,
cert_status: Annotated<String>,
served_certificate_chain: Annotated<Array<String>>,
validated_certificate_chain: Annotated<Array<String>>,
ocsp_response: Annotated<Value>,
}
impl ExpectStaple {
pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
let raw_report = serde_json::from_slice::<ExpectStapleReportRaw>(data)?;
let raw_expect_staple = raw_report.expect_staple_report;
event.logentry = Annotated::new(LogEntry::from(raw_expect_staple.get_message()));
event.culprit = Annotated::new(raw_expect_staple.get_culprit());
event.tags = Annotated::new(raw_expect_staple.get_tags());
event.request = Annotated::new(raw_expect_staple.get_request());
event.expectstaple = Annotated::new(raw_expect_staple.into_protocol());
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SecurityReportType {
Csp,
ExpectCt,
ExpectStaple,
Hpkp,
Unsupported,
}
impl SecurityReportType {
pub fn from_json(data: &[u8]) -> Result<Option<Self>, serde_json::Error> {
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct SecurityReport {
#[serde(rename = "type")]
ty: Option<CspViolationType>,
csp_report: Option<IgnoredAny>,
known_pins: Option<IgnoredAny>,
expect_staple_report: Option<IgnoredAny>,
expect_ct_report: Option<IgnoredAny>,
}
let helper: SecurityReport = serde_json::from_slice(data)?;
Ok(if helper.csp_report.is_some() {
Some(SecurityReportType::Csp)
} else if let Some(CspViolationType::CspViolation) = helper.ty {
Some(SecurityReportType::Csp)
} else if let Some(CspViolationType::Other) = helper.ty {
Some(SecurityReportType::Unsupported)
} else if helper.known_pins.is_some() {
Some(SecurityReportType::Hpkp)
} else if helper.expect_staple_report.is_some() {
Some(SecurityReportType::ExpectStaple)
} else if helper.expect_ct_report.is_some() {
Some(SecurityReportType::ExpectCt)
} else {
None
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use relay_protocol::assert_annotated_snapshot;
#[test]
fn test_unsplit_uri() {
assert_eq!(unsplit_uri("", ""), "");
assert_eq!(unsplit_uri("data", ""), "data:");
assert_eq!(unsplit_uri("data", "foo"), "data://foo");
assert_eq!(unsplit_uri("http", ""), "http://");
assert_eq!(unsplit_uri("http", "foo"), "http://foo");
}
#[test]
fn test_normalize_uri() {
assert_eq!(normalize_uri(""), "'self'");
assert_eq!(normalize_uri("self"), "'self'");
assert_eq!(normalize_uri("data"), "data:");
assert_eq!(normalize_uri("http"), "http://");
assert_eq!(normalize_uri("http://notlocalhost/"), "notlocalhost");
assert_eq!(normalize_uri("https://notlocalhost/"), "notlocalhost");
assert_eq!(normalize_uri("data://notlocalhost/"), "data://notlocalhost");
assert_eq!(normalize_uri("http://notlocalhost/lol.css"), "notlocalhost");
assert_eq!(
normalize_uri("http://notlocalhost:8000/"),
"notlocalhost:8000"
);
assert_eq!(
normalize_uri("http://notlocalhost:8000/lol.css"),
"notlocalhost:8000"
);
assert_eq!(normalize_uri("xyz://notlocalhost/"), "xyz://notlocalhost");
}
#[test]
fn test_csp_basic() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com",
"violated-directive": "style-src cdn.example.com",
"blocked-uri": "http://example.com/lol.css",
"effective-directive": "style-src",
"status-code": "200"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
assert_annotated_snapshot!(Annotated::new(event), @r#"
{
"culprit": "style-src cdn.example.com",
"logentry": {
"formatted": "Blocked 'style' from 'example.com'"
},
"request": {
"url": "http://example.com"
},
"tags": [
[
"effective-directive",
"style-src"
],
[
"blocked-uri",
"http://example.com/lol.css"
]
],
"csp": {
"effective_directive": "style-src",
"blocked_uri": "http://example.com/lol.css",
"document_uri": "http://example.com",
"status_code": 200,
"violated_directive": "style-src cdn.example.com"
}
}
"#);
}
#[test]
fn test_csp_coerce_blocked_uri_if_missing() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com",
"effective-directive": "script-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
assert_annotated_snapshot!(Annotated::new(event), @r#"
{
"culprit": "",
"logentry": {
"formatted": "Blocked unsafe (eval() or inline) 'script'"
},
"request": {
"url": "http://example.com"
},
"tags": [
[
"effective-directive",
"script-src"
],
[
"blocked-uri",
"self"
]
],
"csp": {
"effective_directive": "script-src",
"blocked_uri": "self",
"document_uri": "http://example.com",
"violated_directive": ""
}
}
"#);
}
#[test]
fn test_csp_msdn() {
let json = r#"{
"csp-report": {
"document-uri": "https://example.com/foo/bar",
"referrer": "https://www.google.com/",
"violated-directive": "default-src self",
"original-policy": "default-src self; report-uri /csp-hotline.php",
"blocked-uri": "http://evilhackerscripts.com"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
assert_annotated_snapshot!(Annotated::new(event), @r#"
{
"culprit": "default-src self",
"logentry": {
"formatted": "Blocked 'default-src' from 'evilhackerscripts.com'"
},
"request": {
"url": "https://example.com/foo/bar",
"headers": [
[
"Referer",
"https://www.google.com/"
]
]
},
"tags": [
[
"effective-directive",
"default-src"
],
[
"blocked-uri",
"http://evilhackerscripts.com"
]
],
"csp": {
"effective_directive": "default-src",
"blocked_uri": "http://evilhackerscripts.com",
"document_uri": "https://example.com/foo/bar",
"original_policy": "default-src self; report-uri /csp-hotline.php",
"referrer": "https://www.google.com/",
"violated_directive": "default-src self"
}
}
"#);
}
#[test]
fn test_csp_real() {
let json = r#"{
"csp-report": {
"document-uri": "https://sentry.io/sentry/csp/issues/88513416/",
"referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
"violated-directive": "script-src",
"effective-directive": "script-src",
"original-policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
"disposition": "enforce",
"blocked-uri": "http://baddomain.com/test.js?_=1515535030116",
"line-number": 24,
"column-number": 66270,
"source-file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
"status-code": 0,
"script-sample": ""
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
assert_annotated_snapshot!(Annotated::new(event), @r#"
{
"culprit": "script-src",
"logentry": {
"formatted": "Blocked 'script' from 'baddomain.com'"
},
"request": {
"url": "https://sentry.io/sentry/csp/issues/88513416/",
"headers": [
[
"Referer",
"https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/"
]
]
},
"tags": [
[
"effective-directive",
"script-src"
],
[
"blocked-uri",
"http://baddomain.com/test.js?_=1515535030116"
]
],
"csp": {
"effective_directive": "script-src",
"blocked_uri": "http://baddomain.com/test.js?_=1515535030116",
"document_uri": "https://sentry.io/sentry/csp/issues/88513416/",
"original_policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
"referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
"status_code": 0,
"violated_directive": "script-src",
"source_file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
"line_number": 24,
"column_number": 66270,
"script_sample": "",
"disposition": "enforce"
}
}
"#);
}
#[test]
fn test_csp_culprit_0() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"violated-directive": "style-src http://cdn.example.com",
"effective-directive": "style-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.culprit, @r#""style-src http://cdn.example.com""#);
}
#[test]
fn test_csp_culprit_1() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"violated-directive": "style-src cdn.example.com",
"effective-directive": "style-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.culprit, @r#""style-src cdn.example.com""#);
}
#[test]
fn test_csp_culprit_2() {
let json = r#"{
"csp-report": {
"document-uri": "https://example.com/foo",
"violated-directive": "style-src cdn.example.com",
"effective-directive": "style-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.culprit, @r#""style-src cdn.example.com""#);
}
#[test]
fn test_csp_culprit_3() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"violated-directive": "style-src https://cdn.example.com",
"effective-directive": "style-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.culprit, @r#""style-src https://cdn.example.com""#);
}
#[test]
fn test_csp_culprit_4() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"violated-directive": "style-src http://example.com",
"effective-directive": "style-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.culprit, @r#""style-src 'self'""#);
}
#[test]
fn test_csp_culprit_5() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"violated-directive": "style-src http://example2.com example.com",
"effective-directive": "style-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.culprit, @r#""style-src http://example2.com 'self'""#);
}
#[test]
fn test_csp_culprit_uri_without_scheme() {
let json = r#"{
"csp-report": {
"document-uri": "example.com",
"violated-directive": "style-src example2.com"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.culprit, @r#""style-src example2.com""#);
}
#[test]
fn test_csp_tags_stripe() {
let json = r#"{
"csp-report": {
"document-uri": "https://example.com",
"blocked-uri": "https://api.stripe.com/v1/tokens?card[number]=xxx",
"effective-directive": "script-src"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
insta::assert_debug_snapshot!(event.tags, @r#"
Tags(
PairList(
[
TagEntry(
"effective-directive",
"script-src",
),
TagEntry(
"blocked-uri",
"https://api.stripe.com/v1/tokens",
),
],
),
)
"#);
}
#[test]
fn test_csp_get_message_0() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "img-src",
"blocked-uri": "http://google.com/foo"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'image' from 'google.com'""#);
}
#[test]
fn test_csp_get_message_1() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "style-src",
"blocked-uri": ""
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked inline 'style'""#);
}
#[test]
fn test_csp_get_message_2() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "script-src",
"blocked-uri": "",
"violated-directive": "script-src 'unsafe-inline'"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe inline 'script'""#);
}
#[test]
fn test_csp_get_message_3() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "script-src",
"blocked-uri": "",
"violated-directive": "script-src 'unsafe-eval'"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe eval() 'script'""#);
}
#[test]
fn test_csp_get_message_4() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "script-src",
"blocked-uri": "",
"violated-directive": "script-src example.com"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe (eval() or inline) 'script'""#);
}
#[test]
fn test_csp_get_message_5() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "script-src",
"blocked-uri": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'script' from 'data:'""#);
}
#[test]
fn test_csp_get_message_6() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "script-src",
"blocked-uri": "data"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'script' from 'data:'""#);
}
#[test]
fn test_csp_get_message_7() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "style-src-elem",
"blocked-uri": "http://fonts.google.com/foo"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'style' from 'fonts.google.com'""#);
}
#[test]
fn test_csp_get_message_8() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "script-src-elem",
"blocked-uri": "http://cdn.ajaxapis.com/foo"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'script' from 'cdn.ajaxapis.com'""#);
}
#[test]
fn test_csp_get_message_9() {
let json = r#"{
"csp-report": {
"document-uri": "http://notlocalhost:8000/",
"effective-directive": "style-src",
"blocked-uri": "http://notlocalhost:8000/lol.css"
}
}"#;
let mut event = Event::default();
Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
let message = &event.logentry.value().unwrap().formatted;
insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'style' from 'notlocalhost:8000'""#);
}
#[test]
fn test_expectct_basic() {
let json = r#"{
"expect-ct-report": {
"date-time": "2014-04-06T13:00:50Z",
"hostname": "www.example.com",
"port": 443,
"scheme": "https",
"effective-expiration-date": "2014-05-01T12:40:50Z",
"served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
"validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
"scts": [
{
"version": 1,
"status": "invalid",
"source": "embedded",
"serialized_sct": "ABCD=="
}
],
"failure-mode": "enforce",
"test-report": false
}
}"#;
let mut event = Event::default();
ExpectCt::apply_to_event(json.as_bytes(), &mut event).unwrap();
assert_annotated_snapshot!(Annotated::new(event), @r#"
{
"culprit": "www.example.com",
"logentry": {
"formatted": "Expect-CT failed for 'www.example.com'"
},
"request": {
"url": "www.example.com"
},
"tags": [
[
"hostname",
"www.example.com"
],
[
"port",
"443"
]
],
"expectct": {
"date_time": "2014-04-06T13:00:50+00:00",
"hostname": "www.example.com",
"port": 443,
"scheme": "https",
"effective_expiration_date": "2014-05-01T12:40:50+00:00",
"served_certificate_chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
],
"validated_certificate_chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
],
"scts": [
{
"version": 1,
"status": "invalid",
"source": "embedded",
"serialized_sct": "ABCD=="
}
],
"failure_mode": "enforce",
"test_report": false
}
}
"#);
}
#[test]
fn test_expectct_invalid() {
let json = r#"{
"hostname": "www.example.com",
"date_time": "Not an RFC3339 datetime"
}"#;
let mut event = Event::default();
ExpectCt::apply_to_event(json.as_bytes(), &mut event)
.expect_err("date_time should fail to parse");
}
#[test]
fn test_expectstaple_basic() {
let json = r#"{
"expect-staple-report": {
"date-time": "2014-04-06T13:00:50Z",
"hostname": "www.example.com",
"port": 443,
"response-status": "ERROR_RESPONSE",
"cert-status": "REVOKED",
"effective-expiration-date": "2014-05-01T12:40:50Z",
"served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
"validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
}
}"#;
let mut event = Event::default();
ExpectStaple::apply_to_event(json.as_bytes(), &mut event).unwrap();
assert_annotated_snapshot!(Annotated::new(event), @r#"
{
"culprit": "www.example.com",
"logentry": {
"formatted": "Expect-Staple failed for 'www.example.com'"
},
"request": {
"url": "www.example.com"
},
"tags": [
[
"hostname",
"www.example.com"
],
[
"port",
"443"
],
[
"response_status",
"ERROR_RESPONSE"
],
[
"cert_status",
"REVOKED"
]
],
"expectstaple": {
"date_time": "2014-04-06T13:00:50+00:00",
"hostname": "www.example.com",
"port": 443,
"effective_expiration_date": "2014-05-01T12:40:50+00:00",
"response_status": "ERROR_RESPONSE",
"cert_status": "REVOKED",
"served_certificate_chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
],
"validated_certificate_chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
]
}
}
"#);
}
#[test]
fn test_hpkp_basic() {
let json = r#"{
"date-time": "2014-04-06T13:00:50Z",
"hostname": "example.com",
"port": 443,
"effective-expiration-date": "2014-05-01T12:40:50Z",
"include-subdomains": false,
"served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
"validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
"known-pins": ["pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""]
}"#;
let mut event = Event::default();
Hpkp::apply_to_event(json.as_bytes(), &mut event).unwrap();
assert_annotated_snapshot!(Annotated::new(event), @r#"
{
"logentry": {
"formatted": "Public key pinning validation failed for 'example.com'"
},
"request": {
"url": "example.com"
},
"tags": [
[
"hostname",
"example.com"
],
[
"port",
"443"
],
[
"include-subdomains",
"false"
]
],
"hpkp": {
"date_time": "2014-04-06T13:00:50+00:00",
"hostname": "example.com",
"port": 443,
"effective_expiration_date": "2014-05-01T12:40:50+00:00",
"include_subdomains": false,
"served_certificate_chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
],
"validated_certificate_chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
],
"known_pins": [
"pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
]
}
}
"#);
}
#[test]
fn test_security_report_type_deserializer_recognizes_csp_reports() {
let csp_report_text = r#"{
"csp-report": {
"document-uri": "https://example.com/foo/bar",
"referrer": "https://www.google.com/",
"violated-directive": "default-src self",
"original-policy": "default-src self; report-uri /csp-hotline.php",
"blocked-uri": "http://evilhackerscripts.com"
}
}"#;
let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
assert_eq!(report_type, Some(SecurityReportType::Csp));
}
#[test]
fn test_security_report_type_deserializer_recognizes_csp_violations_reports() {
let csp_report_text = r#"{
"age":0,
"body":{
"blockedURL":"https://example.com/tst/media/7_del.png",
"disposition":"enforce",
"documentURL":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
"effectiveDirective":"img-src",
"lineNumber":9,
"originalPolicy":"default-src 'none'; report-to endpoint-csp;",
"referrer":"https://example.com/test229/",
"sourceFile":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
"statusCode":0
},
"type":"csp-violation",
"url":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
"user_agent":"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36"
}"#;
let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
assert_eq!(report_type, Some(SecurityReportType::Csp));
}
#[test]
fn test_security_report_type_deserializer_recognizes_expect_ct_reports() {
let expect_ct_report_text = r#"{
"expect-ct-report": {
"date-time": "2014-04-06T13:00:50Z",
"hostname": "www.example.com",
"port": 443,
"effective-expiration-date": "2014-05-01T12:40:50Z",
"served-certificate-chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
],
"validated-certificate-chain": [
"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
],
"scts": [
{
"version": 1,
"status": "invalid",
"source": "embedded",
"serialized_sct": "ABCD=="
}
]
}
}"#;
let report_type = SecurityReportType::from_json(expect_ct_report_text.as_bytes()).unwrap();
assert_eq!(report_type, Some(SecurityReportType::ExpectCt));
}
#[test]
fn test_security_report_type_deserializer_recognizes_expect_staple_reports() {
let expect_staple_report_text = r#"{
"expect-staple-report": {
"date-time": "2014-04-06T13:00:50Z",
"hostname": "www.example.com",
"port": 443,
"response-status": "ERROR_RESPONSE",
"cert-status": "REVOKED",
"effective-expiration-date": "2014-05-01T12:40:50Z",
"served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
"validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
}
}"#;
let report_type =
SecurityReportType::from_json(expect_staple_report_text.as_bytes()).unwrap();
assert_eq!(report_type, Some(SecurityReportType::ExpectStaple));
}
#[test]
fn test_security_report_type_deserializer_recognizes_hpkp_reports() {
let hpkp_report_text = r#"{
"date-time": "2014-04-06T13:00:50Z",
"hostname": "www.example.com",
"port": 443,
"effective-expiration-date": "2014-05-01T12:40:50Z",
"include-subdomains": false,
"served-certificate-chain": [
"-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
],
"validated-certificate-chain": [
"-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
],
"known-pins": [
"pin-sha256=\"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=\"",
"pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
]
}"#;
let report_type = SecurityReportType::from_json(hpkp_report_text.as_bytes()).unwrap();
assert_eq!(report_type, Some(SecurityReportType::Hpkp));
}
#[test]
fn test_effective_directive_from_violated_directive_single() {
let csp_raw: CspRaw =
serde_json::from_str(r#"{"violated-directive":"default-src"}"#).unwrap();
assert!(matches!(
csp_raw.effective_directive(),
Ok(CspDirective::DefaultSrc)
));
}
#[test]
fn test_extract_effective_directive_from_long_form() {
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"effective-directive": "script-src 'report-sample' 'strict-dynamic' 'unsafe-eval' 'nonce-random" ,
"blocked-uri": "data"
}
}"#;
let raw_report = serde_json::from_slice::<CspReportRaw>(json.as_bytes()).unwrap();
let raw_csp = raw_report.csp_report;
let effective_directive = raw_csp.effective_directive().unwrap();
assert_eq!(effective_directive, CspDirective::ScriptSrc);
let json = r#"{
"csp-report": {
"document-uri": "http://example.com/foo",
"violated-directive": "script-src 'report-sample' 'strict-dynamic' 'unsafe-eval' 'nonce-random" ,
"blocked-uri": "data"
}
}"#;
let raw_report = serde_json::from_slice::<CspReportRaw>(json.as_bytes()).unwrap();
let raw_csp = raw_report.csp_report;
let effective_directive = raw_csp.effective_directive().unwrap();
assert_eq!(effective_directive, CspDirective::ScriptSrc);
}
}