diff --git a/Cargo.lock b/Cargo.lock index 1948a481d..ee5d66fa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2040,6 +2040,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2 0.11.0", "tokio", "tokio-util", "tower", diff --git a/fluxer_app/index.html b/fluxer_app/index.html index 8f301bb09..0fcdce6ef 100644 --- a/fluxer_app/index.html +++ b/fluxer_app/index.html @@ -23,9 +23,9 @@ - - - + + +
diff --git a/fluxer_app/src/app/AppBootstrap.tsx b/fluxer_app/src/app/AppBootstrap.tsx index e71696309..b0e4a5340 100644 --- a/fluxer_app/src/app/AppBootstrap.tsx +++ b/fluxer_app/src/app/AppBootstrap.tsx @@ -160,7 +160,7 @@ async function bootstrapApp(): Promise { loadLazyModule(() => import('@app/features/auth/state/AccountManager')), loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')), loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')), - loadLazyModule(() => import('@app/features/app/state/GeoIP')), + loadLazyModule(() => import('@app/features/app/state/GeoIP')).then(({default: GeoIP}) => GeoIP.load()), loadLazyModule(() => import('@app/features/input/state/InputKeybind')), loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')), loadLazyModule(() => import('@app/features/ui/state/Notification')), diff --git a/fluxer_app/src/env.d.ts b/fluxer_app/src/env.d.ts index 1f1bf193d..70799b370 100644 --- a/fluxer_app/src/env.d.ts +++ b/fluxer_app/src/env.d.ts @@ -2,10 +2,7 @@ import MediaEngineFacade from '@app/features/voice/engine/MediaEngineFacade'; import type {ElectronAPI} from '@app/features/platform/types/Electron'; -import type { - GeolocationResponse, - InstanceDiscoveryResponse, -} from '@fluxer/instance_bootstrap/src/Types'; +import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types'; import {Buffer} from 'buffer'; type MediaEngineInstance = typeof MediaEngineFacade; @@ -31,7 +28,6 @@ interface FluxerBootstrapGlobal { bootstrapApiPublicEndpoint?: string; }; instance: InstanceDiscoveryResponse; - geoip: GeolocationResponse; } declare global { diff --git a/fluxer_app/src/features/app/state/GeoIP.ts b/fluxer_app/src/features/app/state/GeoIP.ts index 00749bf89..fc2fd2d9d 100644 --- a/fluxer_app/src/features/app/state/GeoIP.ts +++ b/fluxer_app/src/features/app/state/GeoIP.ts @@ -1,40 +1,97 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {GeoEntry, GeolocationResponse} from '@fluxer/instance_bootstrap/src/Types'; +import {Logger} from '@app/features/platform/utils/AppLogger'; +import ageGeos from '@fluxer/constants/src/AgeGeos.json'; +import type {GeoEntry} from '@fluxer/instance_bootstrap/src/Types'; +import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas'; import {makeAutoObservable, runInAction} from 'mobx'; +const GEOIP_PATH = '/_geoip'; +const GEOIP_ATTEMPTS = 3; +const GEOIP_ATTEMPT_TIMEOUT_MS = 2000; +const GEOIP_RETRY_DELAY_MS = 200; + +const logger = new Logger('GeoIP'); + interface ConnectionGeoCoordinates { latitude: string | null; longitude: string | null; } -function getInlinedGeoip(): GeolocationResponse { - const bootstrap = typeof window !== 'undefined' ? window.__FLUXER_BOOTSTRAP__ : undefined; - if (!bootstrap) { - throw new Error('window.__FLUXER_BOOTSTRAP__ is missing — app must be served by fluxer_app_proxy'); - } - return bootstrap.geoip; +const UNRESOLVED_GEOIP: GeolocationResponse = { + countryCode: null, + regionCode: null, + latitude: null, + longitude: null, + ageRestrictedGeos: ageGeos.ageRestrictedGeos, + ageBlockedGeos: ageGeos.ageBlockedGeos, +}; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } +async function fetchGeoipOnce(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GEOIP_ATTEMPT_TIMEOUT_MS); + try { + const response = await fetch(GEOIP_PATH, { + cache: 'no-store', + credentials: 'omit', + headers: {Accept: 'application/json'}, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`GeoIP lookup failed with status ${response.status}`); + } + return GeolocationResponse.parse(await response.json()); + } finally { + clearTimeout(timer); + } +} + +async function fetchGeoip(): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await fetchGeoipOnce(); + } catch (error) { + if (attempt >= GEOIP_ATTEMPTS) { + logger.warn('GeoIP lookup failed, continuing without a location:', error); + return UNRESOLVED_GEOIP; + } + await wait(GEOIP_RETRY_DELAY_MS * attempt); + } + } +} + +let loading: Promise | null = null; + class GeoIP { - countryCode: string | null; - regionCode: string | null; - latitude: string | null; - longitude: string | null; - ageRestrictedGeos: ReadonlyArray; - ageBlockedGeos: ReadonlyArray; + countryCode: string | null = UNRESOLVED_GEOIP.countryCode; + regionCode: string | null = UNRESOLVED_GEOIP.regionCode; + latitude: string | null = UNRESOLVED_GEOIP.latitude; + longitude: string | null = UNRESOLVED_GEOIP.longitude; + ageRestrictedGeos: ReadonlyArray = UNRESOLVED_GEOIP.ageRestrictedGeos; + ageBlockedGeos: ReadonlyArray = UNRESOLVED_GEOIP.ageBlockedGeos; constructor() { - const data = getInlinedGeoip(); - this.countryCode = data.countryCode; - this.regionCode = data.regionCode; - this.latitude = data.latitude; - this.longitude = data.longitude; - this.ageRestrictedGeos = data.ageRestrictedGeos; - this.ageBlockedGeos = data.ageBlockedGeos; makeAutoObservable(this, {}, {autoBind: true}); } + load(): Promise { + loading ??= fetchGeoip().then((data) => { + runInAction(() => { + this.countryCode = data.countryCode; + this.regionCode = data.regionCode; + this.latitude = data.latitude; + this.longitude = data.longitude; + this.ageRestrictedGeos = data.ageRestrictedGeos; + this.ageBlockedGeos = data.ageBlockedGeos; + }); + }); + return loading; + } + applyConnectionFallbackCoordinates(data: ConnectionGeoCoordinates): void { if (data.latitude === null || data.longitude === null) { return; diff --git a/fluxer_app/src/features/platform/service_worker/WorkerAppShell.test.ts b/fluxer_app/src/features/platform/service_worker/WorkerAppShell.test.ts index 14a7e48c4..c4d43cb22 100644 --- a/fluxer_app/src/features/platform/service_worker/WorkerAppShell.test.ts +++ b/fluxer_app/src/features/platform/service_worker/WorkerAppShell.test.ts @@ -19,14 +19,14 @@ const UNRENDERED_INDEX_TEMPLATE = [ '', '', '', - '', + '', '
', ].join(''); const RENDERED_INDEX_DOCUMENT = [ '', '', - '', + '', '
', ].join(''); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppScreenShareWiring.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppScreenShareWiring.test.ts index 5fa07baea..ad218db95 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppScreenShareWiring.test.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppScreenShareWiring.test.ts @@ -344,14 +344,6 @@ const BOOTSTRAP_ENDPOINT = 'https://primary.test/api'; registration: {collect_date_of_birth: true}, }, }, - geoip: { - countryCode: null, - regionCode: null, - latitude: null, - longitude: null, - ageRestrictedGeos: [], - ageBlockedGeos: [], - }, }; const {VoiceEngineV2AppScreenShareExecutionAdapter, shouldRestoreScreenShareAfterReconnect} = await import( diff --git a/fluxer_app_proxy/Cargo.toml b/fluxer_app_proxy/Cargo.toml index fff339ddf..cbe10950e 100644 --- a/fluxer_app_proxy/Cargo.toml +++ b/fluxer_app_proxy/Cargo.toml @@ -16,6 +16,7 @@ rand = "0.10" reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream", "gzip", "brotli", "deflate"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +sha2 = "0.11.0" tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "time", "fs"] } tokio-util = { version = "0.7.19", features = ["io"] } tower = { version = "0.5.3", features = ["util"] } diff --git a/fluxer_app_proxy/src/bootstrap.rs b/fluxer_app_proxy/src/bootstrap.rs index 8e809d031..413c1a5b5 100644 --- a/fluxer_app_proxy/src/bootstrap.rs +++ b/fluxer_app_proxy/src/bootstrap.rs @@ -9,7 +9,6 @@ use serde::Serialize; pub struct BootstrapPayload<'a> { pub config: BootstrapConfig<'a>, pub instance: &'a serde_json::Value, - pub geoip: &'a serde_json::Value, } #[derive(Serialize)] @@ -38,12 +37,7 @@ struct LegacyConfig<'a> { bootstrap_api_public_endpoint: Option<&'a str>, } -pub fn build_bootstrap_script( - config: &AppProxyConfig, - discovery: &DiscoveryResponse, - geoip: &serde_json::Value, - nonce: &str, -) -> String { +pub fn build_bootstrap_script(config: &AppProxyConfig, discovery: &DiscoveryResponse) -> String { let api_public_endpoint = api_public_endpoint(config.bootstrap_api_public_endpoint.as_deref(), discovery); @@ -54,7 +48,6 @@ pub fn build_bootstrap_script( bootstrap_api_public_endpoint: api_public_endpoint, }, instance: &discovery.data, - geoip, }; let legacy = LegacyConfig { @@ -67,7 +60,7 @@ pub fn build_bootstrap_script( let legacy_json = escape_json_for_script(&serde_json::to_string(&legacy).unwrap()); format!( - r#""# + r#""# ) } @@ -144,7 +137,6 @@ const STATIC_PRECONNECT_TAGS: [&str; 2] = [ pub fn inject_bootstrap( html: &str, - nonce: &str, script_tag: &str, static_cdn_endpoint: &str, media_endpoint: &str, @@ -152,35 +144,34 @@ pub fn inject_bootstrap( let static_cdn = static_cdn_endpoint.trim_end_matches('/'); let media = media_endpoint.trim_end_matches('/'); - let nonced = html.replace("{{CSP_NONCE_PLACEHOLDER}}", nonce); - let nonced = apply_static_preconnect(nonced, static_cdn); - let nonced = nonced.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn); - let nonced = apply_media_preconnect(&nonced, media, static_cdn); + let html = apply_static_preconnect(html.to_owned(), static_cdn); + let html = html.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn); + let html = apply_media_preconnect(&html, media, static_cdn); - if nonced.contains("") { - return nonced.replace("", script_tag); + if html.contains("") { + return html.replace("", script_tag); } - if nonced.contains("{{FLUXER_BOOTSTRAP}}") { - return nonced.replace("{{FLUXER_BOOTSTRAP}}", script_tag); + if html.contains("{{FLUXER_BOOTSTRAP}}") { + return html.replace("{{FLUXER_BOOTSTRAP}}", script_tag); } - let insert_at = nonced + let insert_at = html .find("") .map(|pos| pos + "".len()) .or_else(|| { - let pos = nonced.find("').map(|close| pos + close + 1) + let pos = html.find("').map(|close| pos + close + 1) }); if let Some(insert_at) = insert_at { - let mut result = String::with_capacity(nonced.len() + script_tag.len() + 3); - result.push_str(&nonced[..insert_at]); + let mut result = String::with_capacity(html.len() + script_tag.len() + 3); + result.push_str(&html[..insert_at]); result.push_str("\n\t\t"); result.push_str(script_tag); - result.push_str(&nonced[insert_at..]); + result.push_str(&html[insert_at..]); return result; } - nonced + html } fn apply_static_preconnect(mut html: String, static_cdn: &str) -> String { @@ -219,7 +210,6 @@ mod tests { fn inject_into_shipped_shell() -> String { inject_bootstrap( SHIPPED_APP_SHELL, - "shellnonce", "", "https://cdn.example.test/", "https://media.example.test/", @@ -256,16 +246,20 @@ mod tests { let result = inject_into_shipped_shell(); assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}")); assert!(!result.contains("{{MEDIA_ENDPOINT}}")); - assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}")); assert!(!result.contains("{{FLUXER_BOOTSTRAP}}")); assert!(result.contains("")); - assert!(result.contains(r#"nonce="shellnonce""#)); + } + + #[test] + fn shipped_shell_carries_no_nonce_attribute_or_placeholder() { + assert!(!SHIPPED_APP_SHELL.contains("nonce")); + assert!(!SHIPPED_APP_SHELL.contains("{{CSP_NONCE_PLACEHOLDER}}")); } #[test] fn inject_bootstrap_before_head_close() { let html = "App"; - let result = inject_bootstrap(html, "abc123", "", "", ""); + let result = inject_bootstrap(html, "", "", ""); assert!(result.contains("")); assert!(result.contains("")); } @@ -273,7 +267,7 @@ mod tests { #[test] fn inject_bootstrap_fluxer_placeholder() { let html = "{{FLUXER_BOOTSTRAP}}"; - let result = inject_bootstrap(html, "n1", "", "", ""); + let result = inject_bootstrap(html, "", "", ""); assert!(result.contains("")); assert!(!result.contains("{{FLUXER_BOOTSTRAP}}")); } @@ -281,25 +275,16 @@ mod tests { #[test] fn inject_bootstrap_comment_placeholder() { let html = ""; - let result = inject_bootstrap(html, "n2", "", "", ""); + let result = inject_bootstrap(html, "", "", ""); assert!(result.contains("")); assert!(!result.contains("")); } - #[test] - fn inject_bootstrap_replaces_csp_nonce_placeholder() { - let html = r#"{{FLUXER_BOOTSTRAP}}"#; - let result = inject_bootstrap(html, "mynonce", "", "", ""); - assert!(result.contains(r#"nonce="mynonce""#)); - assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}")); - } - #[test] fn inject_bootstrap_replaces_static_cdn_endpoint_placeholder() { let html = r#"{{FLUXER_BOOTSTRAP}}"#; let result = inject_bootstrap( html, - "nonce", "", "https://cdn.example.test/", "", @@ -315,7 +300,6 @@ mod tests { {{FLUXER_BOOTSTRAP}}"#; let result = inject_bootstrap( html, - "nonce", "", "https://cdn.example.test/", "https://media.example.test/", @@ -332,7 +316,6 @@ mod tests { {{FLUXER_BOOTSTRAP}}"#; let result = inject_bootstrap( html, - "nonce", "", "https://cdn.example.test", "", @@ -349,7 +332,6 @@ mod tests { {{FLUXER_BOOTSTRAP}}"#; let result = inject_bootstrap( html, - "nonce", "", "https://cdn.example.test", "https://cdn.example.test/", @@ -367,7 +349,6 @@ mod tests { fn static_cdn_keeps_a_credentialed_and_an_anonymous_preconnect() { let result = inject_bootstrap( SHELL_PRECONNECT_HEAD, - "nonce", "", "https://cdn.example.test/", "https://media.example.test", @@ -384,7 +365,6 @@ mod tests { fn both_static_preconnects_are_dropped_when_the_endpoint_is_empty() { let result = inject_bootstrap( SHELL_PRECONNECT_HEAD, - "nonce", "", "", "https://media.example.test", @@ -422,7 +402,6 @@ mod tests { #[test] fn bootstrap_payload_serialization_field_names() { let instance = serde_json::json!({"name": "test"}); - let geoip = serde_json::json!({"country": "SE"}); let payload = BootstrapPayload { config: BootstrapConfig { release_channel: "stable", @@ -430,20 +409,18 @@ mod tests { bootstrap_api_public_endpoint: None, }, instance: &instance, - geoip: &geoip, }; let json = serde_json::to_string(&payload).unwrap(); assert!(json.contains(r#""releaseChannel""#)); assert!(json.contains(r#""bootstrapApiEndpoint""#)); assert!(json.contains(r#""config""#)); assert!(json.contains(r#""instance""#)); - assert!(json.contains(r#""geoip""#)); + assert!(!json.contains("geoip")); } #[test] fn bootstrap_config_serializes_public_endpoint_when_present() { let instance = serde_json::json!({}); - let geoip = serde_json::json!({}); let payload = BootstrapPayload { config: BootstrapConfig { release_channel: "canary", @@ -451,7 +428,6 @@ mod tests { bootstrap_api_public_endpoint: Some("https://pub.example.com/api"), }, instance: &instance, - geoip: &geoip, }; let json = serde_json::to_string(&payload).unwrap(); assert!(json.contains(r#""bootstrapApiPublicEndpoint""#)); @@ -460,7 +436,6 @@ mod tests { #[test] fn bootstrap_config_omits_public_endpoint_when_none() { let instance = serde_json::json!({}); - let geoip = serde_json::json!({}); let payload = BootstrapPayload { config: BootstrapConfig { release_channel: "stable", @@ -468,7 +443,6 @@ mod tests { bootstrap_api_public_endpoint: None, }, instance: &instance, - geoip: &geoip, }; let json = serde_json::to_string(&payload).unwrap(); assert!(!json.contains("bootstrapApiPublicEndpoint")); @@ -611,8 +585,7 @@ mod tests { let discovery = discovery_offering("https://chat.example.test:8443/api"); let mut config = AppProxyConfig::from_env(); config.bootstrap_api_public_endpoint = Some("https://chat.example.test/api".to_owned()); - let script = - build_bootstrap_script(&config, &discovery, &serde_json::json!({}), "scriptnonce"); + let script = build_bootstrap_script(&config, &discovery); assert!( script.contains(r#""bootstrapApiPublicEndpoint":"https://chat.example.test:8443/api""#) ); @@ -621,4 +594,16 @@ mod tests { )); assert!(!script.contains(r#""https://chat.example.test/api""#)); } + + #[test] + fn the_boot_script_is_a_bare_inline_script_with_nothing_per_visitor() { + let discovery = discovery_offering("https://chat.example.test/api"); + let config = AppProxyConfig::from_env(); + let script = build_bootstrap_script(&config, &discovery); + assert!(script.starts_with("")); + assert!(!script.contains("nonce")); + assert!(!script.contains("geoip")); + assert!(!script.contains("countryCode")); + } } diff --git a/fluxer_app_proxy/src/csp.rs b/fluxer_app_proxy/src/csp.rs index a0f559a4b..bd6493211 100644 --- a/fluxer_app_proxy/src/csp.rs +++ b/fluxer_app_proxy/src/csp.rs @@ -3,15 +3,82 @@ use crate::config::{AppProxyConfig, CspConfig, CspSource, HttpEndpoint}; use axum::http::HeaderValue; use axum::http::header::InvalidHeaderValue; -use rand::RngExt; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use sha2::{Digest, Sha256}; -const CSP_NONCE_HEX_DIGITS: usize = 32; -const CSP_VALIDATION_NONCE: &str = "00000000000000000000000000000000"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineScriptHash(String); -const _: () = assert!( - CSP_VALIDATION_NONCE.len() == CSP_NONCE_HEX_DIGITS, - "the nonce a policy is validated with must be shaped like the nonce a request carries" -); +impl InlineScriptHash { + pub fn of(script_text: &str) -> Self { + Self(format!( + "'sha256-{}'", + STANDARD.encode(Sha256::digest(script_text.as_bytes())) + )) + } + + pub fn as_source(&self) -> &str { + &self.0 + } +} + +pub fn inline_script_hashes(document: &str) -> Vec { + let mut hashes: Vec = Vec::new(); + let mut rest = document; + while let Some((attributes, text, after)) = next_script_element(rest) { + rest = after; + if has_src_attribute(attributes) { + continue; + } + let hash = InlineScriptHash::of(text); + if !hashes.contains(&hash) { + hashes.push(hash); + } + } + hashes +} + +fn next_script_element(html: &str) -> Option<(&str, &str, &str)> { + let mut offset = 0; + loop { + let start = offset + find_ignoring_ascii_case(&html[offset..], "' && boundary != b'/' && !boundary.is_ascii_whitespace() { + offset = name_end; + continue; + } + let tag_end = name_end + html[name_end..].find('>')?; + let text_start = tag_end + 1; + let text_end = text_start + find_ignoring_ascii_case(&html[text_start..], "') + .map_or(html.len(), |index| text_end + index + 1); + return Some(( + &html[name_end..tag_end], + &html[text_start..text_end], + &html[close_end..], + )); + } +} + +fn find_ignoring_ascii_case(haystack: &str, needle: &str) -> Option { + haystack + .as_bytes() + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn has_src_attribute(attributes: &str) -> bool { + attributes + .split(|c: char| c.is_ascii_whitespace() || c == '/') + .any(|token| { + token + .split('=') + .next() + .is_some_and(|name| name.eq_ignore_ascii_case("src")) + }) +} #[derive(Clone, Debug, Default)] pub struct RuntimeCspSources { @@ -129,7 +196,7 @@ impl CompiledCspPolicy { .map_err(CspCompileError::InvalidAssetPolicy)?; HeaderValue::from_str(&build_csp( &config, - CSP_VALIDATION_NONCE, + &[InlineScriptHash::of("")], configured_sources, )) .map_err(CspCompileError::InvalidSpaPolicy)?; @@ -140,26 +207,24 @@ impl CompiledCspPolicy { self.asset.clone() } - pub fn spa_header(&self, nonce: &str, runtime_sources: &RuntimeCspSources) -> HeaderValue { - assert!( - nonce.len() == CSP_NONCE_HEX_DIGITS - && nonce.bytes().all(|byte| byte.is_ascii_hexdigit()), - "a CSP nonce must be a 128-bit hexadecimal value" - ); - HeaderValue::from_str(&build_csp(&self.config, nonce, runtime_sources)).expect( - "every CSP source is a validated keyword, scheme, or ASCII origin, so a policy built \ - from them is always a valid header value", + pub fn spa_header( + &self, + script_hashes: &[InlineScriptHash], + runtime_sources: &RuntimeCspSources, + ) -> HeaderValue { + HeaderValue::from_str(&build_csp(&self.config, script_hashes, runtime_sources)).expect( + "every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \ + policy built from them is always a valid header value", ) } } -pub fn generate_nonce() -> String { - let bytes: [u8; 16] = rand::rng().random(); - hex::encode(bytes) -} - -fn build_csp(config: &CspConfig, nonce: &str, runtime_sources: &RuntimeCspSources) -> String { - build_csp_directives(config, Some(nonce), runtime_sources).join("; ") +fn build_csp( + config: &CspConfig, + script_hashes: &[InlineScriptHash], + runtime_sources: &RuntimeCspSources, +) -> String { + build_csp_directives(config, Some(script_hashes), runtime_sources).join("; ") } fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String { @@ -168,7 +233,7 @@ fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> S fn build_csp_directives( config: &CspConfig, - nonce: Option<&str>, + script_hashes: Option<&[InlineScriptHash]>, runtime_sources: &RuntimeCspSources, ) -> Vec { let mut directives = Vec::with_capacity(14); @@ -182,8 +247,8 @@ fn build_csp_directives( "'wasm-unsafe-eval'".to_owned(), "blob:".to_owned(), ]; - if let Some(n) = nonce { - script.insert(1, format!("'nonce-{n}'")); + if let Some(hashes) = script_hashes { + script.splice(1..1, hashes.iter().map(|hash| hash.as_source().to_owned())); } extend_from(&mut script, &config.extra_script_src, SCRIPT_SOURCES); extend_runtime_sources(&mut script, runtime_sources, true, false); @@ -290,21 +355,52 @@ fn extend_from(target: &mut Vec, extra: &[CspSource], defaults: &[&str]) mod tests { use super::*; - #[test] - fn generate_nonce_produces_32_char_hex() { - let nonce = generate_nonce(); - assert_eq!(nonce.len(), CSP_NONCE_HEX_DIGITS); - assert!(nonce.chars().all(|c| c.is_ascii_hexdigit())); - CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()) - .unwrap() - .spa_header(&nonce, &runtime_sources()); + fn hash_of(text: &str) -> InlineScriptHash { + InlineScriptHash::of(text) } #[test] - fn generate_nonce_is_random() { - let a = generate_nonce(); - let b = generate_nonce(); - assert_ne!(a, b); + fn an_inline_script_hash_is_the_base64_sha256_of_the_exact_script_text() { + assert_eq!( + hash_of("alert('Hello, world.');").as_source(), + "'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='" + ); + assert_eq!( + hash_of("").as_source(), + "'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" + ); + } + + #[test] + fn every_inline_script_is_hashed_and_external_scripts_are_not() { + let document = concat!( + "", + "", + "", + "", + "not a script", + "", + ); + + assert_eq!( + inline_script_hashes(document), + vec![ + hash_of("first()"), + hash_of("second()"), + hash_of("\nthird()\n") + ] + ); + } + + #[test] + fn an_inline_script_repeated_verbatim_is_granted_once() { + let document = ""; + assert_eq!(inline_script_hashes(document), vec![hash_of("same()")]); + } + + #[test] + fn an_unterminated_script_is_never_granted() { + assert!(inline_script_hashes(""#; + const SHELL_WITH_AN_INLINE_SCRIPT: &str = r#"Fluxer"#; #[test] - fn the_rendered_document_always_carries_the_bootstrap_and_a_real_nonce() { + fn the_rendered_document_always_carries_the_bootstrap() { let rendered = render_spa_document( - SHELL_WITH_A_NONCE_HOLE, - "reqnonce", + SHELL_WITH_AN_INLINE_SCRIPT, "", "https://static.example.test", "", @@ -678,16 +676,15 @@ mod tests { ) .expect("test SPA document must render within its size limit"); - assert!(!rendered.contains("{{CSP_NONCE_PLACEHOLDER}}")); - assert!(rendered.contains(r#"nonce="reqnonce""#)); assert!(rendered.contains("")); + assert!(rendered.contains("")); + assert!(!rendered.contains("nonce")); } #[test] fn the_dev_cache_buster_reaches_the_rendered_document_only_when_supplied() { let busted = render_spa_document( - SHELL_WITH_A_NONCE_HOLE, - "reqnonce", + SHELL_WITH_AN_INLINE_SCRIPT, "", "", "", @@ -695,8 +692,7 @@ mod tests { ) .expect("test SPA document must render within its size limit"); let untouched = render_spa_document( - SHELL_WITH_A_NONCE_HOLE, - "reqnonce", + SHELL_WITH_AN_INLINE_SCRIPT, "", "", "", @@ -712,13 +708,12 @@ mod tests { const SHELL_WITH_ENDPOINT_HOLES: &str = r#"Fluxer -"#; +"#; #[test] fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() { let rendered = render_spa_document( SHELL_WITH_ENDPOINT_HOLES, - "reqnonce", "", "https://cdn.example.test/", "https://media.example.test", @@ -751,7 +746,6 @@ mod tests { fn the_media_argument_is_resolved_and_weighed_against_the_static_cdn() { let distinct = render_spa_document( SHELL_WITH_ENDPOINT_HOLES, - "reqnonce", "", "https://cdn.example.test", "https://media.example.test/", @@ -767,7 +761,6 @@ mod tests { let shared = render_spa_document( SHELL_WITH_ENDPOINT_HOLES, - "reqnonce", "", "https://cdn.example.test", "https://cdn.example.test", @@ -934,21 +927,72 @@ mod tests { } } - fn nonce_granted_by(response: &Response) -> String { - let policy = response + fn policy_of(response: &Response) -> String { + response .headers() .get(header::CONTENT_SECURITY_POLICY) .expect("the document was served without a content security policy") .to_str() - .unwrap(); - let opening = policy - .find("'nonce-") - .expect("the content security policy granted no nonce at all"); - let remainder = &policy[opening + "'nonce-".len()..]; - let closing = remainder - .find('\'') - .expect("the content security policy left its nonce source unterminated"); - remainder[..closing].to_owned() + .unwrap() + .to_owned() + } + + fn script_hashes_granted_by(policy: &str) -> Vec { + policy + .split("; ") + .find(|directive| directive.starts_with("script-src ")) + .expect("the content security policy has no script-src directive") + .split(' ') + .filter(|source| source.starts_with("'sha256-")) + .map(str::to_owned) + .collect() + } + + fn bare_inline_scripts_in(document: &str) -> Vec<&str> { + document + .split("") + .expect("an inline script in the served document is never closed")] + }) + .collect() + } + + fn sha256_source(text: &str) -> String { + use base64::Engine as _; + use sha2::Digest as _; + format!( + "'sha256-{}'", + base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(text)) + ) + } + + fn assert_every_inline_script_is_granted(document: &str, policy: &str) { + for tag in document.split("').unwrap()]; + assert!( + tag.is_empty() || tag.contains(" src="), + "the served document carries a script tag the test cannot classify: " + ); + } + let inline = bare_inline_scripts_in(document); + assert!( + !inline.is_empty(), + "the served document carries no inline script at all" + ); + let mut expected: Vec = inline.iter().map(|script| sha256_source(script)).collect(); + expected.sort(); + expected.dedup(); + let mut granted = script_hashes_granted_by(policy); + granted.sort(); + assert_eq!( + granted, expected, + "the policy must grant exactly the inline scripts the document carries" + ); + assert!(!document.contains("nonce")); + assert!(!policy.contains("nonce")); } async fn read_document(response: Response) -> String { @@ -1059,26 +1103,14 @@ mod tests { let response = serve_spa_index(&state, &HeaderMap::new()).await; assert_eq!(response.status(), StatusCode::OK); - let granted_nonce = nonce_granted_by(&response); + let policy = policy_of(&response); let served = read_document(response).await; assert!( - !served.contains("{{CSP_NONCE_PLACEHOLDER}}"), - "the live branch shipped an unfilled nonce hole" - ); - assert!( - served.contains(&format!( - r#"