// Copyright 2019-2024 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT //! The module to process HTML in Tauri. use std::path::{Path, PathBuf}; use html5ever::{ interface::QualName, namespace_url, ns, serialize::{HtmlSerializer, SerializeOpts, Serializer, TraversalScope}, tendril::TendrilSink, LocalName, }; pub use kuchiki::NodeRef; use kuchiki::{Attribute, ExpandedName, NodeData}; use serde::Serialize; #[cfg(feature = "isolation")] use serialize_to_javascript::DefaultTemplate; use crate::config::{DisabledCspModificationKind, PatternKind}; #[cfg(feature = "isolation")] use crate::pattern::isolation::IsolationJavascriptCodegen; /// The token used for script nonces. pub const SCRIPT_NONCE_TOKEN: &str = "__TAURI_SCRIPT_NONCE__"; /// The token used for style nonces. pub const STYLE_NONCE_TOKEN: &str = "__TAURI_STYLE_NONCE__"; // taken from https://github.com/kuchiki-rs/kuchiki/blob/57ee6920d835315a498e748ba4b07a851ae5e498/src/serializer.rs#L12 fn serialize_node_ref_internal( node: &NodeRef, serializer: &mut S, traversal_scope: TraversalScope, ) -> crate::Result<()> { match (traversal_scope, node.data()) { (ref scope, NodeData::Element(element)) => { if *scope == TraversalScope::IncludeNode { let attrs = element.attributes.borrow(); // Unfortunately we need to allocate something to hold these &'a QualName let attrs = attrs .map .iter() .map(|(name, attr)| { ( QualName::new(attr.prefix.clone(), name.ns.clone(), name.local.clone()), &attr.value, ) }) .collect::>(); serializer.start_elem( element.name.clone(), attrs.iter().map(|&(ref name, value)| (name, &**value)), )? } let children = match element.template_contents.as_ref() { Some(template_root) => template_root.children(), None => node.children(), }; for child in children { serialize_node_ref_internal(&child, serializer, TraversalScope::IncludeNode)? } if *scope == TraversalScope::IncludeNode { serializer.end_elem(element.name.clone())? } Ok(()) } (_, &NodeData::DocumentFragment) | (_, &NodeData::Document(_)) => { for child in node.children() { serialize_node_ref_internal(&child, serializer, TraversalScope::IncludeNode)? } Ok(()) } (TraversalScope::ChildrenOnly(_), _) => Ok(()), (TraversalScope::IncludeNode, NodeData::Doctype(doctype)) => { serializer.write_doctype(&doctype.name).map_err(Into::into) } (TraversalScope::IncludeNode, NodeData::Text(text)) => { serializer.write_text(&text.borrow()).map_err(Into::into) } (TraversalScope::IncludeNode, NodeData::Comment(text)) => { serializer.write_comment(&text.borrow()).map_err(Into::into) } (TraversalScope::IncludeNode, NodeData::ProcessingInstruction(contents)) => { let contents = contents.borrow(); serializer .write_processing_instruction(&contents.0, &contents.1) .map_err(Into::into) } } } /// Serializes the node to HTML. pub fn serialize_node(node: &NodeRef) -> Vec { let mut u8_vec = Vec::new(); let mut ser = HtmlSerializer::new( &mut u8_vec, SerializeOpts { traversal_scope: TraversalScope::IncludeNode, ..Default::default() }, ); serialize_node_ref_internal(node, &mut ser, TraversalScope::IncludeNode).unwrap(); u8_vec } /// Parses the given HTML string. pub fn parse(html: String) -> NodeRef { kuchiki::parse_html().one(html) } fn with_head(document: &NodeRef, f: F) { if let Ok(ref node) = document.select_first("head") { f(node.as_node()) } else { let node = NodeRef::new_element( QualName::new(None, ns!(html), LocalName::from("head")), None, ); f(&node); document.prepend(node) } } fn inject_nonce(document: &NodeRef, selector: &str, token: &str) { if let Ok(elements) = document.select(selector) { for target in elements { let node = target.as_node(); let element = node.as_element().unwrap(); let mut attrs = element.attributes.borrow_mut(); // if the node already has the `nonce` attribute, skip it if attrs.get("nonce").is_some() { continue; } attrs.insert("nonce", token.into()); } } } /// Inject nonce tokens to all scripts and styles. pub fn inject_nonce_token( document: &NodeRef, dangerous_disable_asset_csp_modification: &DisabledCspModificationKind, ) { if dangerous_disable_asset_csp_modification.can_modify("script-src") { inject_nonce(document, "script[src^='http']", SCRIPT_NONCE_TOKEN); } if dangerous_disable_asset_csp_modification.can_modify("style-src") { inject_nonce(document, "style", STYLE_NONCE_TOKEN); } } /// Injects a content security policy to the HTML. pub fn inject_csp(document: &NodeRef, csp: &str) { with_head(document, |head| { head.append(create_csp_meta_tag(csp)); }); } fn create_csp_meta_tag(csp: &str) -> NodeRef { NodeRef::new_element( QualName::new(None, ns!(html), LocalName::from("meta")), vec![ ( ExpandedName::new(ns!(), LocalName::from("http-equiv")), Attribute { prefix: None, value: "Content-Security-Policy".into(), }, ), ( ExpandedName::new(ns!(), LocalName::from("content")), Attribute { prefix: None, value: csp.into(), }, ), ], ) } /// The shape of the JavaScript Pattern config #[derive(Debug, Serialize)] #[serde(rename_all = "lowercase", tag = "pattern")] pub enum PatternObject { /// Brownfield pattern. Brownfield, /// Isolation pattern. Recommended for security purposes. Isolation { /// Which `IsolationSide` this `PatternObject` is getting injected into side: IsolationSide, }, } impl From<&PatternKind> for PatternObject { fn from(pattern_kind: &PatternKind) -> Self { match pattern_kind { PatternKind::Brownfield => Self::Brownfield, PatternKind::Isolation { .. } => Self::Isolation { side: IsolationSide::default(), }, } } } /// Where the JavaScript is injected to #[derive(Debug, Serialize)] #[serde(rename_all = "lowercase")] pub enum IsolationSide { /// Original frame, the Brownfield application Original, /// Secure frame, the isolation security application Secure, } impl Default for IsolationSide { fn default() -> Self { Self::Original } } /// Injects the Isolation JavaScript to a codegen time document. /// /// Note: This function is not considered part of the stable API. #[cfg(feature = "isolation")] pub fn inject_codegen_isolation_script(document: &NodeRef) { with_head(document, |head| { let script = NodeRef::new_element( QualName::new(None, ns!(html), "script".into()), vec![( ExpandedName::new(ns!(), LocalName::from("nonce")), Attribute { prefix: None, value: SCRIPT_NONCE_TOKEN.into(), }, )], ); script.append(NodeRef::new_text( IsolationJavascriptCodegen {} .render_default(&Default::default()) .expect("unable to render codegen isolation script template") .into_string(), )); head.prepend(script); }); } /// Temporary workaround for Windows not allowing requests /// /// Note: this does not prevent path traversal due to the isolation application expectation that it /// is secure. pub fn inline_isolation(document: &NodeRef, dir: &Path) { for script in document .select("script[src]") .expect("unable to parse document for scripts") { let src = { let attributes = script.attributes.borrow(); attributes .get(LocalName::from("src")) .expect("script with src attribute has no src value") .to_string() }; let mut path = PathBuf::from(src); if path.has_root() { path = path .strip_prefix("/") .expect("Tauri \"Isolation\" Pattern only supports relative or absolute (`/`) paths.") .into(); } let file = std::fs::read_to_string(dir.join(path)).expect("unable to find isolation file"); script.as_node().append(NodeRef::new_text(file)); let mut attributes = script.attributes.borrow_mut(); attributes.remove(LocalName::from("src")); } } #[cfg(test)] mod tests { use kuchiki::traits::*; #[test] fn csp() { let htmls = vec![ "".to_string(), "".to_string(), ]; for html in htmls { let document = kuchiki::parse_html().one(html); let csp = "csp-string"; super::inject_csp(&document, csp); assert_eq!( document.to_string(), format!( r#""#, ) ); } } }