Skip to main content

autumn_plugin_meta_pixel/
plugin.rs

1//! `MetaPixelPlugin`.
2
3use std::borrow::Cow;
4use std::fmt::Write as _;
5
6use autumn_web::app::AppBuilder;
7use autumn_web::config::AutumnConfig;
8use autumn_web::plugin::Plugin;
9use autumn_web::plugin_contract::PluginContract;
10use autumn_web::{AppState, AutumnError};
11
12use crate::assets::ASSETS;
13use crate::config::{CspCheck, MetaPixelConfig, SECTION};
14use crate::csp::{has_strict_dynamic, has_trusted_types, missing_sources, with_meta_pixel_sources};
15use crate::error::MetaPixelError;
16use crate::pixel::Runtime;
17
18/// Plugin name for duplicate detection.
19pub const PLUGIN_NAME: &str = "autumn-plugin-meta-pixel";
20
21/// Meta Pixel plugin.
22///
23/// ```rust,ignore
24/// autumn_web::app()
25///     .routes(routes![index])
26///     .plugin(MetaPixelPlugin::new())
27///     .run()
28///     .await;
29/// ```
30#[derive(Debug, Clone, Default)]
31pub struct MetaPixelPlugin {
32    config: Option<MetaPixelConfig>,
33    policy_version: Option<u32>,
34}
35
36impl MetaPixelPlugin {
37    /// Makes the plugin. It reads `[meta_pixel]` at startup.
38    #[must_use]
39    pub const fn new() -> Self {
40        Self {
41            config: None,
42            policy_version: None,
43        }
44    }
45
46    /// Makes the plugin with this config. It reads no files.
47    #[must_use]
48    pub const fn with_config(config: MetaPixelConfig) -> Self {
49        Self {
50            config: Some(config),
51            policy_version: None,
52        }
53    }
54
55    /// Uses the app's cookie policy version, not `consent_policy_version`
56    /// in config. Pass the same constant that the app gives to
57    /// `Consent::allows`, so a policy change closes the gate.
58    #[must_use]
59    pub const fn consent_policy_version(mut self, version: u32) -> Self {
60        self.policy_version = Some(version);
61        self
62    }
63
64    /// Loads and checks the config, then shares it with the
65    /// [`MetaPixel`](crate::MetaPixel) extractor. [`Plugin::build`] calls it
66    /// at startup.
67    ///
68    /// # Errors
69    /// Returns [`MetaPixelError::Config`] for bad config, and
70    /// [`MetaPixelError::Csp`] when the CSP blocks the pixel and
71    /// `csp_check = "error"`.
72    pub fn start(self, state: &AppState) -> Result<(), MetaPixelError> {
73        let mut config = match self.config {
74            Some(c) => c,
75            // autumn sets the state profile from the config; "default" means none.
76            None => MetaPixelConfig::load(Some(state.profile()).filter(|p| *p != "default"))?,
77        };
78        if let Some(version) = self.policy_version {
79            config.consent_policy_version = version;
80        }
81        config.validate()?;
82        let on = config.enabled && !config.pixel_ids.is_empty();
83        if config.enabled && config.pixel_ids.is_empty() {
84            tracing::warn!("meta_pixel: enabled, but pixel_ids is empty; the pixel is off");
85        }
86        if on {
87            check_csp(&config, &state.config())?;
88        }
89        tracing::info!(
90            on,
91            pixels = config.pixel_ids.len(),
92            require_consent = config.require_consent,
93            "meta_pixel started"
94        );
95        state.insert_extension(Runtime::new(config));
96        Ok(())
97    }
98}
99
100/// Checks the app CSP against the pixel hosts.
101fn check_csp(config: &MetaPixelConfig, app: &AutumnConfig) -> Result<(), MetaPixelError> {
102    if config.csp_check == CspCheck::Off {
103        return Ok(());
104    }
105    let headers = &app.security.headers;
106    let csp = &headers.content_security_policy;
107    let origin = config
108        .script_origin()
109        .ok_or_else(|| MetaPixelError::Config("script_url has no https origin".to_owned()))?;
110    let gaps = missing_sources(csp, &origin);
111    let strict = has_strict_dynamic(csp);
112    let trusted_types = has_trusted_types(csp);
113    if gaps.is_empty() && !strict && !trusted_types {
114        return Ok(());
115    }
116    let mut problems: Vec<String> = gaps.iter().map(ToString::to_string).collect();
117    if strict {
118        problems.push(
119            "script-src has 'strict-dynamic', so the browser blocks the loader; \
120             remove 'strict-dynamic', or set csp_check = \"off\" and load the pixel yourself"
121                .to_owned(),
122        );
123    }
124    if trusted_types {
125        problems.push(
126            "the policy has require-trusted-types-for, so the browser blocks the loader; \
127             remove it, or set csp_check = \"off\" and load the pixel yourself"
128                .to_owned(),
129        );
130    }
131    let mut message = format!(
132        "the Content-Security-Policy blocks the pixel: {}.",
133        problems.join("; ")
134    );
135    if !gaps.is_empty() {
136        let fixed = toml::Value::String(with_meta_pixel_sources(csp, &origin));
137        // A write to a `String` cannot fail.
138        let _ = write!(
139            message,
140            " Set this in autumn.toml:\n[security.headers]\ncontent_security_policy = {fixed}"
141        );
142        if headers.csp_nonce.enabled {
143            message.push_str(
144                "\nNote: an explicit content_security_policy turns off the automatic nonce. \
145                 Then inline scripts with a CspNonce do not run, and style-src goes back to \
146                 'unsafe-inline'. Add your own nonce sources if you need them.",
147            );
148        }
149    }
150    match config.csp_check {
151        CspCheck::Warn => {
152            tracing::warn!("meta_pixel: {message}");
153            Ok(())
154        }
155        _ => Err(MetaPixelError::Csp(message)),
156    }
157}
158
159impl Plugin for MetaPixelPlugin {
160    fn name(&self) -> Cow<'static, str> {
161        Cow::Borrowed(PLUGIN_NAME)
162    }
163
164    fn contract(&self) -> Option<PluginContract> {
165        Some(
166            PluginContract::new(env!("CARGO_PKG_NAME"))
167                .plugin_version(env!("CARGO_PKG_VERSION"))
168                .autumn_web("0.10"),
169        )
170    }
171
172    fn build(self, app: AppBuilder) -> AppBuilder {
173        app.config_section(SECTION)
174            .plugin_assets(&ASSETS)
175            .on_startup(move |state| {
176                let plugin = self.clone();
177                async move {
178                    plugin
179                        .start(&state)
180                        .map_err(AutumnError::internal_server_error)
181                }
182            })
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::config::CspCheck;
190
191    fn cfg() -> MetaPixelConfig {
192        MetaPixelConfig::with_pixel_ids(["111"])
193    }
194
195    #[test]
196    fn default_csp_fails_with_the_fix() {
197        let err = check_csp(&cfg(), &AutumnConfig::default())
198            .unwrap_err()
199            .to_string();
200        assert!(
201            err.contains("script-src does not allow https://connect.facebook.net"),
202            "{err}"
203        );
204        assert!(err.contains("[security.headers]"), "{err}");
205        assert!(err.contains("content_security_policy = \""), "{err}");
206        assert!(err.contains("https://www.facebook.com"), "{err}");
207    }
208
209    #[test]
210    fn fixed_csp_passes() {
211        let mut app = AutumnConfig::default();
212        app.security.headers.content_security_policy = crate::csp::with_meta_pixel_sources(
213            &app.security.headers.content_security_policy,
214            "https://connect.facebook.net",
215        );
216        check_csp(&cfg(), &app).unwrap();
217        app.security.headers.content_security_policy = String::new();
218        check_csp(&cfg(), &app).unwrap();
219    }
220
221    #[test]
222    fn warn_and_off_do_not_fail() {
223        let mut c = cfg();
224        c.csp_check = CspCheck::Warn;
225        check_csp(&c, &AutumnConfig::default()).unwrap();
226        c.csp_check = CspCheck::Off;
227        check_csp(&c, &AutumnConfig::default()).unwrap();
228    }
229
230    #[test]
231    fn nonce_note_and_strict_dynamic() {
232        let mut app = AutumnConfig::default();
233        app.security.headers.csp_nonce.enabled = true;
234        let err = check_csp(&cfg(), &app).unwrap_err().to_string();
235        assert!(err.contains("nonce"), "{err}");
236        app.security.headers.content_security_policy =
237            "script-src 'self' 'strict-dynamic' https://connect.facebook.net; img-src *; connect-src *".into();
238        let err = check_csp(&cfg(), &app).unwrap_err().to_string();
239        assert!(err.contains("strict-dynamic"), "{err}");
240    }
241
242    #[test]
243    fn trusted_types_fails_with_a_reason() {
244        let mut app = AutumnConfig::default();
245        app.security.headers.content_security_policy =
246            "default-src 'self' https:; require-trusted-types-for 'script'".into();
247        let err = check_csp(&cfg(), &app).unwrap_err().to_string();
248        assert!(err.contains("require-trusted-types-for"), "{err}");
249    }
250
251    #[test]
252    fn nonce_note_names_the_cost() {
253        let mut app = AutumnConfig::default();
254        app.security.headers.csp_nonce.enabled = true;
255        let err = check_csp(&cfg(), &app).unwrap_err().to_string();
256        assert!(err.contains("CspNonce"), "{err}");
257        assert!(err.contains("'unsafe-inline'"), "{err}");
258    }
259
260    #[test]
261    fn consent_policy_version_builder_wins_over_config() {
262        let p = MetaPixelPlugin::with_config(cfg()).consent_policy_version(3);
263        assert_eq!(p.config.as_ref().unwrap().consent_policy_version, 1);
264        assert_eq!(p.policy_version, Some(3));
265    }
266
267    #[test]
268    fn contract_names_autumn_08() {
269        let c = MetaPixelPlugin::new().contract().unwrap();
270        let text = format!("{c:?}");
271        assert!(text.contains("autumn-plugin-meta-pixel"), "{text}");
272        assert!(text.contains("0.10"), "{text}");
273        assert_eq!(MetaPixelPlugin::new().name(), PLUGIN_NAME);
274    }
275}