diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ad5db1bd..b5b9656819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # UNRELEASED +### feat: added a dev mode to `.ic-assets.json5` + +When uploading assets to a local dev replica, the `Strict-Transport-Security` header and the `upgrade-insecure-requests` directive of the `Content-Security-Policy` header will now be stripped out. This permits loading `http://` pages in Safari and other browsers that do not treat localhost specially for this directive. + +A new field in `.ic-assets.json5`, `disable_secure_headers_in_dev_mode`, can be set to `false` to disable this behavior. + ### fix: `dfx deploy --by-proposal` no longer sends chunk data in ProposeCommitBatch Recently we made `dfx deploy` include some chunk data in CommitBatch, in order to streamline diff --git a/src/canisters/frontend/ic-asset/src/asset/config.rs b/src/canisters/frontend/ic-asset/src/asset/config.rs index 356e2614a4..d54eb1107a 100644 --- a/src/canisters/frontend/ic-asset/src/asset/config.rs +++ b/src/canisters/frontend/ic-asset/src/asset/config.rs @@ -31,14 +31,15 @@ pub struct AssetConfig { pub(crate) encodings: Option>, pub(crate) security_policy: Option, pub(crate) disable_security_policy_warning: Option, + pub(crate) disable_secure_headers_in_dev_mode: Option, } impl AssetConfig { - pub fn combined_headers(&self) -> Option { - match (self.headers.as_ref(), self.security_policy) { - (None, None) => None, - (None, Some(policy)) => Some(policy.to_headers()), - (Some(custom_headers), None) => Some(custom_headers.clone()), + pub fn combined_headers(&self, insecure_dev_mode: bool) -> Option { + let mut combined_headers = match (self.headers.as_ref(), self.security_policy) { + (None, None) => return None, + (None, Some(policy)) => policy.to_headers(), + (Some(custom_headers), None) => custom_headers.clone(), (Some(custom_headers), Some(policy)) => { let mut headers = custom_headers.clone(); let custom_header_names: HashSet = @@ -48,8 +49,49 @@ impl AssetConfig { headers.insert(policy_header_name, policy_header_value); } } - Some(headers) + headers } + }; + self.postprocess_headers(&mut combined_headers, insecure_dev_mode); + Some(combined_headers) + } + + fn postprocess_headers(&self, headers: &mut HeadersConfig, insecure_dev_mode: bool) { + if insecure_dev_mode && self.disable_secure_headers_in_dev_mode != Some(false) { + headers.retain(|header_name, header_value| { + if header_name.eq_ignore_ascii_case("Strict-Transport-Security") { + // Block the `Strict-Transport-Security` header to prevent attempting to fetch this page over HTTPS. + false // delete the header + } else if header_name.eq_ignore_ascii_case("Content-Security-Policy") { + // Remove the upgrade-insecure-requests directive from the `Content-Security-Policy` header to prevent + // attempting to fetch linked assets over HTTPS. + let mut upgrade_directive = None; + for directive in header_value.split(';') { + if directive.trim() == "upgrade-insecure-requests" { + upgrade_directive = Some(directive); + } + } + if let Some(upgrade_directive) = upgrade_directive { + // mild hack but soon to be str::substr_range + let mut l = + upgrade_directive.as_ptr() as usize - header_value.as_ptr() as usize; + let mut r = l + upgrade_directive.len(); + // also remove one `;` separator if this is not the only directive + if l != 0 { + l -= 1; + } else if r != header_value.len() { + r += 1; + } + header_value.replace_range(l..r, ""); + } + // delete the header if this was the only directive + header_value + .find(|c: char| !c.is_whitespace() && c != ';') + .is_some() + } else { + true // do not delete the header + } + }); } } @@ -117,6 +159,8 @@ pub struct AssetConfigRule { security_policy: Option, #[serde(skip_serializing_if = "Option::is_none")] disable_security_policy_warning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disable_secure_headers_in_dev_mode: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] @@ -325,6 +369,11 @@ impl AssetConfig { if other.disable_security_policy_warning.is_some() { self.disable_security_policy_warning = other.disable_security_policy_warning; } + + if other.disable_secure_headers_in_dev_mode.is_some() { + self.disable_secure_headers_in_dev_mode = other.disable_secure_headers_in_dev_mode; + } + self } } @@ -436,6 +485,7 @@ mod rule_utils { encodings: Option>, security_policy: Option, disable_security_policy_warning: Option, + disable_secure_headers_in_dev_mode: Option, } impl AssetConfigRule { @@ -450,6 +500,7 @@ mod rule_utils { encodings, security_policy, disable_security_policy_warning, + disable_secure_headers_in_dev_mode, }: InterimAssetConfigRule, config_file_parent_dir: &Path, ) -> Result { @@ -475,6 +526,7 @@ mod rule_utils { encodings, security_policy, disable_security_policy_warning, + disable_secure_headers_in_dev_mode, }) } } @@ -1245,4 +1297,98 @@ mod with_tempdir { assert_eq!(x.cache.clone().unwrap().max_age, Some(22)); assert_eq!(y.cache.clone().unwrap().max_age, Some(22)); } + + #[test] + fn dev_mode_removes_headers() { + let secstandard_csp_minus_upgrade = SecurityPolicy::Standard.to_headers() + ["Content-Security-Policy"] + .replace("upgrade-insecure-requests;", ""); + let cfg = r#"[ + { + "match": "*.html", + "headers": { + "Content-Security-Policy": "upgrade-insecure-requests", + "Strict-Transport-Security": "max-age=60; includeSubDomains", + "Content-Language": "en-US" + } + }, + { + "match": "nested/*", + "security_policy": "standard" + }, + { + "match": "js/*", + "headers": { + "Content-Security-Policy": "upgrade-insecure-requests", + "Strict-Transport-Security": "max-age=60; includeSubDomains", + "Content-Language": "en-US" + }, + "disable_secure_headers_in_dev_mode": false + }, + { + "match": "css/*", + "security_policy": "standard", + "disable_secure_headers_in_dev_mode": false + }, + ]"#; + fn assert_weak(case: &str, headers: &BTreeMap) { + assert!( + !headers.contains_key("Strict-Transport-Security"), + "{case} has STS: {headers:#?}" + ); + assert!( + !headers + .get("Content-Security-Policy") + .is_some_and(|csp| csp.contains("upgrade-insecure-requests")), + "{case} has strong CSP: {headers:#?}" + ); + } + fn assert_strong(case: &str, headers: &BTreeMap) { + assert!( + headers.contains_key("Strict-Transport-Security"), + "{case} lacks STS: {headers:#?}" + ); + assert!( + headers + .get("Content-Security-Policy") + .is_some_and(|csp| csp.contains("upgrade-insecure-requests")), + "{case} has weak CSP: {headers:#?}" + ); + } + let assets_temp_dir = + create_temporary_assets_directory(Some(HashMap::from([("".into(), cfg.into())])), 0); + let assets_dir = assets_temp_dir.path().canonicalize().unwrap(); + let mut assets_config = AssetSourceDirectoryConfiguration::load(&assets_dir).unwrap(); + + let explicit = assets_config + .get_asset_config(&assets_dir.join("index.html")) + .unwrap(); + let explicit_dev = explicit.combined_headers(true).unwrap(); + assert_weak("a1", &explicit_dev); + assert!(!explicit_dev.contains_key("Content-Security-Policy")); + assert_strong("a2", &explicit.combined_headers(false).unwrap()); + + let implicit = assets_config + .get_asset_config(&assets_dir.join("nested/the-thing.txt")) + .unwrap(); + let implicit_dev = implicit.combined_headers(true).unwrap(); + assert_weak("b1", &implicit_dev); + assert_eq!( + implicit_dev["Content-Security-Policy"], + secstandard_csp_minus_upgrade + ); + assert_strong("b2", &implicit.combined_headers(false).unwrap()); + + let explicit_rigid = assets_config + .get_asset_config(&assets_dir.join("js/index.js")) + .unwrap(); + assert_strong("c1", &explicit_rigid.combined_headers(true).unwrap()); + assert_strong("c2", &explicit_rigid.combined_headers(false).unwrap()); + + let implicit_rigid = assets_config + .get_asset_config(&assets_dir.join("css/main.css")) + .unwrap(); + assert_strong("d1", &implicit_rigid.combined_headers(true).unwrap()); + assert_strong("d2", &implicit_rigid.combined_headers(false).unwrap()); + } } diff --git a/src/canisters/frontend/ic-asset/src/batch_upload/operations.rs b/src/canisters/frontend/ic-asset/src/batch_upload/operations.rs index 76d5e03066..1c8eda0a36 100644 --- a/src/canisters/frontend/ic-asset/src/batch_upload/operations.rs +++ b/src/canisters/frontend/ic-asset/src/batch_upload/operations.rs @@ -21,6 +21,7 @@ pub(crate) async fn assemble_batch_operations( canister_assets: HashMap, asset_deletion_reason: AssetDeletionReason, canister_asset_properties: HashMap, + insecure_dev_mode: bool, ) -> Result, AssembleCommitBatchArgumentError> { let mut canister_assets = canister_assets; @@ -32,12 +33,22 @@ pub(crate) async fn assemble_batch_operations( &mut canister_assets, asset_deletion_reason, ); - create_new_assets(&mut operations, project_assets, &canister_assets); + create_new_assets( + &mut operations, + project_assets, + &canister_assets, + insecure_dev_mode, + ); unset_obsolete_encodings(&mut operations, project_assets, &canister_assets); set_encodings(&mut operations, chunk_uploader, project_assets) .await .map_err(AssembleCommitBatchArgumentError::SetEncodingFailed)?; - update_properties(&mut operations, project_assets, &canister_asset_properties); + update_properties( + &mut operations, + project_assets, + &canister_asset_properties, + insecure_dev_mode, + ); Ok(operations) } @@ -49,6 +60,7 @@ pub(crate) async fn assemble_commit_batch_arguments( asset_deletion_reason: AssetDeletionReason, canister_asset_properties: HashMap, batch_id: Nat, + insecure_dev_mode: bool, ) -> Result { let operations = assemble_batch_operations( Some(chunk_uploader), @@ -56,6 +68,7 @@ pub(crate) async fn assemble_commit_batch_arguments( canister_assets, asset_deletion_reason, canister_asset_properties, + insecure_dev_mode, ) .await?; Ok(CommitBatchArguments { @@ -111,6 +124,7 @@ pub(crate) fn create_new_assets( operations: &mut Vec, project_assets: &HashMap, canister_assets: &HashMap, + insecure_dev_mode: bool, ) { for (key, project_asset) in project_assets { if !canister_assets.contains_key(key) { @@ -121,7 +135,10 @@ pub(crate) fn create_new_assets( .as_ref() .and_then(|c| c.max_age); - let headers = project_asset.asset_descriptor.config.combined_headers(); + let headers = project_asset + .asset_descriptor + .config + .combined_headers(insecure_dev_mode); let enable_aliasing = project_asset.asset_descriptor.config.enable_aliasing; let allow_raw_access = project_asset.asset_descriptor.config.allow_raw_access; @@ -198,6 +215,7 @@ pub(crate) fn update_properties( operations: &mut Vec, project_assets: &HashMap, canister_asset_properties: &HashMap, + insecure_dev_mode: bool, ) { for (key, project_asset) in project_assets { let project_asset_properties = project_asset.asset_descriptor.config.clone(); @@ -218,8 +236,9 @@ pub(crate) fn update_properties( } }, headers: { - let project_asset_headers = - project_asset_properties.combined_headers().map(|hm| { + let project_asset_headers = project_asset_properties + .combined_headers(insecure_dev_mode) + .map(|hm| { let mut vec = Vec::from_iter(hm.into_iter()); vec.sort(); vec @@ -330,7 +349,12 @@ mod test_update_properties { }, ); let mut operations = vec![]; - update_properties(&mut operations, &project_assets, &canister_asset_properties); + update_properties( + &mut operations, + &project_assets, + &canister_asset_properties, + false, + ); assert_eq!(operations.len(), 1); assert_eq!( operations[0], @@ -393,7 +417,12 @@ mod test_update_properties { }, ); let mut operations = vec![]; - update_properties(&mut operations, &project_assets, &canister_asset_properties); + update_properties( + &mut operations, + &project_assets, + &canister_asset_properties, + false, + ); assert_eq!(operations.len(), 0); } @@ -424,7 +453,12 @@ mod test_update_properties { }, ); let mut operations = vec![]; - update_properties(&mut operations, &project_assets, &canister_asset_properties); + update_properties( + &mut operations, + &project_assets, + &canister_asset_properties, + false, + ); assert_eq!(operations.len(), 1); assert_eq!( operations[0], diff --git a/src/canisters/frontend/ic-asset/src/evidence/mod.rs b/src/canisters/frontend/ic-asset/src/evidence/mod.rs index 47ce78291a..de5f94328d 100644 --- a/src/canisters/frontend/ic-asset/src/evidence/mod.rs +++ b/src/canisters/frontend/ic-asset/src/evidence/mod.rs @@ -39,6 +39,7 @@ pub async fn compute_evidence( canister: &Canister<'_>, dirs: &[&Path], logger: &Logger, + insecure_dev_mode: bool, ) -> Result { let asset_descriptors = gather_asset_descriptors(dirs, logger)?; @@ -70,6 +71,7 @@ pub async fn compute_evidence( canister_assets, Obsolete, canister_asset_properties, + insecure_dev_mode, ) .await .map_err(ComputeEvidenceError::AssembleCommitBatchArgumentFailed)?; diff --git a/src/canisters/frontend/ic-asset/src/lib.rs b/src/canisters/frontend/ic-asset/src/lib.rs index 87aa17a3cb..8fd77c0aec 100644 --- a/src/canisters/frontend/ic-asset/src/lib.rs +++ b/src/canisters/frontend/ic-asset/src/lib.rs @@ -20,7 +20,7 @@ //! .with_agent(&agent) //! .build()?; //! let logger = slog::Logger::root(slog::Discard, slog::o!()); -//! ic_asset::sync(&canister, &[concat!(env!("CARGO_MANIFEST_DIR"), "assets/").as_ref()], false, &logger).await?; +//! ic_asset::sync(&canister, &[concat!(env!("CARGO_MANIFEST_DIR"), "assets/").as_ref()], false, &logger, false).await?; //! # Ok(()) //! # } diff --git a/src/canisters/frontend/ic-asset/src/sync.rs b/src/canisters/frontend/ic-asset/src/sync.rs index 12fc27f489..52a21b521b 100644 --- a/src/canisters/frontend/ic-asset/src/sync.rs +++ b/src/canisters/frontend/ic-asset/src/sync.rs @@ -51,6 +51,7 @@ pub async fn upload_content_and_assemble_sync_operations( no_delete: bool, mode: batch_upload::plumbing::Mode, logger: &Logger, + insecure_dev_mode: bool, ) -> Result { let asset_descriptors = gather_asset_descriptors(dirs, logger)?; @@ -100,6 +101,7 @@ pub async fn upload_content_and_assemble_sync_operations( }, canister_asset_properties, batch_id, + insecure_dev_mode, ) .await .map_err(UploadContentError::AssembleCommitBatchArgumentFailed)?; @@ -129,6 +131,7 @@ pub async fn sync( dirs: &[&Path], no_delete: bool, logger: &Logger, + insecure_dev_mode: bool, ) -> Result<(), SyncError> { let canister_api_version = api_version(canister).await; let commit_batch_args = upload_content_and_assemble_sync_operations( @@ -138,6 +141,7 @@ pub async fn sync( no_delete, NormalDeploy, logger, + insecure_dev_mode, ) .await?; debug!(logger, "Canister API version: {canister_api_version}. ic-asset API version: {BATCH_UPLOAD_API_VERSION}"); @@ -211,6 +215,7 @@ pub async fn prepare_sync_for_proposal( canister: &Canister<'_>, dirs: &[&Path], logger: &Logger, + insecure_dev_mode: bool, ) -> Result<(Nat, ByteBuf), PrepareSyncForProposalError> { let canister_api_version = api_version(canister).await; let arg = upload_content_and_assemble_sync_operations( @@ -220,6 +225,7 @@ pub async fn prepare_sync_for_proposal( false, ByProposal, logger, + insecure_dev_mode, ) .await?; let arg = sort_batch_operations(arg); diff --git a/src/canisters/frontend/ic-asset/src/upload.rs b/src/canisters/frontend/ic-asset/src/upload.rs index 10ed9a514a..5a6b06eaeb 100644 --- a/src/canisters/frontend/ic-asset/src/upload.rs +++ b/src/canisters/frontend/ic-asset/src/upload.rs @@ -24,6 +24,7 @@ pub async fn upload( canister: &Canister<'_>, files: HashMap, logger: &Logger, + insecure_dev_mode: bool, ) -> Result<(), UploadError> { let asset_descriptors: Vec = files .iter() @@ -62,6 +63,7 @@ pub async fn upload( AssetDeletionReason::Incompatible, HashMap::new(), batch_id, + insecure_dev_mode, ) .await .map_err(UploadError::AssembleCommitBatchArgumentFailed)?; diff --git a/src/canisters/frontend/icx-asset/src/commands/sync.rs b/src/canisters/frontend/icx-asset/src/commands/sync.rs index 544dc6c761..73bb81d32b 100644 --- a/src/canisters/frontend/icx-asset/src/commands/sync.rs +++ b/src/canisters/frontend/icx-asset/src/commands/sync.rs @@ -9,6 +9,6 @@ pub(crate) async fn sync( logger: &Logger, ) -> anyhow::Result<()> { let dirs: Vec<&Path> = o.directory.iter().map(|d| d.as_path()).collect(); - ic_asset::sync(canister, &dirs, o.no_delete, logger).await?; + ic_asset::sync(canister, &dirs, o.no_delete, logger, o.insecure_dev_mode).await?; Ok(()) } diff --git a/src/canisters/frontend/icx-asset/src/commands/upload.rs b/src/canisters/frontend/icx-asset/src/commands/upload.rs index e7f974299a..8fe1d073b5 100644 --- a/src/canisters/frontend/icx-asset/src/commands/upload.rs +++ b/src/canisters/frontend/icx-asset/src/commands/upload.rs @@ -12,7 +12,7 @@ pub(crate) async fn upload( logger: &Logger, ) -> anyhow::Result<()> { let key_map = get_key_map(&opts.files)?; - ic_asset::upload(canister, key_map, logger).await?; + ic_asset::upload(canister, key_map, logger, opts.insecure_dev_mode).await?; Ok(()) } diff --git a/src/canisters/frontend/icx-asset/src/main.rs b/src/canisters/frontend/icx-asset/src/main.rs index 4c391af877..04a4746bc0 100644 --- a/src/canisters/frontend/icx-asset/src/main.rs +++ b/src/canisters/frontend/icx-asset/src/main.rs @@ -94,6 +94,10 @@ struct SyncOpts { /// Do not delete files from the canister that are not present locally. #[arg(long)] no_delete: bool, + + /// Suppress some security headers to ensure compatibility in all browsers over localhost. + #[arg(long)] + insecure_dev_mode: bool, } #[derive(Parser)] @@ -103,6 +107,10 @@ struct UploadOpts { /// Files or folders to send. files: Vec, + + /// Suppress some security headers to ensure compatibility in all browsers over localhost. + #[arg(long)] + insecure_dev_mode: bool, } fn create_identity(maybe_pem: Option) -> Box { diff --git a/src/dfx/src/lib/installers/assets/mod.rs b/src/dfx/src/lib/installers/assets/mod.rs index be5b7ad7b4..fcdea4098b 100644 --- a/src/dfx/src/lib/installers/assets/mod.rs +++ b/src/dfx/src/lib/installers/assets/mod.rs @@ -2,6 +2,7 @@ use crate::lib::canister_info::assets::AssetsCanisterInfo; use crate::lib::canister_info::CanisterInfo; use crate::lib::error::DfxResult; use anyhow::Context; +use dfx_core::config::model::network_descriptor::NetworkDescriptor; use fn_error_context::context; use ic_agent::Agent; use slog::Logger; @@ -11,6 +12,7 @@ use std::path::Path; pub async fn post_install_store_assets( info: &CanisterInfo, agent: &Agent, + network_descriptor: &NetworkDescriptor, logger: &Logger, ) -> DfxResult { let assets_canister_info = info.as_info::()?; @@ -27,7 +29,9 @@ pub async fn post_install_store_assets( .build() .context("Failed to build asset canister caller.")?; - ic_asset::sync(&canister, &source_paths, false, logger) + let insecure_dev_mode = !network_descriptor.is_ic; + + ic_asset::sync(&canister, &source_paths, false, logger, insecure_dev_mode) .await .with_context(|| { format!( @@ -43,6 +47,7 @@ pub async fn post_install_store_assets( pub async fn prepare_assets_for_proposal( info: &CanisterInfo, agent: &Agent, + network_descriptor: &NetworkDescriptor, logger: &Logger, ) -> DfxResult { let assets_canister_info = info.as_info::()?; @@ -59,7 +64,9 @@ pub async fn prepare_assets_for_proposal( .build() .context("Failed to build asset canister caller.")?; - ic_asset::prepare_sync_for_proposal(&canister, &source_paths, logger) + let insecure_dev_mode = !network_descriptor.is_ic; + + ic_asset::prepare_sync_for_proposal(&canister, &source_paths, logger, insecure_dev_mode) .await .with_context(|| { format!( diff --git a/src/dfx/src/lib/operations/canister/deploy_canisters.rs b/src/dfx/src/lib/operations/canister/deploy_canisters.rs index b1bdff2a3a..4ae5002545 100644 --- a/src/dfx/src/lib/operations/canister/deploy_canisters.rs +++ b/src/dfx/src/lib/operations/canister/deploy_canisters.rs @@ -375,7 +375,13 @@ async fn prepare_assets_for_commit( let agent = env.get_agent(); - prepare_assets_for_proposal(&canister_info, agent, env.get_logger()).await?; + prepare_assets_for_proposal( + &canister_info, + agent, + env.get_network_descriptor(), + env.get_logger(), + ) + .await?; Ok(()) } @@ -412,8 +418,15 @@ async fn compute_evidence( .with_canister_id(canister_id) .build() .context("Failed to build asset canister caller.")?; + let insecure_dev_mode = !env.get_network_descriptor().is_ic; - let evidence = ic_asset::compute_evidence(&canister, &source_paths, env.get_logger()).await?; + let evidence = ic_asset::compute_evidence( + &canister, + &source_paths, + env.get_logger(), + insecure_dev_mode, + ) + .await?; println!("{}", evidence); Ok(()) diff --git a/src/dfx/src/lib/operations/canister/install_canister.rs b/src/dfx/src/lib/operations/canister/install_canister.rs index 3591021dbb..481032fc5e 100644 --- a/src/dfx/src/lib/operations/canister/install_canister.rs +++ b/src/dfx/src/lib/operations/canister/install_canister.rs @@ -278,7 +278,7 @@ The command line value will be used.", }; info!(log, "Uploading assets to asset canister..."); - post_install_store_assets(canister_info, agent, log).await?; + post_install_store_assets(canister_info, agent, env.get_network_descriptor(), log).await?; } if !canister_info.get_post_install().is_empty() { let config = env.get_config()?;