Skip to content

Commit b738dbe

Browse files
authored
Merge commit from fork
* security: keep server-controlled content out of local command execution Workspace, owner, and agent names, app commands, DERP region names, and deployment URLs all come from the deployment. None of them may become local command execution or trusted UI. - Add `src/common/validation.ts`: an identifier grammar (a superset of coderd's user, workspace, and agent names that excludes `.` and `--`, the remote-authority separators), a safe-hostname check, and a deployment URL check that rejects shell metacharacters and `%`. - Validate identifiers when building and parsing remote authorities and when composing the `owner/name` workspace identifier, which also closes the `%h` expansion, Host-pattern, and `path.join` traversal paths. - Run workspace app commands over the SSH exec channel as argv (`coder ssh [--tty] <workspace> -- <command>` through `shellPath` and `shellArgs`) instead of typing a composed line into a local terminal after a fixed delay. CLIs before 2.23.0 cannot take a command, so they fail with a clear error rather than falling back. `--tty` is passed from 2.34.0; earlier CLIs always allocate a PTY in command mode. - Pass workspace operands after a `--` end-of-options separator in start, update, speedtest, support bundle, and ping, and document the CLI execution rules in AGENTS.md. - Quote the `--url` and `--global-config` values in SSH `ProxyCommand` with `escapeShellArg`, and reject deployment URLs carrying shell metacharacters or `%` (an OpenSSH ProxyCommand token) before they are embedded. The URL check also covers an `sh`-based ssh on Windows, where the double-quoted form still expands. The user's own `coder.globalFlags` keep their existing quoting so shell expansion in them still works. - Keep a known deployment's stored URL when a deep link names the same origin, so a link cannot rewrite it without a prompt. - Append the DERP region name to the network tooltip with `MarkdownString.appendText`, and limit the tooltip's markdown trust to the commands it links, instead of marking the whole string trusted. Fixes SEC-1033, coder/security-assessments#28, coder/security-assessments#27, coder/security-assessments#35, coder/security-assessments#79, coder/security-assessments#42. * security: name the invalid authority field and clarify host checks Address review feedback: - Throw per-field errors when parsing a remote authority instead of one generic message, naming the invalid hostname, username, workspace, or agent. - Rename the safe-hostname helpers to the ShellSafe* family and add validateShellSafeHostname, mirroring validateShellSafeUrl. - Validate the deployment host in toRemoteAuthority, and explain why quoting alone cannot neutralize the ProxyCommand URL. * security: reject unsafe deployment URLs before sign-in Validate the URL in maybeAskUrl, which every login prompt, auto-login, and vscode:// link passes through, so a bad URL fails before authentication instead of at the SSH ProxyCommand. The ProxyCommand check stays for URLs that reach storage another way. Normalize the URL with `new URL` first so international domain names are stored in their ASCII form and pass the check. Explain why the hostname check is stricter than the URL check. * fix(security): separate hostname safety and report validation failures safely Validate filesystem-safe deployment hostnames separately from SSH safety, applying Windows-specific restrictions only on Windows. Keep rejected values out of notifications, provide Coder Output diagnostics, preserve supported legacy agent names, and retain credentials when validation prevents migration. * fix(security): ignore unsafe stored URLs and reject URL credentials Stored session, current deployment, and URL history entries whose URL fails the shell-safe check are now ignored. A URL planted by an older version prompts a fresh sign-in instead of failing every connection, and a correct link can replace it. Sign-in also rejects a username, password, query, or fragment in the deployment URL. Credentials would be written to the SSH config in plain text, and a query or fragment breaks the API paths built on the URL. * fix(security): apply one deployment URL check at sign-in and storage reads Replace the separate credential, query, and shell-character checks with `validateDeploymentUrl` and `isValidDeploymentUrl`, so sign-in, the SSH ProxyCommand, and stored session, deployment, and URL history reads all apply the same rules. Stored URLs with credentials, a query, or a fragment are now ignored instead of used. When a stored session URL can't be parsed, the hostname mismatch error no longer includes the URL, which can contain credentials and is logged. Share one list of invalid URLs across the tests for each layer, and check that auth values and the header command stay literal in the ProxyCommand on each platform. Addresses SEC-1120 and SEC-1121.
1 parent 53dd0ed commit b738dbe

39 files changed

Lines changed: 1203 additions & 114 deletions

‎AGENTS.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ before handoff run typecheck, lint, format check and the affected tests.
6161
- Add a `CHANGELOG.md` entry for changes users should know about.
6262
- Never disable a lint rule without user approval.
6363

64+
## Running the Coder CLI
65+
66+
Workspace, owner, and agent names, app commands, and URLs come from the
67+
deployment. Treat them as untrusted:
68+
69+
- Run the CLI with `execFile`/`spawn` argv (or a terminal's `shellPath` and
70+
`shellArgs`), never through a shell or `terminal.sendText`.
71+
- Put flags first, then `--`, then deployment-provided operands
72+
(`coder ping -- owner/ws`) so a value like `--foo` is never read as a flag.
73+
- Validate names with `src/common/validation.ts` before they reach SSH hosts,
74+
paths, or the CLI.
75+
- In SSH `ProxyCommand` (the one shell context), build flags with
76+
`getGlobalShellFlags`, which validates the URL and quotes it with
77+
`escapeShellArg`.
78+
6479
## Code style
6580

6681
- Strict TypeScript: no `any`, `as unknown as`, or non-null assertions

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,29 @@
2424
in with OAuth are asked to sign in again once.
2525
- Revoke OAuth tokens on logout even when their scopes are outdated.
2626

27+
### Fixed
28+
29+
- Run workspace app commands as arguments to `coder ssh` instead of typing them
30+
into a local terminal. This requires Coder CLI v2.23.0 or later.
31+
- Validate workspace, owner, and agent names from the deployment before using
32+
them in SSH hosts and paths, preserving legacy agents with underscores and
33+
leading or trailing hyphens.
34+
- Reject deployment hostnames unsafe for directory names, with Windows-specific
35+
restrictions applied only on Windows.
36+
- Show validation failures without rendering deployment-provided values in
37+
notifications; diagnostic details are available in Coder Output.
38+
- Show the network tooltip's relay name as plain text instead of rendering
39+
deployment-provided markdown.
40+
- Reject deployment URLs that contain shell metacharacters or `%` when building
41+
SSH commands. If an earlier version stored such a URL, or a URL with a
42+
non-ASCII hostname, you are asked to sign in again, and the URL is then
43+
stored in its ASCII form.
44+
- Reject deployment URLs that include a username, password, query, or fragment
45+
when you sign in. If a proxy in front of your deployment needs credentials,
46+
send them with `coder.headerCommand`.
47+
- Keep the stored URL of a deployment you are signed in to when a link names
48+
the same origin with a different path.
49+
2750
## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23
2851

2952
### Added

‎src/api/models.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ import {
66
type WorkspaceStatus,
77
} from "coder/site/src/api/typesGenerated";
88

9+
import { validateIdentifier } from "../common/validation";
10+
911
/** True when the user holds the deployment-wide owner role. */
1012
export function isOwner(user: User | undefined): boolean {
1113
return user?.roles.some((role) => role.name === "owner") ?? false;
1214
}
1315

14-
/**
15-
* Create workspace owner/name identifier
16-
*/
16+
/** Create the validated `owner/name` identifier used in CLI argv and URLs. */
1717
export function createWorkspaceIdentifier(workspace: Workspace): string {
18-
return `${workspace.owner_name}/${workspace.name}`;
18+
const owner = validateIdentifier(workspace.owner_name, "workspace owner");
19+
const name = validateIdentifier(workspace.name, "workspace name");
20+
return `${owner}/${name}`;
1921
}
2022

2123
const WORKSPACE_STATUS_LABEL: Readonly<Record<WorkspaceStatus, string>> = {

‎src/api/workspace.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ function runCliCommand(ctx: CliContext, args: string[]): Promise<void> {
7878
const fullArgs = [
7979
...getGlobalFlags(vscode.workspace.getConfiguration(), ctx.auth),
8080
...args,
81+
"--",
8182
createWorkspaceIdentifier(ctx.workspace),
8283
];
8384
const proc = spawn(ctx.binPath, fullArgs);

‎src/cli/cliExec.ts‎

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,11 @@ import { stat } from "node:fs/promises";
33
import { promisify } from "node:util";
44
import * as vscode from "vscode";
55

6-
import { escapeCommandArg } from "../common/strings";
6+
import { validateIdentifier } from "../common/validation";
77
import { isAbortError } from "../error/abort";
88
import { toError } from "../error/normalize";
9-
import {
10-
type CliAuth,
11-
getGlobalFlags,
12-
getGlobalShellFlags,
13-
} from "../settings/cli";
9+
import { type CliFeatureSet } from "../featureSet";
10+
import { type CliAuth, getGlobalFlags } from "../settings/cli";
1411

1512
/** Resolved CLI environment for invoking the coder binary. */
1613
export interface CliEnv {
@@ -83,10 +80,11 @@ export async function speedtest(
8380
signal?: AbortSignal,
8481
): Promise<string> {
8582
const globalFlags = getGlobalFlags(env.configs, env.auth);
86-
const args = [...globalFlags, "speedtest", workspaceName, "--output", "json"];
83+
const args = [...globalFlags, "speedtest", "--output", "json"];
8784
if (duration) {
8885
args.push("-t", duration);
8986
}
87+
args.push("--", workspaceName);
9088
try {
9189
const result = await execFileAsync(env.binary, args, { signal });
9290
return result.stdout;
@@ -134,12 +132,13 @@ export async function supportBundle(
134132
...globalFlags,
135133
"support",
136134
"bundle",
137-
workspaceName,
138-
...(agentName ? [agentName] : []),
139135
"--output-file",
140136
outputPath,
141137
"--yes",
142138
...workspaceFiles.flatMap((file) => ["--workspace-file", file]),
139+
"--",
140+
workspaceName,
141+
...(agentName ? [agentName] : []),
143142
];
144143
try {
145144
await execFileAsync(env.binary, args, { signal });
@@ -156,30 +155,38 @@ export function ping(env: CliEnv, workspaceName: string): vscode.Terminal {
156155
return spawnCliInTerminal({
157156
name: `Coder Ping: ${workspaceName}`,
158157
binary: env.binary,
159-
args: [...globalFlags, "ping", workspaceName],
158+
args: [...globalFlags, "ping", "--", workspaceName],
160159
banner: ["Press Ctrl+C (^C) to stop.", "─".repeat(40)],
161160
});
162161
}
163162

164163
/**
165-
* SSH into a workspace and run a command via `terminal.sendText`.
164+
* Open a terminal that runs `coder ssh <workspace> -- <command>` with no
165+
* local shell in between. Older CLIs cannot take a command, and falling back
166+
* to typing it into a local shell would let the deployment run local code.
166167
*/
167-
export async function openAppStatusTerminal(
168-
env: CliEnv,
169-
app: {
170-
name?: string;
171-
command?: string;
172-
workspace_name: string;
173-
},
174-
): Promise<void> {
175-
const globalFlags = getGlobalShellFlags(env.configs, env.auth);
176-
const terminal = vscode.window.createTerminal({ name: app.name });
177-
terminal.sendText(
178-
`${escapeCommandArg(env.binary)} ${globalFlags.join(" ")} ssh ${escapeCommandArg(app.workspace_name)}`,
168+
export function openAppStatusTerminal(
169+
env: CliEnv & { featureSet: CliFeatureSet },
170+
app: { name?: string; command: string; workspace_name: string },
171+
): void {
172+
if (!env.featureSet.sshCommand) {
173+
throw new Error("Running app commands requires Coder CLI v2.23.0 or later");
174+
}
175+
const workspaceName = validateIdentifier(
176+
app.workspace_name,
177+
"workspace name",
179178
);
180-
await new Promise((resolve) => setTimeout(resolve, 5000));
181-
terminal.sendText(app.command ?? "");
182-
terminal.show(false);
179+
const args = [
180+
...getGlobalFlags(env.configs, env.auth),
181+
"ssh",
182+
...(env.featureSet.sshTty ? ["--tty"] : []),
183+
workspaceName,
184+
"--",
185+
app.command,
186+
];
187+
vscode.window
188+
.createTerminal({ name: app.name, shellPath: env.binary, shellArgs: args })
189+
.show(false);
183190
}
184191

185192
const execFileAsync = promisify(execFile);

‎src/commands.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,15 +1105,16 @@ export class Commands {
11051105
workspace_name: string;
11061106
}): Promise<void> {
11071107
// Launch and run command in terminal if command is provided
1108-
if (app.command) {
1108+
const command = app.command;
1109+
if (command) {
11091110
return withProgress(
11101111
{
11111112
location: vscode.ProgressLocation.Notification,
1112-
title: `Connecting to AI Agent...`,
1113+
title: `Preparing Coder CLI...`,
11131114
},
11141115
async () => {
11151116
const env = await this.resolveCliEnv(this.extensionClient);
1116-
await cliExec.openAppStatusTerminal(env, app);
1117+
cliExec.openAppStatusTerminal(env, { ...app, command });
11171118
},
11181119
);
11191120
}

‎src/common/strings.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export function escapeCommandArg(arg: string): string {
2828
/**
2929
* Cross-platform shell quoting that blocks variable expansion. Use for
3030
* values from outside the user's local settings (e.g. server-controlled).
31+
* The win32 branch targets Windows OpenSSH, which runs `ProxyCommand`
32+
* without a shell and turns `%%` back into `%`. A shell such as cmd.exe or
33+
* Git Bash's `sh` still expands inside those quotes.
3134
*/
3235
export function escapeShellArg(arg: string): string {
3336
if (os.platform() === "win32") {

‎src/common/url.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import url from "node:url";
22
import * as vscode from "vscode";
33

4+
import { parseDeploymentUrl, validateFilenameHostname } from "./validation";
5+
46
/**
57
* Given a URL, return the host in a format that is safe to write.
68
*/
79
export function toSafeHost(rawUrl: string): string {
8-
const u = new URL(rawUrl);
10+
const u = parseDeploymentUrl(rawUrl);
911
// If the host is invalid, an empty string is returned. Although, `new URL`
1012
// should already have thrown in that case.
11-
return url.domainToASCII(u.hostname) || u.hostname;
13+
return validateFilenameHostname(url.domainToASCII(u.hostname) || u.hostname);
1214
}
1315

1416
export function removeTrailingSlashes(value: string): string {

‎src/common/validation.ts‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { isIP } from "node:net";
2+
import os from "node:os";
3+
4+
const OMITTED_URL =
5+
"[URL omitted to protect credentials and other sensitive parameters]";
6+
7+
/** A safe display message with a bounded diagnostic value for Coder Output. */
8+
export class InputValidationError extends Error {
9+
readonly diagnosticValue: string;
10+
11+
constructor(message: string, value: string) {
12+
super(message);
13+
this.name = "InputValidationError";
14+
this.diagnosticValue = JSON.stringify(value.slice(0, 512));
15+
}
16+
}
17+
18+
export function parseDeploymentUrl(value: string): URL {
19+
try {
20+
return new URL(value);
21+
} catch {
22+
throw new InputValidationError(
23+
"Invalid URL: the deployment URL is not a valid address.",
24+
OMITTED_URL,
25+
);
26+
}
27+
}
28+
29+
/** User and workspace names, excluding the remote-authority separators. */
30+
export function isValidIdentifier(value: string): boolean {
31+
return /^\w+(?:-\w+)*$/.test(value);
32+
}
33+
34+
export function validateIdentifier(
35+
value: string,
36+
kind: "username" | "workspace owner" | "workspace name",
37+
): string {
38+
if (!isValidIdentifier(value)) {
39+
throw new InputValidationError(
40+
`Cannot use this ${kind} in an SSH connection: it contains unsupported characters or separators.`,
41+
value,
42+
);
43+
}
44+
return value;
45+
}
46+
47+
/** Legacy agent names may have leading/trailing hyphens and underscores. */
48+
export function validateAgentName(value: string): string {
49+
if (!/^[\w-]+$/.test(value) || value.includes("--")) {
50+
throw new InputValidationError(
51+
"Cannot use this agent name in an SSH connection: it contains unsupported characters or separators.",
52+
value,
53+
);
54+
}
55+
return value;
56+
}
57+
58+
/** Returns the hostname unchanged when it is safe as a filesystem component. */
59+
export function validateFilenameHostname(value: string): string {
60+
if (
61+
!value ||
62+
/^\.+$/.test(value) ||
63+
/[/\\]/.test(value) ||
64+
/\p{Cc}/u.test(value)
65+
) {
66+
throw new InputValidationError(
67+
"Cannot use this deployment hostname for local storage: it is empty or contains unsafe path characters.",
68+
value,
69+
);
70+
}
71+
if (
72+
os.platform() === "win32" &&
73+
(/[<>:"|?*]/.test(value) ||
74+
/[. ]$/.test(value) ||
75+
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(value))
76+
) {
77+
throw new InputValidationError(
78+
"Cannot connect on Windows: this deployment hostname cannot be used as a local directory name.",
79+
value,
80+
);
81+
}
82+
return value;
83+
}
84+
85+
/** Shell-safe, literal SSH host characters; IP structure uses Node's parser. */
86+
export function isShellSafeHostname(value: string): boolean {
87+
return (
88+
/^[\w.-]+$/.test(value) ||
89+
(value.startsWith("[") &&
90+
value.endsWith("]") &&
91+
!value.includes("%") &&
92+
isIP(value.slice(1, -1)) === 6)
93+
);
94+
}
95+
96+
export function validateShellSafeHostname(value: string): string {
97+
if (!isShellSafeHostname(value)) {
98+
throw new InputValidationError(
99+
"Cannot use this deployment hostname in an SSH connection: it contains shell metacharacters or wildcard patterns.",
100+
value,
101+
);
102+
}
103+
return value;
104+
}
105+
106+
/**
107+
* Validates a deployment URL for sign-in, storage reads, and the SSH
108+
* `ProxyCommand`, and returns it unchanged. Rejects credentials, which the SSH
109+
* config would store in plain text; query parameters and fragments, which
110+
* break API paths built on the URL; and characters that are not literal
111+
* inside shell quotes, including OpenSSH's `%` tokens.
112+
*/
113+
export function validateDeploymentUrl(value: string): string {
114+
const url = parseDeploymentUrl(value);
115+
if (url.username || url.password || /[?#]/.test(url.href)) {
116+
throw new InputValidationError(
117+
"Invalid URL: the deployment URL cannot include a username, password, query, or fragment.",
118+
OMITTED_URL,
119+
);
120+
}
121+
if (!/^[\w\-.~:/[\]@!*+,=]+$/.test(value)) {
122+
throw new InputValidationError(
123+
"Invalid URL: the deployment URL cannot be safely used in an SSH connection because it contains unsupported or percent-encoded characters.",
124+
OMITTED_URL,
125+
);
126+
}
127+
return value;
128+
}
129+
130+
export function isValidDeploymentUrl(value: string): boolean {
131+
try {
132+
validateDeploymentUrl(value);
133+
return true;
134+
} catch {
135+
return false;
136+
}
137+
}

‎src/container.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ export class ServiceContainer implements vscode.Disposable {
147147
this.logger,
148148
);
149149

150-
this.commandManager = new CommandManager(this.telemetryService);
150+
this.commandManager = new CommandManager(
151+
this.telemetryService,
152+
this.logger,
153+
);
151154
}
152155

153156
getPathResolver(): PathResolver {

0 commit comments

Comments
 (0)