diff --git a/.changes/object-csp.md b/.changes/object-csp.md new file mode 100644 index 000000000..efe2605a1 --- /dev/null +++ b/.changes/object-csp.md @@ -0,0 +1,5 @@ +--- +"tauri": patch +--- + +Allows the configuration CSP to be an object mapping a directive name to its source list. diff --git a/core/tauri-utils/src/config.rs b/core/tauri-utils/src/config.rs index 4d6162d29..f7ed96d5e 100644 --- a/core/tauri-utils/src/config.rs +++ b/core/tauri-utils/src/config.rs @@ -22,7 +22,12 @@ use serde_json::Value as JsonValue; use serde_with::skip_serializing_none; use url::Url; -use std::{collections::HashMap, fmt, fs::read_to_string, path::PathBuf}; +use std::{ + collections::HashMap, + fmt::{self, Display}, + fs::read_to_string, + path::PathBuf, +}; /// Items to help with parsing content into a [`Config`]. pub mod parse; @@ -593,6 +598,121 @@ fn default_file_drop_enabled() -> bool { true } +/// A Content-Security-Policy directive source list. +/// See . +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", untagged)] +pub enum CspDirectiveSources { + /// An inline list of CSP sources. Same as [`Self::List`], but concatenated with a space separator. + Inline(String), + /// A list of CSP sources. The collection will be concatenated with a space separator for the CSP string. + List(Vec), +} + +impl Default for CspDirectiveSources { + fn default() -> Self { + Self::List(Vec::new()) + } +} + +impl From for Vec { + fn from(sources: CspDirectiveSources) -> Self { + match sources { + CspDirectiveSources::Inline(source) => source.split(' ').map(|s| s.to_string()).collect(), + CspDirectiveSources::List(l) => l, + } + } +} + +impl CspDirectiveSources { + /// Whether the given source is configured on this directive or not. + pub fn contains(&self, source: &str) -> bool { + match self { + Self::Inline(s) => s.contains(&format!("{} ", source)) || s.contains(&format!(" {}", source)), + Self::List(l) => l.contains(&source.into()), + } + } + + /// Appends the given source to this directive. + pub fn push>(&mut self, source: S) { + match self { + Self::Inline(s) => { + s.push(' '); + s.push_str(source.as_ref()); + } + Self::List(l) => { + l.push(source.as_ref().to_string()); + } + } + } + + /// Extends this CSP directive source list with the given array of sources. + pub fn extend(&mut self, sources: Vec) { + for s in sources { + self.push(s); + } + } +} + +/// A Content-Security-Policy definition. +/// See . +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", untagged)] +pub enum Csp { + /// The entire CSP policy in a single text string. + Policy(String), + /// An object mapping a directive with its sources values as a list of strings. + DirectiveMap(HashMap), +} + +impl From> for Csp { + fn from(map: HashMap) -> Self { + Self::DirectiveMap(map) + } +} + +impl From for HashMap { + fn from(csp: Csp) -> Self { + match csp { + Csp::Policy(policy) => { + let mut map = HashMap::new(); + for directive in policy.split(';') { + let mut tokens = directive.trim().split(' '); + if let Some(directive) = tokens.next() { + let sources = tokens.map(|s| s.to_string()).collect::>(); + map.insert(directive.to_string(), CspDirectiveSources::List(sources)); + } + } + map + } + Csp::DirectiveMap(m) => m, + } + } +} + +impl Display for Csp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Policy(s) => write!(f, "{}", s), + Self::DirectiveMap(m) => { + let len = m.len(); + let mut i = 0; + for (directive, sources) in m { + let sources: Vec = sources.clone().into(); + write!(f, "{} {}", directive, sources.join(" "))?; + i += 1; + if i != len { + write!(f, "; ")?; + } + } + Ok(()) + } + } + } +} + /// Security configuration. #[skip_serializing_none] #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] @@ -604,12 +724,12 @@ pub struct SecurityConfig { /// /// This is a really important part of the configuration since it helps you ensure your WebView is secured. /// See . - pub csp: Option, + pub csp: Option, /// The Content Security Policy that will be injected on all HTML files on development. /// /// This is a really important part of the configuration since it helps you ensure your WebView is secured. /// See . - pub dev_csp: Option, + pub dev_csp: Option, /// Freeze the `Object.prototype` when using the custom protocol. #[serde(default)] pub freeze_prototype: bool, @@ -2399,10 +2519,49 @@ mod build { } } + impl ToTokens for CspDirectiveSources { + fn to_tokens(&self, tokens: &mut TokenStream) { + let prefix = quote! { ::tauri::utils::config::CspDirectiveSources }; + + tokens.append_all(match self { + Self::Inline(sources) => { + let sources = sources.as_str(); + quote!(#prefix::Inline(#sources.into())) + } + Self::List(list) => { + let list = vec_lit(list, str_lit); + quote!(#prefix::List(#list)) + } + }) + } + } + + impl ToTokens for Csp { + fn to_tokens(&self, tokens: &mut TokenStream) { + let prefix = quote! { ::tauri::utils::config::Csp }; + + tokens.append_all(match self { + Self::Policy(policy) => { + let policy = policy.as_str(); + quote!(#prefix::Policy(#policy.into())) + } + Self::DirectiveMap(list) => { + let map = map_lit( + quote! { ::std::collections::HashMap }, + list, + str_lit, + identity, + ); + quote!(#prefix::DirectiveMap(#map)) + } + }) + } + } + impl ToTokens for SecurityConfig { fn to_tokens(&self, tokens: &mut TokenStream) { - let csp = opt_str_lit(self.csp.as_ref()); - let dev_csp = opt_str_lit(self.dev_csp.as_ref()); + let csp = opt_lit(self.csp.as_ref()); + let dev_csp = opt_lit(self.dev_csp.as_ref()); let freeze_prototype = self.freeze_prototype; literal_struct!(tokens, SecurityConfig, csp, dev_csp, freeze_prototype); diff --git a/core/tauri/src/manager.rs b/core/tauri/src/manager.rs index 6135e2e32..4295a6668 100644 --- a/core/tauri/src/manager.rs +++ b/core/tauri/src/manager.rs @@ -20,6 +20,7 @@ use tauri_macros::default_runtime; use tauri_utils::pattern::isolation::RawIsolationPayload; use tauri_utils::{ assets::{AssetKey, CspHash}, + config::{Csp, CspDirectiveSources}, html::{SCRIPT_NONCE_TOKEN, STYLE_NONCE_TOKEN}, }; @@ -67,8 +68,8 @@ const MENU_EVENT: &str = "tauri://menu"; #[derive(Default)] /// Spaced and quoted Content-Security-Policy hash values. struct CspHashStrings { - script: String, - style: String, + script: Vec, + style: Vec, } /// Sets the CSP value to the asset HTML if needed (on Linux). @@ -78,20 +79,19 @@ fn set_csp( assets: Arc, asset_path: &AssetKey, #[allow(unused_variables)] manager: &WindowManager, - mut csp: String, + csp: Csp, ) -> String { + let mut csp = csp.into(); let hash_strings = assets .csp_hashes(asset_path) .fold(CspHashStrings::default(), |mut acc, hash| { match hash { CspHash::Script(hash) => { - acc.script.push(' '); - acc.script.push_str(hash); + acc.script.push(hash.into()); } CspHash::Style(hash) => { - acc.style.push(' '); - acc.style.push_str(hash); + acc.style.push(hash.into()); } _csp_hash => { #[cfg(debug_assertions)] @@ -120,15 +120,13 @@ fn set_csp( #[cfg(feature = "isolation")] if let Pattern::Isolation { schema, .. } = &manager.inner.pattern { - let default_src = format!("default-src {}", format_real_schema(schema)); - if csp.contains("default-src") { - csp = csp.replace("default-src", &default_src); - } else { - csp.push_str("; "); - csp.push_str(&default_src); - } + let default_src = csp + .entry("default-src".into()) + .or_insert_with(Default::default); + default_src.push(format_real_schema(schema)); } + let csp = Csp::DirectiveMap(csp).to_string(); #[cfg(target_os = "linux")] { *asset = asset.replacen(tauri_utils::html::CSP_TOKEN, &csp, 1); @@ -156,9 +154,9 @@ fn replace_with_callback String>( fn replace_csp_nonce( asset: &mut String, token: &str, - csp: &mut String, - csp_attr: &str, - hashes: String, + csp: &mut HashMap, + directive: &str, + hashes: Vec, ) { let mut nonces = Vec::new(); *asset = replace_with_callback(asset, token, || { @@ -168,29 +166,17 @@ fn replace_csp_nonce( }); if !(nonces.is_empty() && hashes.is_empty()) { - let attr = format!( - "{} 'self'{}{}", - csp_attr, - if nonces.is_empty() { - "".into() - } else { - format!( - " {}", - nonces - .into_iter() - .map(|n| format!("'nonce-{}'", n)) - .collect::>() - .join(" ") - ) - }, - hashes - ); - if csp.contains(csp_attr) { - *csp = csp.replace(csp_attr, &attr); - } else { - csp.push_str("; "); - csp.push_str(&attr); + let nonce_sources = nonces + .into_iter() + .map(|n| format!("'nonce-{}'", n)) + .collect::>(); + let sources = csp.entry(directive.into()).or_insert_with(Default::default); + let self_source = "'self'".to_string(); + if !sources.contains(&self_source) { + sources.push(self_source); } + sources.extend(nonce_sources); + sources.extend(hashes); } } @@ -376,7 +362,7 @@ impl WindowManager { } } - fn csp(&self) -> Option { + fn csp(&self) -> Option { if cfg!(feature = "custom-protocol") { self.inner.config.tauri.security.csp.clone() } else { @@ -1045,7 +1031,7 @@ impl WindowManager { // naive way to check if it's an html if html.contains('<') && html.contains('>') { let mut document = tauri_utils::html::parse(html); - tauri_utils::html::inject_csp(&mut document, &csp); + tauri_utils::html::inject_csp(&mut document, &csp.to_string()); url.set_path(&format!("text/html,{}", document.to_string())); } } diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index 896531537..a541e7674 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -122,7 +122,12 @@ } ], "security": { - "csp": "default-src 'self' customprotocol: asset: img-src: 'self'; style-src 'unsafe-inline' 'self' https://fonts.googleapis.com; img-src 'self' asset: https://asset.localhost blob: data:; font-src https://fonts.gstatic.com", + "csp": { + "default-src": "'self' customprotocol: asset:", + "font-src": ["https://fonts.gstatic.com"], + "img-src": "'self' asset: https://asset.localhost blob: data:", + "style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com" + }, "freezePrototype": true }, "systemTray": { diff --git a/tooling/cli/schema.json b/tooling/cli/schema.json index 512da62cb..e7f3a6a58 100644 --- a/tooling/cli/schema.json +++ b/tooling/cli/schema.json @@ -867,6 +867,38 @@ }, "additionalProperties": false }, + "Csp": { + "description": "A Content-Security-Policy definition. See .", + "anyOf": [ + { + "description": "The entire CSP policy in a single text string.", + "type": "string" + }, + { + "description": "An object mapping a directive with its sources values as a list of strings.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CspDirectiveSources" + } + } + ] + }, + "CspDirectiveSources": { + "description": "A Content-Security-Policy directive source list. See .", + "anyOf": [ + { + "description": "An inline list of CSP sources. Same as [`Self::List`], but concatenated with a space separator.", + "type": "string" + }, + { + "description": "A list of CSP sources. The collection will be concatenated with a space separator for the CSP string.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "DebConfig": { "description": "Configuration for Debian (.deb) bundles.", "type": "object", @@ -1310,16 +1342,24 @@ "properties": { "csp": { "description": "The Content Security Policy that will be injected on all HTML files on the built application. If [`dev_csp`](SecurityConfig.dev_csp) is not specified, this value is also injected on dev.\n\nThis is a really important part of the configuration since it helps you ensure your WebView is secured. See .", - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/Csp" + }, + { + "type": "null" + } ] }, "devCsp": { "description": "The Content Security Policy that will be injected on all HTML files on development.\n\nThis is a really important part of the configuration since it helps you ensure your WebView is secured. See .", - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/Csp" + }, + { + "type": "null" + } ] }, "freezePrototype": { diff --git a/tooling/cli/src/info.rs b/tooling/cli/src/info.rs index 0526f2fe1..c0c63b50d 100644 --- a/tooling/cli/src/info.rs +++ b/tooling/cli/src/info.rs @@ -729,7 +729,8 @@ pub fn command(_options: Options) -> Result<()> { .tauri .security .csp - .clone() + .as_ref() + .map(|c| c.to_string()) .unwrap_or_else(|| "unset".to_string()), ) .display();