Skip to content

Commit 39fec7c

Browse files
authored
🤖 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 -->
1 parent c5ff4de commit 39fec7c

4 files changed

Lines changed: 80 additions & 16 deletions

File tree

‎registry/coder/modules/mux/README.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Automatically install and run [Mux](https://github.com/coder/mux) in a Coder wor
1414
module "mux" {
1515
count = data.coder_workspace.me.start_count
1616
source = "registry.coder.com/coder/mux/coder"
17-
version = "1.0.8"
17+
version = "1.1.0"
1818
agent_id = coder_agent.main.id
1919
}
2020
```
@@ -37,7 +37,7 @@ module "mux" {
3737
module "mux" {
3838
count = data.coder_workspace.me.start_count
3939
source = "registry.coder.com/coder/mux/coder"
40-
version = "1.0.8"
40+
version = "1.1.0"
4141
agent_id = coder_agent.main.id
4242
}
4343
```
@@ -48,7 +48,7 @@ module "mux" {
4848
module "mux" {
4949
count = data.coder_workspace.me.start_count
5050
source = "registry.coder.com/coder/mux/coder"
51-
version = "1.0.8"
51+
version = "1.1.0"
5252
agent_id = coder_agent.main.id
5353
# Default is "latest"; set to a specific version to pin
5454
install_version = "0.4.0"
@@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`:
6363
module "mux" {
6464
count = data.coder_workspace.me.start_count
6565
source = "registry.coder.com/coder/mux/coder"
66-
version = "1.0.8"
66+
version = "1.1.0"
6767
agent_id = coder_agent.main.id
6868
add-project = "/path/to/project"
6969
}
@@ -75,7 +75,7 @@ module "mux" {
7575
module "mux" {
7676
count = data.coder_workspace.me.start_count
7777
source = "registry.coder.com/coder/mux/coder"
78-
version = "1.0.8"
78+
version = "1.1.0"
7979
agent_id = coder_agent.main.id
8080
port = 8080
8181
}
@@ -89,7 +89,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
8989
module "mux" {
9090
count = data.coder_workspace.me.start_count
9191
source = "registry.coder.com/coder/mux/coder"
92-
version = "1.0.8"
92+
version = "1.1.0"
9393
agent_id = coder_agent.main.id
9494
use_cached = true
9595
}
@@ -103,7 +103,7 @@ Run without installing from the network (requires Mux to be pre-installed):
103103
module "mux" {
104104
count = data.coder_workspace.me.start_count
105105
source = "registry.coder.com/coder/mux/coder"
106-
version = "1.0.8"
106+
version = "1.1.0"
107107
agent_id = coder_agent.main.id
108108
install = false
109109
}

‎registry/coder/modules/mux/main.tf‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ terraform {
77
source = "coder/coder"
88
version = ">= 2.5"
99
}
10+
random = {
11+
source = "hashicorp/random"
12+
version = ">= 3.0"
13+
}
1014
}
1115
}
1216

@@ -113,6 +117,22 @@ variable "open_in" {
113117
}
114118
}
115119

120+
# Per-module auth token for cross-site request protection.
121+
# We pass this token into each mux process at launch time (process-scoped env)
122+
# and include it in the app URL query string (?token=...).
123+
#
124+
# Why process-scoped env instead of a shared coder_env value:
125+
# multiple mux module instances can target the same agent (different slug/port).
126+
# A single global MUX_SERVER_AUTH_TOKEN env key would cause collisions.
127+
resource "random_password" "mux_auth_token" {
128+
length = 64
129+
special = false
130+
}
131+
132+
locals {
133+
mux_auth_token = random_password.mux_auth_token.result
134+
}
135+
116136
resource "coder_script" "mux" {
117137
agent_id = var.agent_id
118138
display_name = var.display_name
@@ -125,6 +145,7 @@ resource "coder_script" "mux" {
125145
INSTALL_PREFIX : var.install_prefix,
126146
OFFLINE : !var.install,
127147
USE_CACHED : var.use_cached,
148+
AUTH_TOKEN : local.mux_auth_token,
128149
})
129150
run_on_start = true
130151

@@ -140,7 +161,7 @@ resource "coder_app" "mux" {
140161
agent_id = var.agent_id
141162
slug = var.slug
142163
display_name = var.display_name
143-
url = "http://localhost:${var.port}"
164+
url = "http://localhost:${var.port}?token=${local.mux_auth_token}"
144165
icon = "/icon/mux.svg"
145166
subdomain = var.subdomain
146167
share = var.share
@@ -154,5 +175,3 @@ resource "coder_app" "mux" {
154175
threshold = 6
155176
}
156177
}
157-
158-

‎registry/coder/modules/mux/mux.tftest.hcl‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,62 @@ run "install_false_and_use_cached_conflict" {
2020
]
2121
}
2222

23+
# Needs command = apply because the URL contains random_password.result,
24+
# which is unknown during plan.
2325
run "custom_port" {
24-
command = plan
26+
command = apply
2527

2628
variables {
2729
agent_id = "foo"
2830
port = 8080
2931
}
3032

3133
assert {
32-
condition = resource.coder_app.mux.url == "http://localhost:8080"
33-
error_message = "coder_app URL must use the configured port"
34+
condition = startswith(resource.coder_app.mux.url, "http://localhost:8080?token=")
35+
error_message = "coder_app URL must use the configured port and include auth token"
36+
}
37+
38+
assert {
39+
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:8080?token=") == random_password.mux_auth_token.result
40+
error_message = "URL token must match the generated auth token"
41+
}
42+
}
43+
44+
# Needs command = apply because random_password.result is unknown during plan.
45+
run "auth_token_in_server_script" {
46+
command = apply
47+
48+
variables {
49+
agent_id = "foo"
50+
}
51+
52+
assert {
53+
condition = strcontains(resource.coder_script.mux.script, "MUX_SERVER_AUTH_TOKEN=")
54+
error_message = "mux launch script must set MUX_SERVER_AUTH_TOKEN"
55+
}
56+
57+
assert {
58+
condition = strcontains(resource.coder_script.mux.script, random_password.mux_auth_token.result)
59+
error_message = "mux launch script must use the generated auth token"
60+
}
61+
}
62+
63+
# Needs command = apply because random_password.result is unknown during plan.
64+
run "auth_token_in_url" {
65+
command = apply
66+
67+
variables {
68+
agent_id = "foo"
69+
}
70+
71+
assert {
72+
condition = startswith(resource.coder_app.mux.url, "http://localhost:4000?token=")
73+
error_message = "coder_app URL must include auth token query parameter"
74+
}
75+
76+
assert {
77+
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:4000?token=") == random_password.mux_auth_token.result
78+
error_message = "URL token must match the generated auth token"
3479
}
3580
}
3681

@@ -62,5 +107,3 @@ run "use_cached_only_success" {
62107
use_cached = true
63108
}
64109
}
65-
66-

‎registry/coder/modules/mux/run.sh‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ function run_mux() {
99
rm -f "$HOME/.mux/server.lock"
1010

1111
local port_value
12+
local auth_token_value
1213
port_value="${PORT}"
14+
auth_token_value="${AUTH_TOKEN}"
1315
if [ -z "$port_value" ]; then
1416
port_value="4000"
1517
fi
@@ -20,7 +22,7 @@ function run_mux() {
2022
fi
2123
echo "🚀 Starting mux server on port $port_value..."
2224
echo "Check logs at ${LOG_PATH}!"
23-
PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
25+
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
2426
}
2527

2628
# Check if mux is already installed for offline mode

0 commit comments

Comments
 (0)