aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-03-10 18:25:05 +0100
committerGitHub <noreply@github.com>2026-03-10 18:25:05 +0100
commit458f90cbbdd9efe304a788fe100724b0644b5afe (patch)
tree24513cee1761fd465b5da7615469e97eea2bd275
parentHighlight priority levels (#4827) (diff)
downloadsl0ppij-458f90cbbdd9efe304a788fe100724b0644b5afe.tar.gz
sl0ppij-458f90cbbdd9efe304a788fe100724b0644b5afe.tar.xz
sl0ppij-458f90cbbdd9efe304a788fe100724b0644b5afe.zip
Web security hardening (#4830)
* security hardening * fix some issues * rustfmt * add pr
-rw-r--r--CHANGELOG.md2
-rw-r--r--Cargo.lock1
-rw-r--r--src/commands.rs8
-rw-r--r--zellij-client/Cargo.toml1
-rw-r--r--zellij-client/assets/auth.js2
-rw-r--r--zellij-client/assets/index.html5
-rw-r--r--zellij-client/assets/modals.js117
-rw-r--r--zellij-client/src/lib.rs4
-rw-r--r--zellij-client/src/remote_attach/auth.rs12
-rw-r--r--zellij-client/src/remote_attach/http_client.rs30
-rw-r--r--zellij-client/src/remote_attach/mod.rs52
-rw-r--r--zellij-client/src/remote_attach/unit/remote_attach_tests.rs2
-rw-r--r--zellij-client/src/remote_attach/websockets.rs32
-rw-r--r--zellij-client/src/web_client/authentication.rs20
-rw-r--r--zellij-client/src/web_client/connection_manager.rs14
-rw-r--r--zellij-client/src/web_client/http_handlers.rs43
-rw-r--r--zellij-client/src/web_client/mod.rs25
-rw-r--r--zellij-client/src/web_client/server_listener.rs5
-rw-r--r--zellij-client/src/web_client/session_management.rs4
-rw-r--r--zellij-client/src/web_client/types.rs1
-rw-r--r--zellij-client/src/web_client/unit/web_client_tests.rs545
-rw-r--r--zellij-client/src/web_client/websocket_handlers.rs46
-rw-r--r--zellij-utils/src/cli.rs8
-rw-r--r--zellij-utils/src/web_authentication_tokens.rs114
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)
diff --git a/Cargo.lock b/Cargo.lock
index d9b7cf09..27afb50b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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('&', "&amp;")
+ .replace('<', "&lt;")
+ .replace('>', "&gt;")
+ .replace('"', "&quot;")
+ .replace('\'', "&#x27;")
+}
+
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("&lt;script&gt;"),
+ "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);