diff --git a/cinny b/cinny index 747400e..8e30c73 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 747400ea258fbb9fd1297c5e82d0081251bbfff0 +Subproject commit 8e30c73e2fd5584c843f39e9ffdab7ab79ff71b1 diff --git a/config.json b/config.json index 4fa9e5d..150d1cd 100644 --- a/config.json +++ b/config.json @@ -25,6 +25,7 @@ }, "gifApiKey": "", "webAppUrl": "https://chat.lotusguild.org", + "desktopCallOrigin": "http://127.0.0.1:44548", "statusPages": { "matrix.lotusguild.org": { "url": "https://isitup.lotusguild.org", diff --git a/scripts/windows-smoke.mjs b/scripts/windows-smoke.mjs index a757340..1808d1b 100644 --- a/scripts/windows-smoke.mjs +++ b/scripts/windows-smoke.mjs @@ -11,6 +11,7 @@ // instead of failing, so the same script runs on main and on PR branches. import { writeFileSync, mkdirSync } from 'node:fs'; import { execSync } from 'node:child_process'; +import { createServer } from 'node:http'; import { chromium } from 'playwright-core'; const OUT = process.argv[2] || 'smoke-out'; @@ -150,21 +151,38 @@ else if (!kc.supported) record('keychain round trip', 'fail', 'secure_session_su else record('keychain round trip', kc.roundTrip && kc.restored ? 'pass' : 'fail', JSON.stringify(kc)); // 7. A foreign page loaded in the window must not get the microphone (#22). -await page.goto('https://example.com/').catch(() => undefined); -const foreignMic = await page.evaluate(async () => { - try { - const s = await navigator.mediaDevices.getUserMedia({ audio: true }); - s.getTracks().forEach((t) => t.stop()); - return 'ok'; - } catch (e) { - return e.name; - } +// Served locally on another port (a different origin, still a secure context), +// so the check doesn't depend on the runner reaching the internet. +const foreign = createServer((_, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('foreignforeign page'); }); -record( - 'microphone refused to a foreign page', - foreignMic === 'NotAllowedError' ? 'pass' : 'info', - `${foreignMic}${foreignMic === 'ok' ? ' (build without the #22 origin check)' : ''}`, -); +await new Promise((r) => foreign.listen(9333, '127.0.0.1', r)); +const FOREIGN = 'http://localhost:9333/'; +await page.goto(FOREIGN).catch(() => undefined); +if (!page.url().startsWith(FOREIGN)) { + record('microphone refused to a foreign page', 'fail', `navigation did not happen (at ${page.url()})`); +} else { + const foreignMic = await page.evaluate(async () => { + try { + const s = await navigator.mediaDevices.getUserMedia({ audio: true }); + s.getTracks().forEach((t) => t.stop()); + return 'ok'; + } catch (e) { + return e.name; + } + }); + const guarded = cfg.desktopCallOrigin !== undefined; // builds with #22 also carry #43 + let status = 'info'; + if (foreignMic === 'NotAllowedError') status = 'pass'; + else if (guarded) status = 'fail'; + record( + 'microphone refused to a foreign page', + status, + `${foreignMic} at ${page.url()}${status === 'info' ? ' (build without the #22 origin check)' : ''}`, + ); +} +foreign.close(); await page.goto(APP).catch(() => undefined); await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a676eb6..e76c2c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,8 @@ use tauri_plugin_opener::OpenerExt; pub mod gpu_workarounds; mod native; mod secure_session; +#[cfg(any(target_os = "linux", target_os = "windows", test))] +mod webview_permissions; /// Bring the main window to the foreground from the tray / a hidden / /// minimized state. Shared by the tray, single-instance, and deep-link paths. @@ -190,6 +192,47 @@ mod update_retry { } } +/// How this copy of the app gets updated. +/// +/// Tauri's updater can replace the Windows install and a Linux AppImage, but +/// not a copy installed by a package manager: it tries to write next to the +/// binary in /usr/bin and fails with "Permission denied (os error 13)" +/// (reported on CachyOS). Those installs update through their package +/// manager instead; the web UI shows the right command. +pub(crate) fn install_kind(linux: bool, appimage: bool, os_release: &str) -> &'static str { + if !linux || appimage { + return "in-app"; + } + let field = |key: &str| { + os_release + .lines() + .find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('='))) + .map(|v| v.trim().trim_matches('"').to_ascii_lowercase()) + .unwrap_or_default() + }; + let ids = format!("{} {}", field("ID"), field("ID_LIKE")); + let has = |name: &str| ids.split_whitespace().any(|w| w == name); + if has("arch") { + "pacman" + } else if has("debian") || has("ubuntu") { + "deb" + } else { + "manual" + } +} + +#[tauri::command] +fn update_install_kind() -> &'static str { + let linux = cfg!(target_os = "linux"); + let appimage = std::env::var_os("APPIMAGE").is_some(); + let os_release = if linux { + std::fs::read_to_string("/etc/os-release").unwrap_or_default() + } else { + String::new() + }; + install_kind(linux, appimage, &os_release) +} + #[tauri::command] async fn check_for_update(app: tauri::AppHandle) -> Result { #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -212,6 +255,15 @@ async fn install_update(app: tauri::AppHandle) -> Result<(), String> { { use std::time::{Duration, Instant}; + // A package-manager install can't be replaced in place (see + // install_kind); refuse before downloading anything. + let kind = update_install_kind(); + if kind != "in-app" { + return Err(format!( + "install: package-managed ({kind}): update Lotus Chat with your package manager" + )); + } + let emit = |detail: serde_json::Value| { native::emit_to_web(&app, "lotus-update-progress", &detail.to_string()); }; @@ -940,6 +992,7 @@ pub fn run() { send_notification, check_for_update, install_update, + update_install_kind, native::power::set_call_active, native::jumplist::set_jump_list, native::thumbbar::set_thumbbar, @@ -958,7 +1011,17 @@ pub fn run() { secure_session::secure_session_get, secure_session::secure_session_clear, ]) - .plugin(tauri_plugin_localhost::Builder::new(port).build()) + // Bound to 127.0.0.1 explicitly (cinny #43). The app is still loaded as + // http://localhost:{port} (its storage lives under that origin, and the + // engines try 127.0.0.1 for `localhost`); the bundled call page is + // loaded as http://127.0.0.1:{port}, a separate origin on the same + // server. Binding the name `localhost` could pick ::1 only (Windows + // lists it first), and then the call page wouldn't load. + .plugin( + tauri_plugin_localhost::Builder::new(port) + .host("127.0.0.1") + .build(), + ) .plugin( // DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome) // owns the decorated flag. Letting window-state restore a saved @@ -1226,41 +1289,76 @@ pub fn run() { let _ = window_vibrancy::apply_mica(&window, Some(true)); } - // Auto-grant camera, microphone, and notification permissions in WebView2. - #[cfg(target_os = "windows")] - window.with_webview(|webview| { - use webview2_com::{ - Microsoft::Web::WebView2::Win32::{ - COREWEBVIEW2_PERMISSION_KIND, - COREWEBVIEW2_PERMISSION_KIND_CAMERA, - COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, - COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS, - COREWEBVIEW2_PERMISSION_STATE_ALLOW, - }, - PermissionRequestedEventHandler, - }; + // cinny-desktop #22: the app's own page gets the microphone, camera + // and notifications without a prompt; other origins (room widgets, + // link-preview embeds) are refused them. See webview_permissions. + #[cfg(any(target_os = "linux", target_os = "windows"))] + let app_origins = webview_permissions::AppOrigins::new( + port, + app.config().build.dev_url.as_ref(), + ); - let controller = webview.controller(); - if let Ok(core) = unsafe { controller.CoreWebView2() } { - let handler = PermissionRequestedEventHandler::create(Box::new( - |_sender, args| { - if let Some(args) = args { - let mut kind = COREWEBVIEW2_PERMISSION_KIND(0); - unsafe { args.PermissionKind(&mut kind) }?; - if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE - || kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA - || kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS - { - unsafe { - args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW) - }?; - } - } - Ok(()) + #[cfg(target_os = "windows")] + window.with_webview({ + let app_origins = app_origins.clone(); + move |webview| { + use webview2_com::{ + Microsoft::Web::WebView2::Win32::{ + COREWEBVIEW2_PERMISSION_KIND, + COREWEBVIEW2_PERMISSION_KIND_CAMERA, + COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION, + COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, + COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS, + COREWEBVIEW2_PERMISSION_STATE_ALLOW, + COREWEBVIEW2_PERMISSION_STATE_DENY, }, - )); - let mut token = Default::default(); - let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) }; + PermissionRequestedEventHandler, + }; + use webview_permissions::{decide, Decision, Kind, WINDOWS_GRANTS}; + + let controller = webview.controller(); + if let Ok(core) = unsafe { controller.CoreWebView2() } { + let handler = PermissionRequestedEventHandler::create(Box::new( + move |_sender, args| { + if let Some(args) = args { + let mut raw = COREWEBVIEW2_PERMISSION_KIND(0); + unsafe { args.PermissionKind(&mut raw) }?; + let kind = if raw == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE + || raw == COREWEBVIEW2_PERMISSION_KIND_CAMERA + { + Kind::Media + } else if raw == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS { + Kind::Notifications + } else if raw == COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION { + Kind::Geolocation + } else { + Kind::Other + }; + // The origin of the frame that asked. + let mut uri = windows::core::PWSTR::null(); + unsafe { args.Uri(&mut uri) }?; + let uri = webview2_com::take_pwstr(uri); + match decide(kind, &uri, &app_origins, WINDOWS_GRANTS) { + Decision::Allow => unsafe { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW) + }?, + Decision::Deny => { + eprintln!( + "webview: denied {kind:?} permission to {uri}" + ); + unsafe { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY) + }? + } + Decision::Default => {} + } + } + Ok(()) + }, + )); + let mut token = Default::default(); + let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) }; + } } })?; @@ -1268,19 +1366,48 @@ pub fn run() { // default (unlike WebView2/WKWebView), which leaves // `navigator.mediaDevices` undefined and makes Element Call // report "browser does not support WebRTC". Turn them on and - // auto-grant the resulting camera/mic permission prompt, mirroring - // the WebView2 handling above. + // answer the permission requests, mirroring the WebView2 handling + // above. WebKitGTK doesn't say which frame asked, so the origin + // checked is the page in the window (see webview_permissions). #[cfg(target_os = "linux")] - window.with_webview(|webview| { - use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt}; + window.with_webview(move |webview| { + use webkit2gtk::glib::prelude::ObjectExt; + use webkit2gtk::{ + DeviceInfoPermissionRequest, GeolocationPermissionRequest, + NotificationPermissionRequest, PermissionRequestExt, SettingsExt, + UserMediaPermissionRequest, WebViewExt, + }; + use webview_permissions::{decide, Decision, Kind, LINUX_GRANTS}; let wv = webview.inner(); if let Some(settings) = WebViewExt::settings(&wv) { settings.set_enable_media_stream(true); settings.set_enable_webrtc(true); } - wv.connect_permission_request(|_webview, request| { - request.allow(); + wv.connect_permission_request(move |wv, request| { + let kind = if request.is::() { + Kind::Media + } else if request.is::() { + Kind::DeviceInfo + } else if request.is::() { + Kind::Notifications + } else if request.is::() { + Kind::Geolocation + } else { + Kind::Other + }; + let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default(); + match decide(kind, &uri, &app_origins, LINUX_GRANTS) { + Decision::Allow => request.allow(), + // No prompt of our own: anything not granted is denied. + Decision::Deny | Decision::Default => { + eprintln!( + "webview: denied {} to {uri}", + request.type_().name() + ); + request.deny(); + } + } true }); })?; @@ -1338,6 +1465,36 @@ mod webview2_args_tests { } } +#[cfg(test)] +mod install_kind_tests { + use super::install_kind; + + #[test] + fn package_installs_update_through_their_package_manager() { + let cachy = "NAME=\"CachyOS Linux\"\nID=cachyos\nID_LIKE=arch\n"; + let arch = "NAME=\"Arch Linux\"\nID=arch\n"; + let ubuntu = "NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"; + let debian = "ID=debian\n"; + let mint = "ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"; + let fedora = "ID=fedora\n"; + assert_eq!(install_kind(true, false, cachy), "pacman"); + assert_eq!(install_kind(true, false, arch), "pacman"); + assert_eq!(install_kind(true, false, ubuntu), "deb"); + assert_eq!(install_kind(true, false, debian), "deb"); + assert_eq!(install_kind(true, false, mint), "deb"); + assert_eq!(install_kind(true, false, fedora), "manual"); + assert_eq!(install_kind(true, false, ""), "manual"); + // ID_LIKE mentioning arch only as part of a longer word doesn't count. + assert_eq!(install_kind(true, false, "ID=x\nID_LIKE=archlike\n"), "manual"); + } + + #[test] + fn appimage_and_windows_update_in_app() { + assert_eq!(install_kind(true, true, "ID=cachyos\nID_LIKE=arch\n"), "in-app"); + assert_eq!(install_kind(false, false, ""), "in-app"); + } +} + #[cfg(test)] mod tray_tests { use super::*; diff --git a/src-tauri/src/webview_permissions.rs b/src-tauri/src/webview_permissions.rs new file mode 100644 index 0000000..328e420 --- /dev/null +++ b/src-tauri/src/webview_permissions.rs @@ -0,0 +1,273 @@ +//! Which WebView permission requests the app grants (cinny-desktop #22). +//! +//! The web client asks for the microphone/camera/screen (calls, voice +//! messages), the device list (audio-output picker), notifications and the +//! location (location sharing). Those are granted without a prompt, but only +//! to the app's own origin. Everything else is left alone (Windows: WebView2's +//! own prompt) or denied (Linux: WebKitGTK has no prompt of its own). +//! +//! What "the requesting origin" means differs per engine: +//! - WebView2 reports the origin of the frame that asked (`args.Uri()`), so a +//! room widget or link-preview embed is refused here. +//! - WebKitGTK doesn't say which frame asked; the check is on the page loaded +//! in the window. Frames are gated before the request gets this far by the +//! Permissions Policy: cinny only puts `microphone; camera` in the `allow=` +//! of the call frame, and cross-origin frames get neither location nor +//! notifications. +//! +//! The call frame (cinny #43): the bundled Element Call page is loaded from +//! `http://127.0.0.1:{port}`, the same local server on a second origin, so it +//! can't reach the app's storage. WebView2 reports that origin for the call's +//! microphone/camera requests; it gets media and nothing else. + +use tauri::Url; + +/// A permission request, reduced to what the policy cares about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + /// Microphone, camera or screen capture (getUserMedia/getDisplayMedia). + Media, + /// Device labels/ids from enumerateDevices (WebKitGTK only). + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + DeviceInfo, + Notifications, + Geolocation, + /// Anything else: clipboard read, storage access, pointer lock, DRM, … + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Decision { + Allow, + Deny, + /// Let the engine decide (WebView2 prompts; WebKitGTK denies). + Default, +} + +/// What the Linux (WebKitGTK) handler grants to the app. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) const LINUX_GRANTS: &[Kind] = &[ + Kind::Media, + Kind::DeviceInfo, + Kind::Notifications, + Kind::Geolocation, +]; +/// What the Windows (WebView2) handler grants to the app. Location keeps +/// WebView2's own prompt, as before. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) const WINDOWS_GRANTS: &[Kind] = &[Kind::Media, Kind::Notifications]; + +/// The decision for a request of `kind` from `uri`. +pub(crate) fn decide(kind: Kind, uri: &str, app: &AppOrigins, grants: &[Kind]) -> Decision { + if kind == Kind::Other { + return Decision::Default; + } + if app.is_call_frame(uri) { + // The call page: microphone/camera/screen only. + return if kind == Kind::Media && grants.contains(&kind) { + Decision::Allow + } else { + Decision::Deny + }; + } + if !app.contains(uri) { + return Decision::Deny; + } + if grants.contains(&kind) { + Decision::Allow + } else { + Decision::Default + } +} + +/// scheme, host, port (explicit or the scheme's default). +type Origin = (String, String, Option); + +fn origin_of(uri: &str) -> Option { + let url = Url::parse(uri).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + Some((url.scheme().to_owned(), host, url.port_or_known_default())) +} + +/// The origins the app's own page is served from, and the call page's. +#[derive(Clone, Debug)] +pub(crate) struct AppOrigins { + app: Vec, + call: Option, +} + +impl AppOrigins { + /// Release builds load `http://localhost:{port}` (tauri-plugin-localhost). + /// Debug builds load the bundled page (`tauri://localhost`, or + /// `http://tauri.localhost` on Windows) or, under `tauri dev`, `dev_url`. + pub(crate) fn new(port: u16, dev_url: Option<&Url>) -> Self { + let mut uris = vec![format!("http://localhost:{port}/")]; + if cfg!(debug_assertions) { + uris.push("tauri://localhost/".into()); + uris.push("http://tauri.localhost/".into()); + if let Some(dev) = dev_url { + uris.push(dev.to_string()); + } + } + Self { + app: uris.iter().filter_map(|u| origin_of(u)).collect(), + call: origin_of(&format!("http://127.0.0.1:{port}/")), + } + } + + /// The app's own page. + pub(crate) fn contains(&self, uri: &str) -> bool { + origin_of(uri).is_some_and(|o| self.app.contains(&o)) + } + + /// The call page on its own origin (`http://127.0.0.1:{port}`). + pub(crate) fn is_call_frame(&self, uri: &str) -> bool { + origin_of(uri).is_some_and(|o| self.call.as_ref() == Some(&o)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> AppOrigins { + AppOrigins::new(44548, None) + } + + #[test] + fn app_origin_matches_only_the_app() { + let app = app(); + assert!(app.contains("http://localhost:44548/")); + assert!(app.contains("http://localhost:44548/#/home/!room:server")); + assert!(app.contains("http://LOCALHOST:44548/public/element-call/index.html")); + for other in [ + "http://localhost:44549/", + "https://localhost:44548/", + "http://127.0.0.1:44548/", + "http://localhost/", + "http://localhost.evil.example:44548/", + "http://evil.example/?http://localhost:44548/", + "https://www.youtube-nocookie.com/embed/x", + "https://chat.lotusguild.org/", + "about:blank", + "data:text/html,hi", + "null", + "", + ] { + assert!(!app.contains(other), "{other}"); + } + } + + #[test] + fn debug_builds_also_accept_the_bundled_and_dev_pages() { + let dev = Url::parse("http://localhost:8080").unwrap(); + let app = AppOrigins::new(44548, Some(&dev)); + assert_eq!( + app.contains("tauri://localhost/index.html"), + cfg!(debug_assertions) + ); + assert_eq!( + app.contains("http://tauri.localhost/"), + cfg!(debug_assertions) + ); + assert_eq!( + app.contains("http://localhost:8080/"), + cfg!(debug_assertions) + ); + assert!(app.contains("http://localhost:44548/")); + assert!(!app.contains("tauri://evil/")); + } + + #[test] + fn app_gets_its_grants_without_a_prompt() { + let app = app(); + let uri = "http://localhost:44548/"; + for kind in [ + Kind::Media, + Kind::DeviceInfo, + Kind::Notifications, + Kind::Geolocation, + ] { + assert_eq!( + decide(kind, uri, &app, LINUX_GRANTS), + Decision::Allow, + "{kind:?}" + ); + } + assert_eq!( + decide(Kind::Media, uri, &app, WINDOWS_GRANTS), + Decision::Allow + ); + assert_eq!( + decide(Kind::Notifications, uri, &app, WINDOWS_GRANTS), + Decision::Allow + ); + // Location on Windows keeps WebView2's prompt. + assert_eq!( + decide(Kind::Geolocation, uri, &app, WINDOWS_GRANTS), + Decision::Default + ); + } + + #[test] + fn other_origins_are_refused() { + let app = app(); + for uri in [ + "https://widget.example/", + "https://www.youtube-nocookie.com/embed/x", + "", + ] { + for kind in [ + Kind::Media, + Kind::DeviceInfo, + Kind::Notifications, + Kind::Geolocation, + ] { + assert_eq!(decide(kind, uri, &app, LINUX_GRANTS), Decision::Deny); + assert_eq!(decide(kind, uri, &app, WINDOWS_GRANTS), Decision::Deny); + } + } + } + + #[test] + fn call_frame_gets_media_only() { + let app = app(); + let call = "http://127.0.0.1:44548/public/element-call/index.html?widgetId=x"; + assert!(app.is_call_frame(call)); + assert!(!app.contains(call)); + for grants in [LINUX_GRANTS, WINDOWS_GRANTS] { + assert_eq!(decide(Kind::Media, call, &app, grants), Decision::Allow); + for kind in [Kind::DeviceInfo, Kind::Notifications, Kind::Geolocation] { + assert_eq!(decide(kind, call, &app, grants), Decision::Deny, "{kind:?}"); + } + assert_eq!(decide(Kind::Other, call, &app, grants), Decision::Default); + } + for not_call in [ + "http://127.0.0.1:44549/", + "https://127.0.0.1:44548/", + "http://127.0.0.2:44548/", + "http://[::1]:44548/", + ] { + assert!(!app.is_call_frame(not_call), "{not_call}"); + assert_eq!( + decide(Kind::Media, not_call, &app, WINDOWS_GRANTS), + Decision::Deny + ); + } + } + + #[test] + fn other_kinds_are_left_to_the_engine() { + let app = app(); + for uri in ["http://localhost:44548/", "https://widget.example/"] { + assert_eq!( + decide(Kind::Other, uri, &app, LINUX_GRANTS), + Decision::Default + ); + assert_eq!( + decide(Kind::Other, uri, &app, WINDOWS_GRANTS), + Decision::Default + ); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0509432..7f094cb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -71,7 +71,7 @@ }, "app": { "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'" + "csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: http://127.0.0.1:44548 https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'" } } } \ No newline at end of file