From 39fec7ca820ea9bb630a5316a1569081bb23065e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:08:12 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20mux=20module=20=E2=80=94?= =?UTF-8?q?=20add=20per-workspace=20auth=20token=20for=20CSWSH=20protectio?= =?UTF-8?q?n=20(#728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add per-workspace authentication token wiring to the Mux Coder module, closing the last-mile deployment gap for cross-site WebSocket hijacking (CSWSH) protection identified in coder/security#120. ## Background When Mux runs as a Coder workspace app, it is accessible via Coder's subdomain proxy (e.g., `mux--ws--user.apps.coder.com`). Without an auth token, a malicious same-site origin (another user's workspace app on the same `*.coder.com` domain) can hijack the WebSocket session and execute arbitrary commands via the oRPC API. The Mux application itself already implements: - **Strict same-origin enforcement** for HTTP/CORS and WebSocket upgrades (coder/mux#2418) - **Auth token support** — the server reads `MUX_SERVER_AUTH_TOKEN` or `--auth-token`, and the browser frontend extracts `?token=` from the URL and persists it to localStorage What was missing was module-level token generation and browser/backend wiring. ## Implementation - **`random_password.mux_auth_token`** generates a 64-character token per module instance. - **Backend wiring:** `run.sh` launches mux with a process-scoped `MUX_SERVER_AUTH_TOKEN` environment variable. - **Frontend wiring:** `coder_app.mux.url` includes `?token=` so first launch from Coder passes the token to the browser for bootstrap/persistence. To avoid cross-instance breakage, this change intentionally does **not** use a shared `coder_env` key. Multiple `coder/mux` module instances can target the same `agent_id` (different `slug`/`port`), and a single global env key would collide. Process-scoped env keeps each instance's backend token aligned with its app URL token. ## Validation - `terraform fmt -check -diff` in `registry/coder/modules/mux` - `terraform test` in `registry/coder/modules/mux` (8 passed, 0 failed) - Updated tests now verify the URL token value (not just prefix) and verify the launch script sets `MUX_SERVER_AUTH_TOKEN` using the generated token. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_ --- registry/coder/modules/mux/README.md | 14 +++--- registry/coder/modules/mux/main.tf | 25 +++++++++-- registry/coder/modules/mux/mux.tftest.hcl | 53 ++++++++++++++++++++--- registry/coder/modules/mux/run.sh | 4 +- 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md index 00a9de65..b9cfafc0 100644 --- a/registry/coder/modules/mux/README.md +++ b/registry/coder/modules/mux/README.md @@ -14,7 +14,7 @@ Automatically install and run [Mux](https://github.com/coder/mux) in a Coder wor module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.8" + version = "1.1.0" agent_id = coder_agent.main.id } ``` @@ -37,7 +37,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.8" + version = "1.1.0" agent_id = coder_agent.main.id } ``` @@ -48,7 +48,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.8" + version = "1.1.0" agent_id = coder_agent.main.id # Default is "latest"; set to a specific version to pin install_version = "0.4.0" @@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.8" + version = "1.1.0" agent_id = coder_agent.main.id add-project = "/path/to/project" } @@ -75,7 +75,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.8" + version = "1.1.0" agent_id = coder_agent.main.id port = 8080 } @@ -89,7 +89,7 @@ Run an existing copy of Mux if found, otherwise install from npm: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.8" + version = "1.1.0" agent_id = coder_agent.main.id use_cached = true } @@ -103,7 +103,7 @@ Run without installing from the network (requires Mux to be pre-installed): module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.8" + version = "1.1.0" agent_id = coder_agent.main.id install = false } diff --git a/registry/coder/modules/mux/main.tf b/registry/coder/modules/mux/main.tf index 870beae2..1eeddecf 100644 --- a/registry/coder/modules/mux/main.tf +++ b/registry/coder/modules/mux/main.tf @@ -7,6 +7,10 @@ terraform { source = "coder/coder" version = ">= 2.5" } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } } } @@ -113,6 +117,22 @@ variable "open_in" { } } +# Per-module auth token for cross-site request protection. +# We pass this token into each mux process at launch time (process-scoped env) +# and include it in the app URL query string (?token=...). +# +# Why process-scoped env instead of a shared coder_env value: +# multiple mux module instances can target the same agent (different slug/port). +# A single global MUX_SERVER_AUTH_TOKEN env key would cause collisions. +resource "random_password" "mux_auth_token" { + length = 64 + special = false +} + +locals { + mux_auth_token = random_password.mux_auth_token.result +} + resource "coder_script" "mux" { agent_id = var.agent_id display_name = var.display_name @@ -125,6 +145,7 @@ resource "coder_script" "mux" { INSTALL_PREFIX : var.install_prefix, OFFLINE : !var.install, USE_CACHED : var.use_cached, + AUTH_TOKEN : local.mux_auth_token, }) run_on_start = true @@ -140,7 +161,7 @@ resource "coder_app" "mux" { agent_id = var.agent_id slug = var.slug display_name = var.display_name - url = "http://localhost:${var.port}" + url = "http://localhost:${var.port}?token=${local.mux_auth_token}" icon = "/icon/mux.svg" subdomain = var.subdomain share = var.share @@ -154,5 +175,3 @@ resource "coder_app" "mux" { threshold = 6 } } - - diff --git a/registry/coder/modules/mux/mux.tftest.hcl b/registry/coder/modules/mux/mux.tftest.hcl index c403d377..af103ae2 100644 --- a/registry/coder/modules/mux/mux.tftest.hcl +++ b/registry/coder/modules/mux/mux.tftest.hcl @@ -20,8 +20,10 @@ run "install_false_and_use_cached_conflict" { ] } +# Needs command = apply because the URL contains random_password.result, +# which is unknown during plan. run "custom_port" { - command = plan + command = apply variables { agent_id = "foo" @@ -29,8 +31,51 @@ run "custom_port" { } assert { - condition = resource.coder_app.mux.url == "http://localhost:8080" - error_message = "coder_app URL must use the configured port" + condition = startswith(resource.coder_app.mux.url, "http://localhost:8080?token=") + error_message = "coder_app URL must use the configured port and include auth token" + } + + assert { + condition = trimprefix(resource.coder_app.mux.url, "http://localhost:8080?token=") == random_password.mux_auth_token.result + error_message = "URL token must match the generated auth token" + } +} + +# Needs command = apply because random_password.result is unknown during plan. +run "auth_token_in_server_script" { + command = apply + + variables { + agent_id = "foo" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "MUX_SERVER_AUTH_TOKEN=") + error_message = "mux launch script must set MUX_SERVER_AUTH_TOKEN" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, random_password.mux_auth_token.result) + error_message = "mux launch script must use the generated auth token" + } +} + +# Needs command = apply because random_password.result is unknown during plan. +run "auth_token_in_url" { + command = apply + + variables { + agent_id = "foo" + } + + assert { + condition = startswith(resource.coder_app.mux.url, "http://localhost:4000?token=") + error_message = "coder_app URL must include auth token query parameter" + } + + assert { + condition = trimprefix(resource.coder_app.mux.url, "http://localhost:4000?token=") == random_password.mux_auth_token.result + error_message = "URL token must match the generated auth token" } } @@ -62,5 +107,3 @@ run "use_cached_only_success" { use_cached = true } } - - diff --git a/registry/coder/modules/mux/run.sh b/registry/coder/modules/mux/run.sh index 2409f19d..0d0c6520 100644 --- a/registry/coder/modules/mux/run.sh +++ b/registry/coder/modules/mux/run.sh @@ -9,7 +9,9 @@ function run_mux() { rm -f "$HOME/.mux/server.lock" local port_value + local auth_token_value port_value="${PORT}" + auth_token_value="${AUTH_TOKEN}" if [ -z "$port_value" ]; then port_value="4000" fi @@ -20,7 +22,7 @@ function run_mux() { fi echo "🚀 Starting mux server on port $port_value..." echo "Check logs at ${LOG_PATH}!" - PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 & + MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 & } # Check if mux is already installed for offline mode