🤖 feat: mux module — add per-workspace auth token for CSWSH protection (#728)

## 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=<secret>` 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`_

<!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh -->
This commit is contained in:
Michael Suchacz 2026-02-14 23:08:12 +01:00 committed by GitHub
parent c5ff4de9ed
commit 39fec7ca82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 80 additions and 16 deletions

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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