Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
139 changes: 139 additions & 0 deletions crates/volt-core/src/security/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Content Security Policy generation for WebView responses.

mod validation;

pub use self::validation::{validate_path, validate_url_scheme};

const VOLT_EMBEDDED_ORIGINS: &str = "volt://localhost http://volt.localhost https://volt.localhost";

/// Default CSP for production builds - strict, no unsafe-eval.
pub fn production_csp() -> String {
[
"default-src 'none'",
&format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"),
&format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"),
&format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
]
.join("; ")
}

/// CSP for development builds - allows connections to localhost dev servers.
pub fn development_csp(dev_server_origin: &str) -> String {
let Some((safe_http_origin, safe_ws_origin)) = sanitize_dev_server_origin(dev_server_origin)
else {
return [
"default-src 'none'",
&format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"),
&format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"),
&format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
]
.join("; ");
};

[
"default-src 'none'",
&format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin} {safe_ws_origin}"),
]
.join("; ")
}

fn sanitize_dev_server_origin(dev_server_origin: &str) -> Option<(String, String)> {
let parsed = url::Url::parse(dev_server_origin).ok()?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return None;
}
let host = parsed.host_str()?;
if host.contains(';')
|| host.contains('\n')
|| host.contains('\r')
|| host.chars().any(|ch| ch.is_ascii_whitespace())
{
return None;
}
let mut http_origin = format!("{scheme}://{host}");
if let Some(port) = parsed.port() {
http_origin.push(':');
http_origin.push_str(&port.to_string());
}
let ws_scheme = if scheme == "https" { "wss" } else { "ws" };
let ws_origin = http_origin.replacen(scheme, ws_scheme, 1);
Some((http_origin, ws_origin))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_production_csp() {
let csp = production_csp();
assert!(csp.contains("default-src 'none'"));
assert!(csp.contains("script-src 'self'"));
assert!(csp.contains("volt://localhost"));
assert!(csp.contains("https://volt.localhost"));
assert!(!csp.contains("unsafe-eval"));
assert!(!csp.contains("*"));
}

#[test]
fn test_development_csp_includes_origin() {
let csp = development_csp("http://localhost:5173");
assert!(csp.contains("http://localhost:5173"));
assert!(csp.contains("volt://localhost"));
assert!(csp.contains("https://volt.localhost"));
assert!(csp.contains("connect-src"));
assert!(csp.contains("script-src"));
}

#[test]
fn test_development_csp_includes_websocket() {
let csp = development_csp("http://localhost:5173");
assert!(csp.contains("ws://localhost:5173"));
assert!(
!csp.contains("ws://http://"),
"should not have double protocol"
);
}

#[test]
fn test_development_csp_https_uses_wss() {
let csp = development_csp("https://localhost:5173");
assert!(csp.contains("wss://localhost:5173"));
assert!(
!csp.contains("ws://https://"),
"should not have double protocol"
);
}

#[test]
fn test_development_csp_rejects_invalid_origin_injection() {
let csp = development_csp("http://localhost:5173;script-src *");
assert!(!csp.contains("localhost:5173;script-src"));
assert!(csp.contains("script-src 'self'"));
assert!(!csp.contains("script-src *"));
}

#[test]
fn test_development_csp_rejects_whitespace_in_origin() {
let csp = development_csp("http://localhost :5173");
assert!(!csp.contains("localhost :5173"));
}

#[test]
fn test_production_csp_has_only_explicit_localhost_allowances() {
let csp = production_csp();
assert!(csp.contains("https://volt.localhost"));
assert!(!csp.contains("http://localhost"));
assert!(!csp.contains("https://localhost"));
assert!(!csp.contains("ws://"));
}
}
Original file line number Diff line number Diff line change
@@ -1,72 +1,5 @@
//! Content Security Policy generation for WebView responses.

use unicode_normalization::UnicodeNormalization;

const VOLT_EMBEDDED_ORIGINS: &str = "volt://localhost http://volt.localhost https://volt.localhost";

/// Default CSP for production builds - strict, no unsafe-eval.
pub fn production_csp() -> String {
[
"default-src 'none'",
&format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"),
&format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"),
&format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
]
.join("; ")
}

/// CSP for development builds - allows connections to localhost dev servers.
pub fn development_csp(dev_server_origin: &str) -> String {
let Some((safe_http_origin, safe_ws_origin)) = sanitize_dev_server_origin(dev_server_origin)
else {
return [
"default-src 'none'",
&format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"),
&format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"),
&format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
&format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"),
]
.join("; ");
};

[
"default-src 'none'",
&format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"),
&format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin} {safe_ws_origin}"),
]
.join("; ")
}

