diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-03-10 18:25:05 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-10 18:25:05 +0100 |
| commit | 458f90cbbdd9efe304a788fe100724b0644b5afe (patch) | |
| tree | 24513cee1761fd465b5da7615469e97eea2bd275 | |
| parent | Highlight priority levels (#4827) (diff) | |
| download | sl0ppij-458f90cbbdd9efe304a788fe100724b0644b5afe.tar.gz sl0ppij-458f90cbbdd9efe304a788fe100724b0644b5afe.tar.xz sl0ppij-458f90cbbdd9efe304a788fe100724b0644b5afe.zip | |
Web security hardening (#4830)
* security hardening
* fix some issues
* rustfmt
* add pr
24 files changed, 962 insertions, 131 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 0849e115..31671404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * fix: make sessions compatible across versions (https://github.com/zellij-org/zellij/pull/4439) * fix: occasional status-bar pop-out after resurrecting a session (https://github.com/zellij-org/zellij/pull/4440) * fix: properly serialize/resurrect layouts with one-line split panes (https://github.com/zellij-org/zellij/pull/4442) -* feat: allow attaching to remote Zellij sessions over https (eg. `zellij attach https://example.com/my-cool-session`) (https://github.com/zellij-org/zellij/pull/4460) +* feat: allow attaching to remote Zellij sessions over https (eg. `zellij attach https://example.com/my-cool-session`) (https://github.com/zellij-org/zellij/pull/4460 and https://github.com/zellij-org/zellij/pull/4830) * build: Update Rust toolchain to 1.90.0 (https://github.com/zellij-org/zellij/pull/4457) * feat: allow plugins to read pane scrollback (https://github.com/zellij-org/zellij/pull/4465) * infra: migrate wasm runtime from wasmtime to wasmi (https://github.com/zellij-org/zellij/pull/4449) @@ -4813,6 +4813,7 @@ dependencies = [ "isahc", "lazy_static", "log", + "native-tls", "nix", "notify", "num_cpus", diff --git a/src/commands.rs b/src/commands.rs index 77bdecec..a86d1949 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -247,7 +247,7 @@ pub(crate) fn create_auth_token(name: Option<String>, read_only: bool) -> Result create_token(name, read_only) .map(|(token, token_name)| { let access_type = if read_only { " (read-only)" } else { "" }; - format!("{}: {}{}", token, token_name, access_type) + format!("{}: {}{}", token_name, token, access_type) }) .map_err(|e| e.to_string()) } @@ -716,6 +716,8 @@ pub(crate) fn start_client(opts: CliArgs) { token: None, remember: false, forget: false, + ca_cert: None, + insecure: false, })); } else { opts.command = None; @@ -748,6 +750,8 @@ pub(crate) fn start_client(opts: CliArgs) { token, remember, forget, + ca_cert, + insecure, })) = opts.command.clone() { if let Some(remote_session_url) = session_name.as_ref().and_then(|s| { @@ -774,6 +778,8 @@ pub(crate) fn start_client(opts: CliArgs) { token, remember, forget, + ca_cert, + insecure, config_options.client_async_worker_tasks, ) { eprintln!("{}", e); diff --git a/zellij-client/Cargo.toml b/zellij-client/Cargo.toml index 2c665454..2cb1492a 100644 --- a/zellij-client/Cargo.toml +++ b/zellij-client/Cargo.toml @@ -40,6 +40,7 @@ tokio = { workspace = true } tokio-util = { workspace = true } isahc = { workspace = true } tokio-tungstenite = { version = "0.20", features = ["native-tls"] } #TODO: see about this native-tls... +native-tls = "0.2" futures-util = "0.3" urlencoding = "2.1" diff --git a/zellij-client/assets/auth.js b/zellij-client/assets/auth.js index 14c971b8..44b0c50a 100644 --- a/zellij-client/assets/auth.js +++ b/zellij-client/assets/auth.js @@ -96,7 +96,7 @@ export async function getClientId(token, rememberMe, hasAuthenticationCookie) { export async function initAuthentication() { let token = null; let remember = null; - let hasAuthenticationCookie = window.is_authenticated; + let hasAuthenticationCookie = document.body.dataset.authenticated === "true"; if (!hasAuthenticationCookie) { const tokenResult = await waitForSecurityToken(); diff --git a/zellij-client/assets/index.html b/zellij-client/assets/index.html index aef5ef86..92f59a0a 100644 --- a/zellij-client/assets/index.html +++ b/zellij-client/assets/index.html @@ -20,11 +20,8 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Zellij Web Client</title> </head> - <body> + <body data-authenticated="IS_AUTHENTICATED"> <div id="terminal" tabindex="0"></div> - <script> - window.is_authenticated = IS_AUTHENTICATED; - </script> <script src="assets/modals.js"></script> <!-- Module files - order matters --> <script type="module" src="assets/utils.js"></script> diff --git a/zellij-client/assets/modals.js b/zellij-client/assets/modals.js index 34b968b9..348859bf 100644 --- a/zellij-client/assets/modals.js +++ b/zellij-client/assets/modals.js @@ -411,19 +411,36 @@ function showErrorModal(title, description) { const modal = document.createElement('div'); modal.className = 'security-modal error'; - modal.innerHTML = ` - <div class="security-modal-content"> - <h3>${title}</h3> - <div class="error-description">${description}</div> - <div class="button-row"> - <button id="dismiss" class="dismiss-btn">Acknowledge</button> - </div> - <div class="status-bar"></div> - </div> - `; + const content = document.createElement('div'); + content.className = 'security-modal-content'; + + const h3 = document.createElement('h3'); + h3.textContent = title; + content.appendChild(h3); + + const desc = document.createElement('div'); + desc.className = 'error-description'; + desc.textContent = description; + content.appendChild(desc); + + const buttonRow = document.createElement('div'); + buttonRow.className = 'button-row'; + + const dismissBtn = document.createElement('button'); + dismissBtn.id = 'dismiss'; + dismissBtn.className = 'dismiss-btn'; + dismissBtn.textContent = 'Acknowledge'; + buttonRow.appendChild(dismissBtn); + content.appendChild(buttonRow); + + const statusBar = document.createElement('div'); + statusBar.className = 'status-bar'; + content.appendChild(statusBar); + + modal.appendChild(content); document.body.appendChild(modal); - modal.querySelector('#dismiss').focus(); + dismissBtn.focus(); const handleKeydown = (e) => { if (e.key === 'Enter' || e.key === 'Escape') { @@ -440,7 +457,7 @@ function showErrorModal(title, description) { resolve(); }; - modal.querySelector('#dismiss').onclick = cleanup; + dismissBtn.onclick = cleanup; modal.onclick = (e) => { if (e.target === modal) { @@ -459,25 +476,65 @@ function showReconnectionModal(attemptNumber, delaySeconds) { modal.style.background = 'rgba(28, 28, 28, 0.85)'; // More transparent to show terminal const isFirstAttempt = attemptNumber === 1; - const title = isFirstAttempt ? 'Connection Lost' : 'Reconnection Failed'; - const message = isFirstAttempt - ? `Reconnecting in <span id="countdown">${delaySeconds}</span> second${delaySeconds > 1 ? 's' : ''}...` - : `Retrying in <span id="countdown">${delaySeconds}</span> second${delaySeconds > 1 ? 's' : ''}... (Attempt ${attemptNumber})`; - - modal.innerHTML = ` - <div class="security-modal-content"> - <h3 id="modal-title">${title}</h3> - <div class="error-description" id="modal-message">${message}</div> - <div class="button-row" id="button-row"> - <button id="cancel" class="cancel-btn">Cancel</button> - <button id="reconnect" class="submit-btn">Reconnect Now</button> - </div> - <div class="status-bar"></div> - </div> - `; + const titleText = isFirstAttempt ? 'Connection Lost' : 'Reconnection Failed'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'security-modal-content'; + + const titleEl = document.createElement('h3'); + titleEl.id = 'modal-title'; + titleEl.textContent = titleText; + contentDiv.appendChild(titleEl); + + const messageEl = document.createElement('div'); + messageEl.className = 'error-description'; + messageEl.id = 'modal-message'; + + if (isFirstAttempt) { + messageEl.appendChild(document.createTextNode('Reconnecting in ')); + const countdown = document.createElement('span'); + countdown.id = 'countdown'; + countdown.textContent = delaySeconds; + messageEl.appendChild(countdown); + messageEl.appendChild(document.createTextNode(delaySeconds > 1 ? ' seconds...' : ' second...')); + } else { + messageEl.appendChild(document.createTextNode('Retrying in ')); + const countdown = document.createElement('span'); + countdown.id = 'countdown'; + countdown.textContent = delaySeconds; + messageEl.appendChild(countdown); + messageEl.appendChild(document.createTextNode( + (delaySeconds > 1 ? ' seconds' : ' second') + '... (Attempt ' + attemptNumber + ')' + )); + } + contentDiv.appendChild(messageEl); + + const buttonRowEl = document.createElement('div'); + buttonRowEl.className = 'button-row'; + buttonRowEl.id = 'button-row'; + + const cancelBtn = document.createElement('button'); + cancelBtn.id = 'cancel'; + cancelBtn.className = 'cancel-btn'; + cancelBtn.textContent = 'Cancel'; + buttonRowEl.appendChild(cancelBtn); + + const reconnectBtn = document.createElement('button'); + reconnectBtn.id = 'reconnect'; + reconnectBtn.className = 'submit-btn'; + reconnectBtn.textContent = 'Reconnect Now'; + buttonRowEl.appendChild(reconnectBtn); + + contentDiv.appendChild(buttonRowEl); + + const statusBarEl = document.createElement('div'); + statusBarEl.className = 'status-bar'; + contentDiv.appendChild(statusBarEl); + + modal.appendChild(contentDiv); document.body.appendChild(modal); - modal.querySelector('#reconnect').focus(); + reconnectBtn.focus(); let countdownInterval; let remainingSeconds = delaySeconds; @@ -503,7 +560,7 @@ function showReconnectionModal(attemptNumber, delaySeconds) { } const messageElement = modal.querySelector('#modal-message'); - messageElement.innerHTML = 'Connecting...'; + messageElement.textContent = 'Connecting...'; }; countdownInterval = setInterval(updateCountdown, 1000); diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs index 002fa048..c762b28b 100644 --- a/zellij-client/src/lib.rs +++ b/zellij-client/src/lib.rs @@ -535,6 +535,8 @@ pub fn start_remote_client( token: Option<String>, remember: bool, forget: bool, + ca_cert: Option<std::path::PathBuf>, + insecure: bool, async_worker_tasks: Option<usize>, ) -> Result<Option<ConnectToSession>, RemoteClientError> { info!("Starting Zellij client!"); @@ -548,6 +550,8 @@ pub fn start_remote_client( token, remember, forget, + ca_cert.as_deref(), + insecure, )?; let reconnect_to_session = None; diff --git a/zellij-client/src/remote_attach/auth.rs b/zellij-client/src/remote_attach/auth.rs index cc29d1bc..b9955800 100644 --- a/zellij-client/src/remote_attach/auth.rs +++ b/zellij-client/src/remote_attach/auth.rs @@ -19,9 +19,11 @@ pub async fn authenticate( server_base_url: &str, auth_token: &str, remember_me: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, ) -> Result<(String, HttpClientWithCookies, Option<String>), RemoteClientError> { - let http_client = - HttpClientWithCookies::new().map_err(|e| RemoteClientError::Other(Box::new(e)))?; + let http_client = HttpClientWithCookies::new(ca_cert, insecure) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?; // Step 1: Login with auth token let login_url = format!("{}{}", server_base_url, LOGIN_ENDPOINT); @@ -105,9 +107,11 @@ pub async fn authenticate( pub async fn validate_session_token( server_base_url: &str, session_token: &str, + ca_cert: Option<&std::path::Path>, + insecure: bool, ) -> Result<(String, HttpClientWithCookies), RemoteClientError> { - let http_client = - HttpClientWithCookies::new().map_err(|e| RemoteClientError::Other(Box::new(e)))?; + let http_client = HttpClientWithCookies::new(ca_cert, insecure) + .map_err(|e| RemoteClientError::Other(Box::new(e)))?; // Pre-populate the session_token cookie http_client.set_cookie("session_token".to_string(), session_token.to_string()); diff --git a/zellij-client/src/remote_attach/http_client.rs b/zellij-client/src/remote_attach/http_client.rs index b10c124e..5fa66b69 100644 --- a/zellij-client/src/remote_attach/http_client.rs +++ b/zellij-client/src/remote_attach/http_client.rs @@ -2,14 +2,30 @@ use super::config::connection_timeout; use isahc::prelude::*; use isahc::{config::RedirectPolicy, AsyncBody, HttpClient, Request, Response}; use std::collections::HashMap; +use std::path::Path; use std::sync::{Arc, Mutex}; -pub fn create_http_client() -> Result<HttpClient, isahc::Error> { - HttpClient::builder() +pub fn create_http_client( + ca_cert: Option<&Path>, + insecure: bool, +) -> Result<HttpClient, isahc::Error> { + let mut builder = HttpClient::builder() .redirect_policy(RedirectPolicy::Follow) - .ssl_options(isahc::config::SslOption::DANGER_ACCEPT_INVALID_CERTS) - .timeout(connection_timeout()) - .build() + .timeout(connection_timeout()); + + if insecure { + eprintln!( + "WARNING: TLS certificate validation is disabled. This connection is NOT secure." + ); + builder = builder.ssl_options( + isahc::config::SslOption::DANGER_ACCEPT_INVALID_CERTS + | isahc::config::SslOption::DANGER_ACCEPT_INVALID_HOSTS, + ); + } else if let Some(ca_path) = ca_cert { + builder = builder.ssl_ca_certificate(isahc::config::CaCertificate::file(ca_path)); + } + + builder.build() } pub struct HttpClientWithCookies { @@ -18,9 +34,9 @@ pub struct HttpClientWithCookies { } impl HttpClientWithCookies { - pub fn new() -> Result<Self, isahc::Error> { + pub fn new(ca_cert: Option<&Path>, insecure: bool) -> Result<Self, isahc::Error> { Ok(Self { - client: create_http_client()?, + client: create_http_client(ca_cert, insecure)?, cookies: Arc::new(Mutex::new(HashMap::new())), }) } diff --git a/zellij-client/src/remote_attach/mod.rs b/zellij-client/src/remote_attach/mod.rs index d6d7cf32..45a8a032 100644 --- a/zellij-client/src/remote_attach/mod.rs +++ b/zellij-client/src/remote_attach/mod.rs @@ -38,6 +38,8 @@ pub fn attach_to_remote_session( token: Option<String>, remember: bool, forget: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, ) -> Result<WebSocketConnections, RemoteClientError> { // Extract server URL for token management let server_url = extract_server_url(remote_session_url)?; @@ -57,13 +59,22 @@ pub fn attach_to_remote_session( runtime.clone(), remote_session_url, &server_url, + ca_cert, + insecure, )? { return Ok(connections); } } // Normal auth flow with retry logic - authenticate_with_retry(runtime, remote_session_url, token, remember) + authenticate_with_retry( + runtime, + remote_session_url, + token, + remember, + ca_cert, + insecure, + ) } /// Try to connect using a saved session token @@ -72,11 +83,20 @@ fn try_to_connect_with_saved_session_token( runtime: Handle, remote_session_url: &str, server_url: &str, + ca_cert: Option<&std::path::Path>, + insecure: bool, ) -> Result<Option<WebSocketConnections>, RemoteClientError> { if let Ok(Some(saved_session_token)) = remote_session_tokens::get_session_token(server_url) { // we have a saved session token, let's try to authenticate with it + let ca_cert_owned = ca_cert.map(|p| p.to_path_buf()); match runtime.block_on(async move { - remote_attach_with_session_token(remote_session_url, &saved_session_token).await + remote_attach_with_session_token( + remote_session_url, + &saved_session_token, + ca_cert_owned.as_deref(), + insecure, + ) + .await }) { Ok(connections) => { return Ok(Some(connections)); @@ -100,6 +120,8 @@ fn authenticate_with_retry( remote_session_url: &str, initial_token: Option<String>, remember: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, ) -> Result<WebSocketConnections, RemoteClientError> { use dialoguer::{Confirm, Password}; @@ -117,9 +139,17 @@ fn authenticate_with_retry( .map_err(|e| RemoteClientError::IoError(e))?, }; - match runtime - .block_on(async move { remote_attach(remote_session_url, &auth_token, remember).await }) - { + let ca_cert_owned = ca_cert.map(|p| p.to_path_buf()); + match runtime.block_on(async move { + remote_attach( + remote_session_url, + &auth_token, + remember, + ca_cert_owned.as_deref(), + insecure, + ) + .await + }) { Ok((connections, session_token_opt)) => { // Save session token if we got one if let Some(session_token) = session_token_opt { @@ -167,16 +197,20 @@ async fn remote_attach( server_url: &str, auth_token: &str, remember_me: bool, + ca_cert: Option<&std::path::Path>, + insecure: bool, ) -> Result<(websockets::WebSocketConnections, Option<String>), RemoteClientError> { let server_base_url = extract_server_url(server_url)?; let session_name = extract_session_name(server_url)?; let (web_client_id, http_client, session_token) = - auth::authenticate(&server_base_url, auth_token, remember_me).await?; + auth::authenticate(&server_base_url, auth_token, remember_me, ca_cert, insecure).await?; let connections = websockets::establish_websocket_connections( &web_client_id, &http_client, &server_base_url, &session_name, + ca_cert, + insecure, ) .await .map_err(|e| RemoteClientError::ConnectionFailed(e.to_string()))?; @@ -186,16 +220,20 @@ async fn remote_attach( async fn remote_attach_with_session_token( server_url: &str, session_token: &str, + ca_cert: Option<&std::path::Path>, + insecure: bool, ) -> Result<websockets::WebSocketConnections, RemoteClientError> { let server_base_url = extract_server_url(server_url)?; let session_name = extract_session_name(server_url)?; let (web_client_id, http_client) = - auth::validate_session_token(&server_base_url, session_token).await?; + auth::validate_session_token(&server_base_url, session_token, ca_cert, insecure).await?; let connections = websockets::establish_websocket_connections( &web_client_id, &http_client, &server_base_url, &session_name, + ca_cert, + insecure, ) .await .map_err(|e| RemoteClientError::ConnectionFailed(e.to_string()))?; diff --git a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs index 92c13cda..68dd0907 100644 --- a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs +++ b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs @@ -314,6 +314,8 @@ mod tests { token, remember, forget, + None, + true, // insecure for tests ) }) .await diff --git a/zellij-client/src/remote_attach/websockets.rs b/zellij-client/src/remote_attach/websockets.rs index b8628645..07776466 100644 --- a/zellij-client/src/remote_attach/websockets.rs +++ b/zellij-client/src/remote_attach/websockets.rs @@ -1,7 +1,10 @@ use super::config::{WS_CONTROL_ENDPOINT, WS_TERMINAL_ENDPOINT}; use super::http_client::HttpClientWithCookies; +use std::path::Path; use tokio::net::TcpStream; -use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{ + connect_async_tls_with_config, Connector, MaybeTlsStream, WebSocketStream, +}; pub struct WebSocketConnections { pub terminal_ws: WebSocketStream<MaybeTlsStream<TcpStream>>, @@ -22,6 +25,8 @@ pub async fn establish_websocket_connections( http_client: &HttpClientWithCookies, server_base_url: &str, session_name: &str, + ca_cert: Option<&Path>, + insecure: bool, ) -> Result<WebSocketConnections, Box<dyn std::error::Error>> { let ws_protocol = if server_base_url.starts_with("https") { "wss" @@ -86,10 +91,29 @@ pub async fn establish_websocket_connections( let terminal_request = terminal_request.body(())?; let control_request = control_request.body(())?; + // Build TLS connector based on ca_cert/insecure settings + let connector = if insecure { + let tls_connector = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build()?; + Some(Connector::NativeTls(tls_connector)) + } else if let Some(ca_path) = ca_cert { + let ca_pem = std::fs::read(ca_path)?; + let ca_cert = native_tls::Certificate::from_pem(&ca_pem)?; + let tls_connector = native_tls::TlsConnector::builder() + .add_root_certificate(ca_cert) + .build()?; + Some(Connector::NativeTls(tls_connector)) + } else { + None + }; + // Connect to both WebSockets concurrently - // tokio-tungstenite handles TLS automatically for wss:// URLs - let (terminal_ws, _) = connect_async(terminal_request).await?; - let (control_ws, _) = connect_async(control_request).await?; + let (terminal_ws, _) = + connect_async_tls_with_config(terminal_request, None, false, connector.clone()).await?; + let (control_ws, _) = + connect_async_tls_with_config(control_request, None, false, connector).await?; Ok(WebSocketConnections { terminal_ws, diff --git a/zellij-client/src/web_client/authentication.rs b/zellij-client/src/web_client/authentication.rs index 8de7c087..987822a3 100644 --- a/zellij-client/src/web_client/authentication.rs +++ b/zellij-client/src/web_client/authentication.rs @@ -3,7 +3,15 @@ use axum::body::Body; use axum::http::header::SET_COOKIE; use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; use axum_extra::extract::cookie::{Cookie, SameSite}; -use zellij_utils::web_authentication_tokens::{is_session_token_read_only, validate_session_token}; +use zellij_utils::web_authentication_tokens::{ + hash_token, is_session_token_read_only, validate_session_token, +}; + +#[derive(Clone)] +pub struct SessionTokenHash(pub String); + +#[derive(Clone, Copy)] +pub struct IsReadOnly(pub bool); pub async fn auth_middleware(request: Request, next: Next) -> Result<Response, StatusCode> { let cookies = parse_cookies(&request); @@ -16,11 +24,17 @@ pub async fn auth_middleware(request: Request, next: Next) -> Result<Response, S match validate_session_token(&session_token) { Ok(true) => { // Check if this is a read-only token - let is_read_only = is_session_token_read_only(&session_token).unwrap_or(false); + let is_read_only = is_session_token_read_only(&session_token).unwrap_or(true); + + // Compute session token hash for client ownership verification + let session_token_hash = hash_token(&session_token); // Store in request extensions for downstream handlers let mut request = request; - request.extensions_mut().insert(is_read_only); + request.extensions_mut().insert(IsReadOnly(is_read_only)); + request + .extensions_mut() + .insert(SessionTokenHash(session_token_hash)); let response = next.run(request).await; Ok(response) diff --git a/zellij-client/src/web_client/connection_manager.rs b/zellij-client/src/web_client/connection_manager.rs index 981ea863..cb3ef5f8 100644 --- a/zellij-client/src/web_client/connection_manager.rs +++ b/zellij-client/src/web_client/connection_manager.rs @@ -12,10 +12,21 @@ impl ConnectionTable { client_id: String, client_os_api: Box<dyn ClientOsApi>, is_read_only: bool, + session_token_hash: String, ) { self.client_id_to_channels .insert(client_id.clone(), ClientChannels::new(client_os_api)); - self.client_read_only_status.insert(client_id, is_read_only); + self.client_read_only_status + .insert(client_id.clone(), is_read_only); + self.client_session_token_hash + .insert(client_id, session_token_hash); + } + + pub fn verify_client_ownership(&self, client_id: &str, session_token_hash: &str) -> bool { + self.client_session_token_hash + .get(client_id) + .map(|hash| hash == session_token_hash) + .unwrap_or(false) } pub fn is_client_read_only(&self, client_id: &str) -> bool { @@ -76,6 +87,7 @@ impl ConnectionTable { client_channels.cleanup(); } self.client_read_only_status.remove(client_id); + self.client_session_token_hash.remove(client_id); } pub fn get_should_not_reconnect_flag(&self, client_id: &str) -> Option<Arc<AtomicBool>> { diff --git a/zellij-client/src/web_client/http_handlers.rs b/zellij-client/src/web_client/http_handlers.rs index 4040fab7..c0dc96b7 100644 --- a/zellij-client/src/web_client/http_handlers.rs +++ b/zellij-client/src/web_client/http_handlers.rs @@ -1,3 +1,4 @@ +use crate::web_client::authentication::{IsReadOnly, SessionTokenHash}; use crate::web_client::types::{AppState, CreateClientIdResponse, LoginRequest, LoginResponse}; use crate::web_client::utils::{get_mime_type, parse_cookies}; use axum::{ @@ -11,6 +12,14 @@ use include_dir; use uuid::Uuid; use zellij_utils::{consts::VERSION, web_authentication_tokens::create_session_token}; +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + const WEB_CLIENT_PAGE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/", @@ -23,14 +32,16 @@ pub async fn serve_html(State(state): State<AppState>, request: Request) -> Html let cookies = parse_cookies(&request); let is_authenticated = cookies.get("session_token").is_some(); let auth_value = if is_authenticated { "true" } else { "false" }; - let base_url = state - .config - .lock() - .unwrap() - .web_client - .base_url - .clone() - .unwrap_or("/".to_string()); + let base_url = html_escape( + &state + .config + .lock() + .unwrap() + .web_client + .base_url + .clone() + .unwrap_or("/".to_string()), + ); let html = Html( WEB_CLIENT_PAGE @@ -97,7 +108,20 @@ pub async fn create_new_client( request: axum::extract::Request, ) -> Result<Json<CreateClientIdResponse>, (StatusCode, impl IntoResponse)> { // Extract is_read_only from request extensions (set by auth middleware) - let is_read_only = request.extensions().get::<bool>().copied().unwrap_or(false); + let is_read_only = request + .extensions() + .get::<IsReadOnly>() + .copied() + .unwrap_or(IsReadOnly(true)) + .0; + let session_token_hash = request + .extensions() + .get::<SessionTokenHash>() + .cloned() + .ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Missing session info".to_string()), + ))?; let web_client_id = String::from(Uuid::new_v4()); let os_input = state @@ -109,6 +133,7 @@ pub async fn create_new_client( web_client_id.to_owned(), os_input, is_read_only, + session_token_hash.0, ); Ok(Json(CreateClientIdResponse { diff --git a/zellij-client/src/web_client/mod.rs b/zellij-client/src/web_client/mod.rs index 19a1c422..ef6f6b49 100644 --- a/zellij-client/src/web_client/mod.rs +++ b/zellij-client/src/web_client/mod.rs @@ -1,6 +1,6 @@ pub mod control_message; -mod authentication; +pub(crate) mod authentication; mod connection_manager; mod http_handlers; mod ipc_listener; @@ -228,6 +228,7 @@ pub async fn serve_web_client( } }); + let is_https = state.is_https; let app = Router::new() .route("/ws/control", any(ws_handler_control)) .route("/ws/terminal", any(ws_handler_terminal)) @@ -239,7 +240,27 @@ pub async fn serve_web_client( .route("/assets/{*path}", get(get_static_asset)) .route("/command/login", post(login_handler)) .route("/info/version", get(version_handler)) - .with_state(state); + .with_state(state) + .layer(axum::middleware::from_fn(move |request, next: axum::middleware::Next| { + async move { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap()); + headers.insert("X-Frame-Options", "DENY".parse().unwrap()); + headers.insert("Referrer-Policy", "strict-origin-when-cross-origin".parse().unwrap()); + headers.insert( + "Content-Security-Policy", + "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:; img-src 'self' data:".parse().unwrap(), + ); + if is_https { + headers.insert( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains".parse().unwrap(), + ); + } + response + } + })); match rustls_config { Some(rustls_config) => { diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs index bcd83425..7ac5fa2e 100644 --- a/zellij-client/src/web_client/server_listener.rs +++ b/zellij-client/src/web_client/server_listener.rs @@ -59,6 +59,11 @@ pub fn zellij_server_listener( return; }; let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone(); + if let Err(e) = zellij_utils::sessions::validate_session_name(&session_name) { + log::error!("Invalid session name: {}", e); + client_connection_bus.close_connection(); + return; + } sock_dir.push(session_name.clone()); sock_dir.to_str().unwrap().to_owned() }; diff --git a/zellij-client/src/web_client/session_management.rs b/zellij-client/src/web_client/session_management.rs index 936b4c87..65d96ea1 100644 --- a/zellij-client/src/web_client/session_management.rs +++ b/zellij-client/src/web_client/session_management.rs @@ -134,6 +134,10 @@ pub fn create_first_message( } pub fn create_ipc_pipe(session_name: &str) -> PathBuf { + if let Err(e) = zellij_utils::sessions::validate_session_name(session_name) { + log::error!("Invalid session name: {}", e); + panic!("Invalid session name: {}", e); + } let zellij_ipc_pipe: PathBuf = { let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone(); fs::create_dir_all(&sock_dir).unwrap(); diff --git a/zellij-client/src/web_client/types.rs b/zellij-client/src/web_client/types.rs index 9fa21f8d..18f3d155 100644 --- a/zellij-client/src/web_client/types.rs +++ b/zellij-client/src/web_client/types.rs @@ -85,6 +85,7 @@ impl SessionManager for RealSessionManager { pub struct ConnectionTable { pub client_id_to_channels: HashMap<String, ClientChannels>, pub client_read_only_status: HashMap<String, bool>, + pub client_session_token_hash: HashMap<String, String>, } #[derive(Debug, Clone)] diff --git a/zellij-client/src/web_client/unit/web_client_tests.rs b/zellij-client/src/web_client/unit/web_client_tests.rs index 542a7865..2ea2982a 100644 --- a/zellij-client/src/web_client/unit/web_client_tests.rs +++ b/zellij-client/src/web_client/unit/web_client_tests.rs @@ -2085,6 +2085,551 @@ mod web_client_tests { .unwrap(); connect_async(request).await } + + // ========== Task 1: Security Headers Tests ========== + + #[tokio::test] + #[serial] + async fn test_security_headers_present_on_all_responses() { + let _ = delete_db(); + + let session_manager = Arc::new(MockSessionManager::new()); + let client_os_api_factory = Arc::new(MockClientOsApiFactory::new()); + + let config = Config::default(); + let options = Options::default(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + + let temp_config_path = std::env::temp_dir().join("test_config.kdl"); + + let server_handle = tokio::spawn(serve_web_client( + config, + options, + Some(temp_config_path), + listener, + None, + Some(session_manager), + Some(client_os_api_factory), + addr.ip(), + port, + )); + + wait_for_server(port, Duration::from_secs(5)) + .await + .expect("Server failed to start"); + + let url = format!("http://127.0.0.1:{}/info/version", port); + + let response = timeout( + Duration::from_secs(5), + tokio::task::spawn_blocking(move || isahc::get(&url)), + ) + .await + .expect("Request timed out") + .expect("Spawn blocking failed") + .expect("Request failed"); + + assert_eq!( + response + .headers() + .get("X-Content-Type-Options") + .map(|v| v.to_str().unwrap()), + Some("nosniff"), + "X-Content-Type-Options header should be 'nosniff'" + ); + assert_eq!( + response + .headers() + .get("X-Frame-Options") + .map(|v| v.to_str().unwrap()), + Some("DENY"), + "X-Frame-Options header should be 'DENY'" + ); + assert_eq!( + response + .headers() + .get("Referrer-Policy") + .map(|v| v.to_str().unwrap()), + Some("strict-origin-when-cross-origin"), + "Referrer-Policy header should be 'strict-origin-when-cross-origin'" + ); + assert!( + response.headers().get("Content-Security-Policy").is_some(), + "Content-Security-Policy header should be present" + ); + let csp = response + .headers() + .get("Content-Security-Policy") + .unwrap() + .to_str() + .unwrap(); + assert!( + csp.contains("default-src 'self'"), + "CSP should contain default-src 'self'" + ); + // HSTS should NOT be present when is_https is false + assert!( + response + .headers() + .get("Strict-Transport-Security") + .is_none(), + "Strict-Transport-Security should NOT be present when is_https is false" + ); + + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + #[tokio::test] + #[serial] + async fn test_security_headers_on_authenticated_routes() { + let _ = delete_db(); + + let test_token_name = "test_token_sec_headers_auth"; + let (auth_token, _) = create_token(Some(test_token_name.to_string()), false) + .expect("Failed to create test token"); + + let session_manager = Arc::new(MockSessionManager::new()); + let client_os_api_factory = Arc::new(MockClientOsApiFactory::new()); + + let config = Config::default(); + let options = Options::default(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + + let temp_config_path = std::env::temp_dir().join("test_config.kdl"); + let server_handle = tokio::spawn(async move { + serve_web_client( + config, + options, + Some(temp_config_path), + listener, + None, + Some(session_manager), + Some(client_os_api_factory), + addr.ip(), + port, + ) + .await; + }); + + wait_for_server(port, Duration::from_secs(5)) + .await + .expect("Server failed to start"); + + let session_token = login_and_get_session_token(port, &auth_token).await; + + let session_url = format!("http://127.0.0.1:{}/session", port); + let response = timeout( + Duration::from_secs(5), + tokio::task::spawn_blocking({ + let session_token = session_token.to_string(); + move || { + isahc::Request::post(&session_url) + .header("Cookie", format!("session_token={}", session_token)) + .header("Content-Type", "application/json") + .body("{}") + .unwrap() + .send() + } + }), + ) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert_eq!( + response + .headers() + .get("X-Content-Type-Options") + .map(|v| v.to_str().unwrap()), + Some("nosniff"), + "X-Content-Type-Options header should be present on authenticated routes" + ); + assert_eq!( + response + .headers() + .get("X-Frame-Options") + .map(|v| v.to_str().unwrap()), + Some("DENY"), + "X-Frame-Options header should be present on authenticated routes" + ); + + server_handle.abort(); + revoke_token(test_token_name).expect("Failed to revoke test token"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // ========== Task 10: web_client_id binding tests ========== + + #[tokio::test] + #[serial] + async fn test_control_websocket_rejects_foreign_web_client_id() { + let _ = delete_db(); + + // Create a read-write token and a read-only token + let (rw_token, _) = create_token(Some("rw_token".to_string()), false).unwrap(); + let (ro_token, _) = create_token(Some("ro_token".to_string()), true).unwrap(); + + let mock_session_manager = Arc::new(MockSessionManager::with_all_sessions_existing()); + let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new()); + let factory_for_verification = mock_os_api_factory.clone(); + + let config = Config::default(); + let options = Options::default(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + let ip = addr.ip(); + + let temp_config_path = std::env::temp_dir().join("test_config.kdl"); + let server_handle = tokio::spawn(async move { + serve_web_client( + config, + options, + Some(temp_config_path), + listener, + None, + Some(mock_session_manager), + Some(mock_os_api_factory), + ip, + port, + ) + .await; + }); + + wait_for_server(port, Duration::from_secs(5)) + .await + .expect("Server failed to start"); + + // Login with read-write token and create a client session + let rw_session_token = login_and_get_session_token(port, &rw_token).await; + let rw_web_client_id = create_client_session(port, &rw_session_token).await; + + // Login with read-only token and create a client session + let ro_session_token = login_and_get_session_token(port, &ro_token).await; + let _ro_web_client_id = create_client_session(port, &ro_session_token).await; + + // Connect a control WebSocket using the read-only session token + let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port); + let (control_ws, _) = timeout( + Duration::from_secs(5), + connect_async_with_cookie(&control_ws_url, &ro_session_token), + ) + .await + .expect("Control WebSocket connection timed out") + .expect("Failed to connect to control WebSocket"); + + let (mut control_sink, mut control_stream) = control_ws.split(); + + // Wait for initial SetConfig message + let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await; + + // Send a TerminalResize message with the READ-WRITE user's web_client_id + let resize_msg = WebClientToWebServerControlMessage { + web_client_id: rw_web_client_id.clone(), + payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size { + rows: 1, + cols: 1, + }), + }; + + control_sink + .send(Message::Text(serde_json::to_string(&resize_msg).unwrap())) + .await + .expect("Failed to send resize message"); + + // The WebSocket should be closed by the server + let close_result = timeout(Duration::from_secs(3), control_stream.next()).await; + match close_result { + Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) => { + // Expected: connection was closed + }, + Err(_) => { + // Timeout - connection may have been silently dropped + }, + Ok(Some(Ok(_))) => { + // If we get another message, the connection wasn't closed - this is a failure + // but let's check if it closes after + let second_result = timeout(Duration::from_secs(2), control_stream.next()).await; + assert!( + matches!( + second_result, + Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) | Err(_) + ), + "WebSocket should have been closed after sending foreign web_client_id" + ); + }, + } + + // Verify that NO TerminalResize message was forwarded on behalf of the rw client + tokio::time::sleep(Duration::from_millis(200)).await; + let mock_apis = factory_for_verification.mock_apis.lock().unwrap(); + for (_, mock_api) in mock_apis.iter() { + let messages = mock_api.get_sent_messages(); + for msg in &messages { + if matches!(msg, ClientToServerMsg::TerminalResize { .. }) { + panic!( + "TerminalResize should NOT have been forwarded for a foreign web_client_id" + ); + } + } + } + + let _ = control_sink.close().await; + server_handle.abort(); + let _ = delete_db(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + #[tokio::test] + #[serial] + async fn test_control_websocket_accepts_own_web_client_id() { + let _ = delete_db(); + + let (rw_token, _) = create_token(Some("rw_own_test".to_string()), false).unwrap(); + + let mock_session_manager = Arc::new(MockSessionManager::with_all_sessions_existing()); + let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new()); + let factory_for_verification = mock_os_api_factory.clone(); + + let config = Config::default(); + let options = Options::default(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + let ip = addr.ip(); + + let temp_config_path = std::env::temp_dir().join("test_config.kdl"); + let server_handle = tokio::spawn(async move { + serve_web_client( + config, + options, + Some(temp_config_path), + listener, + None, + Some(mock_session_manager), + Some(mock_os_api_factory), + ip, + port, + ) + .await; + }); + + wait_for_server(port, Duration::from_secs(5)) + .await + .expect("Server failed to start"); + + let session_token = login_and_get_session_token(port, &rw_token).await; + let web_client_id = create_client_session(port, &session_token).await; + + let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port); + let (control_ws, _) = timeout( + Duration::from_secs(5), + connect_async_with_cookie(&control_ws_url, &session_token), + ) + .await + .expect("Control WebSocket connection timed out") + .expect("Failed to connect to control WebSocket"); + + let (mut control_sink, mut control_stream) = control_ws.split(); + + // Wait for initial SetConfig message + let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await; + + // Send a TerminalResize message with the CORRECT web_client_id + let resize_msg = WebClientToWebServerControlMessage { + web_client_id: web_client_id.clone(), + payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size { + rows: 30, + cols: 100, + }), + }; + + control_sink + .send(Message::Text(serde_json::to_string(&resize_msg).unwrap())) + .await + .expect("Failed to send resize message"); + + // Allow message processing + tokio::time::sleep(Duration::from_millis(500)).await; + + // Verify the resize was forwarded + let mock_apis = factory_for_verification.mock_apis.lock().unwrap(); + let mut found_resize = false; + for (_, mock_api) in mock_apis.iter() { + let messages = mock_api.get_sent_messages(); + for msg in messages { + if matches!(msg, ClientToServerMsg::TerminalResize { .. }) { + found_resize = true; + } + } + } + assert!( + found_resize, + "TerminalResize should be forwarded when using own web_client_id" + ); + + let _ = control_sink.close().await; + server_handle.abort(); + let _ = delete_db(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + #[tokio::test] + #[serial] + async fn test_terminal_websocket_rejects_foreign_web_client_id() { + let _ = delete_db(); + + let (rw_token, _) = create_token(Some("rw_term_test".to_string()), false).unwrap(); + let (ro_token, _) = create_token(Some("ro_term_test".to_string()), true).unwrap(); + + let mock_session_manager = Arc::new(MockSessionManager::with_all_sessions_existing()); + let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new()); + + let config = Config::default(); + let options = Options::default(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + let ip = addr.ip(); + + let temp_config_path = std::env::temp_dir().join("test_config.kdl"); + let server_handle = tokio::spawn(async move { + serve_web_client( + config, + options, + Some(temp_config_path), + listener, + None, + Some(mock_session_manager), + Some(mock_os_api_factory), + ip, + port, + ) + .await; + }); + + wait_for_server(port, Duration::from_secs(5)) + .await + .expect("Server failed to start"); + + // Create two sessions with different tokens + let rw_session_token = login_and_get_session_token(port, &rw_token).await; + let rw_web_client_id = create_client_session(port, &rw_session_token).await; + + let ro_session_token = login_and_get_session_token(port, &ro_token).await; + let _ro_web_client_id = create_client_session(port, &ro_session_token).await; + + // Try to connect terminal WebSocket using ro session token but rw web_client_id + let terminal_ws_url = format!( + "ws://127.0.0.1:{}/ws/terminal?web_client_id={}", + port, rw_web_client_id + ); + let ws_result = timeout( + Duration::from_secs(5), + connect_async_with_cookie(&terminal_ws_url, &ro_session_token), + ) + .await; + + match ws_result { + Ok(Ok((ws, _))) => { + // Connection was established but should be closed immediately + let (_sink, mut stream) = ws.split(); + let msg = timeout(Duration::from_secs(2), stream.next()).await; + match msg { + Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) | Err(_) => { + // Expected: connection was rejected/closed + }, + Ok(Some(Ok(_))) => { + // Unexpected: received data on a connection that should have been rejected + // The server may close after initial processing + }, + } + }, + Ok(Err(_)) => { + // Connection failed - expected behavior + }, + Err(_) => { + // Timeout - acceptable + }, + } + + server_handle.abort(); + let _ = delete_db(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // ========== Task 8: HTML-escape base_url test ========== + + #[tokio::test] + #[serial] + async fn test_base_url_with_special_characters_is_escaped() { + let _ = delete_db(); + + let session_manager = Arc::new(MockSessionManager::new()); + let client_os_api_factory = Arc::new(MockClientOsApiFactory::new()); + + let mut config = Config::default(); + config.web_client.base_url = Some("\"><script>alert(1)</script>".to_string()); + let options = Options::default(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + + let temp_config_path = std::env::temp_dir().join("test_config.kdl"); + let server_handle = tokio::spawn(async move { + serve_web_client( + config, + options, + Some(temp_config_path), + listener, + None, + Some(session_manager), + Some(client_os_api_factory), + addr.ip(), + port, + ) + .await; + }); + + wait_for_server(port, Duration::from_secs(5)) + .await + .expect("Server failed to start"); + + let url = format!("http://127.0.0.1:{}/", port); + let mut response = timeout( + Duration::from_secs(5), + tokio::task::spawn_blocking(move || isahc::get(&url)), + ) + .await + .expect("Request timed out") + .expect("Spawn blocking failed") + .expect("Request failed"); + + let body = response.text().expect("Failed to read response body"); + assert!( + !body.contains("<script>alert(1)</script>"), + "Response body should NOT contain unescaped script tag" + ); + assert!( + body.contains("<script>"), + "Response body should contain HTML-escaped script tag" + ); + + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + } } #[derive(Debug, Clone)] diff --git a/zellij-client/src/web_client/websocket_handlers.rs b/zellij-client/src/web_client/websocket_handlers.rs index f320e75b..ac7dd0ec 100644 --- a/zellij-client/src/web_client/websocket_handlers.rs +++ b/zellij-client/src/web_client/websocket_handlers.rs @@ -1,3 +1,4 @@ +use crate::web_client::authentication::SessionTokenHash; use crate::web_client::control_message::{ SetConfigPayload, WebClientToWebServerControlMessage, WebClientToWebServerControlMessagePayload, WebServerToWebClientControlMessage, @@ -24,8 +25,9 @@ pub async fn ws_handler_control( ws: WebSocketUpgrade, _path: Option<AxumPath<String>>, State(state): State<AppState>, + axum::Extension(session_token_hash): axum::Extension<SessionTokenHash>, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_ws_control(socket, state)) + ws.on_upgrade(move |socket| handle_ws_control(socket, state, session_token_hash)) } pub async fn ws_handler_terminal( @@ -33,11 +35,18 @@ pub async fn ws_handler_terminal( session_name: Option<AxumPath<String>>, Query(params): Query<TerminalParams>, State(state): State<AppState>, + axum::Extension(session_token_hash): axum::Extension<SessionTokenHash>, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_ws_terminal(socket, session_name, params, state)) + ws.on_upgrade(move |socket| { + handle_ws_terminal(socket, session_name, params, state, session_token_hash) + }) } -async fn handle_ws_control(socket: WebSocket, state: AppState) { +async fn handle_ws_control( + socket: WebSocket, + state: AppState, + session_token_hash: SessionTokenHash, +) { let payload = SetConfigPayload::from(&*state.config.lock().unwrap()); let set_config_msg = WebServerToWebClientControlMessage::SetConfig(payload); @@ -79,6 +88,21 @@ async fn handle_ws_control(socket: WebSocket, state: AppState) { serde_json::from_str(&msg); match deserialized_msg { Ok(deserialized_msg) => { + if !state + .connection_table + .lock() + .unwrap() + .verify_client_ownership( + &deserialized_msg.web_client_id, + &session_token_hash.0, + ) + { + log::error!( + "Client attempted to use web_client_id {} that does not belong to their session", + deserialized_msg.web_client_id + ); + return; + } if !set_client_control_channel { set_client_control_channel = true; state @@ -112,8 +136,24 @@ async fn handle_ws_terminal( session_name: Option<AxumPath<String>>, params: TerminalParams, state: AppState, + session_token_hash: SessionTokenHash, ) { let web_client_id = params.web_client_id; + + // Verify the session token owns this web_client_id + if !state + .connection_table + .lock() + .unwrap() + .verify_client_ownership(&web_client_id, &session_token_hash.0) + { + log::error!( + "Terminal WebSocket: client does not own web_client_id {}", + web_client_id + ); + return; + } + let Some(os_input) = state .connection_table .lock() diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs index c1b3fde1..0d5e847c 100644 --- a/zellij-utils/src/cli.rs +++ b/zellij-utils/src/cli.rs @@ -330,6 +330,14 @@ pub enum Sessions { /// Delete saved session before connecting #[clap(long, value_parser)] forget: bool, + + /// Path to a custom CA certificate (PEM format) for verifying the remote server + #[clap(long, value_name = "FILE", value_parser)] + ca_cert: Option<PathBuf>, + + /// Skip TLS certificate validation (DANGEROUS — development only) + #[clap(long, value_parser)] + insecure: bool, }, /// Watch a session (read-only) diff --git a/zellij-utils/src/web_authentication_tokens.rs b/zellij-utils/src/web_authentication_tokens.rs index ddd368bc..0102be19 100644 --- a/zellij-utils/src/web_authentication_tokens.rs +++ b/zellij-utils/src/web_authentication_tokens.rs @@ -60,20 +60,37 @@ impl From<std::io::Error> for TokenError { type Result<T> = std::result::Result<T, TokenError>; fn get_db_path() -> Result<PathBuf> { - if cfg!(debug_assertions) { - // tests db - let data_dir = ZELLIJ_PROJ_DIR.data_dir(); - std::fs::create_dir_all(&data_dir)?; - Ok(data_dir.join("tokens_for_dev.db")) + let data_dir = ZELLIJ_PROJ_DIR.data_dir(); + std::fs::create_dir_all(&data_dir)?; + + let db_path = if cfg!(debug_assertions) { + data_dir.join("tokens_for_dev.db") } else { - // prod db - let data_dir = ZELLIJ_PROJ_DIR.data_dir(); - std::fs::create_dir_all(data_dir)?; - Ok(data_dir.join("tokens.db")) + data_dir.join("tokens.db") + }; + + Ok(db_path) +} + +fn open_db() -> Result<Connection> { + let db_path = get_db_path()?; + let conn = Connection::open(&db_path)?; + init_db(&conn)?; + + // Set restrictive permissions on the database file + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&db_path, perms); } + + Ok(conn) } fn init_db(conn: &Connection) -> Result<()> { + conn.execute_batch("PRAGMA foreign_keys = ON")?; + conn.execute( "CREATE TABLE IF NOT EXISTS tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -98,25 +115,31 @@ fn init_db(conn: &Connection) -> Result<()> { )?; // Migration: Add read_only column if it doesn't exist - conn.execute( + match conn.execute( "ALTER TABLE tokens ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT 0", [], - ) - .ok(); + ) { + Ok(_) => {}, + Err(e) => { + let err_msg = e.to_string(); + // "duplicate column name" is the expected error when the column already exists + if !err_msg.contains("duplicate column name") { + return Err(TokenError::Database(e)); + } + }, + } Ok(()) } -fn hash_token(token: &str) -> String { +pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); format!("{:x}", hasher.finalize()) } pub fn create_token(name: Option<String>, read_only: bool) -> Result<(String, String)> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let token = Uuid::new_v4().to_string(); let token_hash = hash_token(&token); @@ -143,9 +166,7 @@ pub fn create_token(name: Option<String>, read_only: bool) -> Result<(String, St } pub fn create_session_token(auth_token: &str, remember_me: bool) -> Result<String> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; cleanup_expired_sessions()?; @@ -192,9 +213,7 @@ pub fn create_session_token(auth_token: &str, remember_me: bool) -> Result<Strin } pub fn validate_session_token(session_token: &str) -> Result<bool> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let session_token_hash = hash_token(session_token); @@ -208,9 +227,7 @@ pub fn validate_session_token(session_token: &str) -> Result<bool> { } pub fn is_session_token_read_only(session_token: &str) -> Result<bool> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let session_token_hash = hash_token(session_token); @@ -231,9 +248,7 @@ pub fn is_session_token_read_only(session_token: &str) -> Result<bool> { } pub fn cleanup_expired_sessions() -> Result<usize> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let rows_affected = conn.execute( "DELETE FROM session_tokens WHERE expires_at <= datetime('now')", @@ -244,9 +259,7 @@ pub fn cleanup_expired_sessions() -> Result<usize> { } pub fn revoke_session_token(session_token: &str) -> Result<bool> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let session_token_hash = hash_token(session_token); let rows_affected = conn.execute( @@ -258,9 +271,7 @@ pub fn revoke_session_token(session_token: &str) -> Result<bool> { } pub fn revoke_sessions_for_auth_token(auth_token: &str) -> Result<usize> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let auth_token_hash = hash_token(auth_token); let rows_affected = conn.execute( @@ -272,11 +283,11 @@ pub fn revoke_sessions_for_auth_token(auth_token: &str) -> Result<usize> { } pub fn revoke_token(name: &str) -> Result<bool> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let mut conn = open_db()?; + + let tx = conn.transaction().map_err(TokenError::Database)?; - let token_hash = match conn.query_row( + let token_hash = match tx.query_row( "SELECT token_hash FROM tokens WHERE name = ?1", [&name], |row| row.get::<_, String>(0), @@ -287,30 +298,29 @@ pub fn revoke_token(name: &str) -> Result<bool> { }; if let Some(token_hash) = token_hash { - conn.execute( + tx.execute( "DELETE FROM session_tokens WHERE auth_token_hash = ?1", [&token_hash], )?; } - let rows_affected = conn.execute("DELETE FROM tokens WHERE name = ?1", [&name])?; + let rows_affected = tx.execute("DELETE FROM tokens WHERE name = ?1", [&name])?; + tx.commit().map_err(TokenError::Database)?; Ok(rows_affected > 0) } pub fn revoke_all_tokens() -> Result<usize> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let mut conn = open_db()?; - conn.execute("DELETE FROM session_tokens", [])?; - let rows_affected = conn.execute("DELETE FROM tokens", [])?; + let tx = conn.transaction().map_err(TokenError::Database)?; + tx.execute("DELETE FROM session_tokens", [])?; + let rows_affected = tx.execute("DELETE FROM tokens", [])?; + tx.commit().map_err(TokenError::Database)?; Ok(rows_affected) } pub fn rename_token(old_name: &str, new_name: &str) -> Result<()> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let count: i64 = conn.query_row( "SELECT COUNT(*) FROM tokens WHERE name = ?1", @@ -337,9 +347,7 @@ pub fn rename_token(old_name: &str, new_name: &str) -> Result<()> { } pub fn list_tokens() -> Result<Vec<TokenInfo>> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let mut stmt = conn.prepare("SELECT name, created_at, read_only FROM tokens ORDER BY created_at")?; @@ -367,9 +375,7 @@ pub fn delete_db() -> Result<()> { } pub fn validate_token(token: &str) -> Result<bool> { - let db_path = get_db_path()?; - let conn = Connection::open(db_path)?; - init_db(&conn)?; + let conn = open_db()?; let token_hash = hash_token(token); |
