Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ic-assets dev mode #4011

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### feat: error when using insecure identity on mainnet

This used to be a warning. A hard error can abort the command so that no insecure state will be on the mainnet.
Expand Down
158 changes: 152 additions & 6 deletions src/canisters/frontend/ic-asset/src/asset/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ pub struct AssetConfig {
pub(crate) encodings: Option<Vec<ContentEncoder>>,
pub(crate) security_policy: Option<SecurityPolicy>,
pub(crate) disable_security_policy_warning: Option<bool>,
pub(crate) disable_secure_headers_in_dev_mode: Option<bool>,
ericswanson-dfinity marked this conversation as resolved.
Show resolved Hide resolved
}

impl AssetConfig {
pub fn combined_headers(&self) -> Option<HeadersConfig> {
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<HeadersConfig> {
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<String> =
Expand All @@ -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
}
});
}
}

Expand Down Expand Up @@ -117,6 +159,8 @@ pub struct AssetConfigRule {
security_policy: Option<SecurityPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_security_policy_warning: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_secure_headers_in_dev_mode: Option<bool>,
}

#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -419,6 +468,7 @@ mod rule_utils {
encodings: Option<Vec<ContentEncoder>>,
security_policy: Option<SecurityPolicy>,
disable_security_policy_warning: Option<bool>,
disable_secure_headers_in_dev_mode: Option<bool>,
}

impl AssetConfigRule {
Expand All @@ -433,6 +483,7 @@ mod rule_utils {
encodings,
security_policy,
disable_security_policy_warning,
disable_secure_headers_in_dev_mode,
}: InterimAssetConfigRule,
config_file_parent_dir: &Path,
) -> Result<Self, LoadRuleError> {
Expand All @@ -458,6 +509,7 @@ mod rule_utils {
encodings,
security_policy,
disable_security_policy_warning,
disable_secure_headers_in_dev_mode,
})
}
}
Expand Down Expand Up @@ -1228,4 +1280,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<String, String>) {
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<String, String>) {
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());
}
}
50 changes: 42 additions & 8 deletions src/canisters/frontend/ic-asset/src/batch_upload/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub(crate) async fn assemble_batch_operations(
canister_assets: HashMap<String, AssetDetails>,
asset_deletion_reason: AssetDeletionReason,
canister_asset_properties: HashMap<String, AssetProperties>,
insecure_dev_mode: bool,
) -> Result<Vec<BatchOperationKind>, AssembleCommitBatchArgumentError> {
let mut canister_assets = canister_assets;

Expand All @@ -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)
}
Expand All @@ -49,13 +60,15 @@ pub(crate) async fn assemble_commit_batch_arguments(
asset_deletion_reason: AssetDeletionReason,
canister_asset_properties: HashMap<String, AssetProperties>,
batch_id: Nat,
insecure_dev_mode: bool,
) -> Result<CommitBatchArguments, AssembleCommitBatchArgumentError> {
let operations = assemble_batch_operations(
Some(chunk_uploader),
&project_assets,
canister_assets,
asset_deletion_reason,
canister_asset_properties,
insecure_dev_mode,
)
.await?;
Ok(CommitBatchArguments {
Expand Down Expand Up @@ -111,6 +124,7 @@ pub(crate) fn create_new_assets(
operations: &mut Vec<BatchOperationKind>,
project_assets: &HashMap<String, ProjectAsset>,
canister_assets: &HashMap<String, AssetDetails>,
insecure_dev_mode: bool,
) {
for (key, project_asset) in project_assets {
if !canister_assets.contains_key(key) {
Expand All @@ -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;

Expand Down Expand Up @@ -198,6 +215,7 @@ pub(crate) fn update_properties(
operations: &mut Vec<BatchOperationKind>,
project_assets: &HashMap<String, ProjectAsset>,
canister_asset_properties: &HashMap<String, AssetProperties>,
insecure_dev_mode: bool,
) {
for (key, project_asset) in project_assets {
let project_asset_properties = project_asset.asset_descriptor.config.clone();
Expand All @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions src/canisters/frontend/ic-asset/src/evidence/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub async fn compute_evidence(
canister: &Canister<'_>,
dirs: &[&Path],
logger: &Logger,
insecure_dev_mode: bool,
) -> Result<String, ComputeEvidenceError> {
let asset_descriptors = gather_asset_descriptors(dirs, logger)?;

Expand All @@ -64,6 +65,7 @@ pub async fn compute_evidence(
canister_assets,
Obsolete,
canister_asset_properties,
insecure_dev_mode,
)
.await
.map_err(ComputeEvidenceError::AssembleCommitBatchArgumentFailed)?;
Expand Down
2 changes: 1 addition & 1 deletion src/canisters/frontend/ic-asset/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
//! # }

Expand Down
Loading
Loading