fn sanitize_dev_server_origin(dev_server_origin: &str) -> Option<(String, String)> {
let parsed = url::Url::parse(dev_server_origin).ok()?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return None;
}
let host = parsed.host_str()?;
if host.contains(';')
|| host.contains('\n')
|| host.contains('\r')
|| host.chars().any(|ch| ch.is_ascii_whitespace())
{
return None;
}
let mut http_origin = format!("{scheme}://{host}");
if let Some(port) = parsed.port() {
http_origin.push(':');
http_origin.push_str(&port.to_string());
}
let ws_scheme = if scheme == "https" { "wss" } else { "ws" };
let ws_origin = http_origin.replacen(scheme, ws_scheme, 1);
Some((http_origin, ws_origin))
}

/// Validate that a path string does not attempt directory traversal.
pub fn validate_path(path: &str) -> Result<(), String> {
let normalized = path.nfc().collect::<String>();
Expand Down Expand Up @@ -129,17 +62,6 @@ pub fn validate_url_scheme(url: &str) -> Result<(), String> {
mod tests {
use super::*;

#[test]
fn test_production_csp() {
let csp = production_csp();
assert!(csp.contains("default-src 'none'"));
assert!(csp.contains("script-src 'self'"));
assert!(csp.contains("volt://localhost"));
assert!(csp.contains("https://volt.localhost"));
assert!(!csp.contains("unsafe-eval"));
assert!(!csp.contains("*"));
}

#[test]
fn test_path_traversal_blocked() {
assert!(validate_path("../../etc/passwd").is_err());
Expand Down Expand Up @@ -177,61 +99,6 @@ mod tests {
assert!(validate_url_scheme("vbscript:msgbox").is_err());
}

// ── Expanded tests ─────────────────────────────────────────────

#[test]
fn test_development_csp_includes_origin() {
let csp = development_csp("http://localhost:5173");
assert!(csp.contains("http://localhost:5173"));
assert!(csp.contains("volt://localhost"));
assert!(csp.contains("https://volt.localhost"));
assert!(csp.contains("connect-src"));
assert!(csp.contains("script-src"));
}

#[test]
fn test_development_csp_includes_websocket() {
let csp = development_csp("http://localhost:5173");
assert!(csp.contains("ws://localhost:5173"));
assert!(
!csp.contains("ws://http://"),
"should not have double protocol"
);
}

#[test]
fn test_development_csp_https_uses_wss() {
let csp = development_csp("https://localhost:5173");
assert!(csp.contains("wss://localhost:5173"));
assert!(
!csp.contains("ws://https://"),
"should not have double protocol"
);
}

#[test]
fn test_development_csp_rejects_invalid_origin_injection() {
let csp = development_csp("http://localhost:5173;script-src *");
assert!(!csp.contains("localhost:5173;script-src"));
assert!(csp.contains("script-src 'self'"));
assert!(!csp.contains("script-src *"));
}

#[test]
fn test_development_csp_rejects_whitespace_in_origin() {
let csp = development_csp("http://localhost :5173");
assert!(!csp.contains("localhost :5173"));
}

#[test]
fn test_production_csp_has_only_explicit_localhost_allowances() {
let csp = production_csp();
assert!(csp.contains("https://volt.localhost"));
assert!(!csp.contains("http://localhost"));
assert!(!csp.contains("https://localhost"));
assert!(!csp.contains("ws://"));
}

#[test]
fn test_path_empty_string() {
// Empty string should be valid (it's a relative path with no components)
Expand Down
18 changes: 18 additions & 0 deletions crates/volt-runner/src/js_runtime/tests/native_ipc/roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ fn ipc_main_rejects_reserved_volt_channels() {
assert!(error.contains("reserved by Volt"));
}

#[test]
fn ipc_main_rejects_reserved_plugin_channels() {
let runtime = JsRuntimeManager::start().expect("js runtime start");
let client = runtime.client();

let error = client
.eval_promise_string(
"(async () => {
const { ipcMain } = await import('volt:ipc');
ipcMain.handle('plugin:acme.search:ping', () => ({ ok: true }));
return 'unreachable';
})()",
)
.expect_err("reserved plugin channel should be rejected");

assert!(error.contains("reserved by Volt"));
}

#[test]
fn ipc_roundtrip_handles_reserved_native_fast_path_without_js_handler() {
let runtime = JsRuntimeManager::start().expect("js runtime start");
Expand Down
6 changes: 5 additions & 1 deletion crates/volt-runner/src/modules/volt_ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ const IPC_MODULE_BOOTSTRAP: &str = r#"
};

const ensureUserChannel = (method) => {
if (method.startsWith('volt:')) {
if (
method.startsWith('volt:')
|| method.startsWith('__volt_internal:')
|| method.startsWith('plugin:')
) {
throw new Error(`IPC channel is reserved by Volt: ${method}`);
}
return method;
Expand Down
Loading