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..], "')
+ .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("