Skip to content

Commit 900ebc9

Browse files
committed
fix: address R3 review feedback
- Drop `shell: true` from `runCliCommand`; pass args as an array via `getGlobalFlags` (raw). Eliminates Windows cmd.exe quoting concerns entirely (DEREM-17) and lets `--parameter` values pass through verbatim. - Close child stdin after spawn so unexpected interactive CLI prompts see EOF and error out instead of hanging (DEREM-18). - Guard the running case of the state machine against post-update builds that aren't running yet (DEREM-19). - Drop client-side regex validation (server validates with RE2, ReDoS-safe) to prevent extension-host freezes on hostile patterns (DEREM-22). - Expand `${env:VAR}` in `coder.globalFlags` so users can reference environment variables now that there's no outer shell expansion. - Document the new globalFlags expansion semantics in package.json.
1 parent ecac995 commit 900ebc9

14 files changed

Lines changed: 146 additions & 153 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@
160160
]
161161
},
162162
"coder.globalFlags": {
163-
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item; values are passed verbatim and in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nNote that for `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here. The `--global-config` and `--use-keyring` flags are silently ignored as the extension manages them via `#coder.useKeyring#`.",
163+
"markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item; values are passed verbatim and in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nValues are passed directly to the CLI without a shell, so `$VAR` and `%VAR%` are **not** expanded. Use `${env:VAR}` to reference environment variables (e.g. `--cfg=${env:HOME}/cfg`); missing variables resolve to an empty string.\n\nNote that for `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here. The `--global-config` and `--use-keyring` flags are silently ignored as the extension manages them via `#coder.useKeyring#`.",
164164
"type": "array",
165165
"items": {
166166
"type": "string"

src/api/updateParameters.ts

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import * as vscode from "vscode";
22

3-
import { escapeShellArg } from "../util";
4-
53
import type { Api } from "coder/site/src/api/api";
64
import type {
75
TemplateVersionParameter,
@@ -25,15 +23,15 @@ export async function collectUpdateParameters(
2523
restClient: Api,
2624
workspace: Workspace,
2725
): Promise<string[]> {
28-
const newParams = await restClient.getTemplateVersionRichParameters(
29-
workspace.template_active_version_id,
30-
);
26+
const [newParams, currentValues] = await Promise.all([
27+
restClient.getTemplateVersionRichParameters(
28+
workspace.template_active_version_id,
29+
),
30+
restClient.getWorkspaceBuildParameters(workspace.latest_build.id),
31+
]);
3132
const candidates = newParams.filter((p) => p.required && !p.default_value);
3233
if (candidates.length === 0) return [];
3334

34-
const currentValues = await restClient.getWorkspaceBuildParameters(
35-
workspace.latest_build.id,
36-
);
3735
const existing = new Set(currentValues.map((p) => p.name));
3836
const toPrompt = candidates.filter((p) => !existing.has(p.name));
3937

@@ -44,8 +42,7 @@ export async function collectUpdateParameters(
4442
if (value === undefined) {
4543
throw new WorkspaceUpdateCancelledError();
4644
}
47-
// Server-controlled values; block shell expansion under `shell: true`.
48-
args.push("--parameter", escapeShellArg(`${param.name}=${value}`));
45+
args.push("--parameter", `${param.name}=${value}`);
4946
}
5047
return args;
5148
}
@@ -178,20 +175,12 @@ function substituteTemplate(
178175
}
179176

180177
/**
181-
* Returns `{ ok, message }`; invalid RE2 regexes fall through to server-side
182-
* validation.
178+
* Returns `{ ok, message }`. Regex constraints are intentionally not tested
179+
* client-side; server validates with RE2 (linear-time, ReDoS-safe).
183180
*/
184181
function makeValidator(
185182
param: TemplateVersionParameter,
186183
): (input: string) => { ok: boolean; message?: string } {
187-
let re: RegExp | undefined;
188-
if (param.validation_regex) {
189-
try {
190-
re = new RegExp(param.validation_regex);
191-
} catch {
192-
re = undefined;
193-
}
194-
}
195184
return (input) => {
196185
if (!input) return { ok: !param.required };
197186
if (param.type === "number") {
@@ -216,14 +205,6 @@ function makeValidator(
216205
};
217206
}
218207
}
219-
if (re && !re.test(input)) {
220-
return {
221-
ok: false,
222-
message:
223-
substituteTemplate(param.validation_error, param, input) ||
224-
"Invalid format",
225-
};
226-
}
227208
return { ok: true };
228209
};
229210
}

src/api/workspace.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { spawn } from "node:child_process";
22
import * as vscode from "vscode";
33

4-
import { getGlobalShellFlags, type CliAuth } from "../settings/cli";
5-
import { escapeCommandArg } from "../util";
4+
import { getGlobalFlags, type CliAuth } from "../settings/cli";
65

76
import { errToStr, createWorkspaceIdentifier } from "./api-helper";
87
import { collectUpdateParameters } from "./updateParameters";
@@ -62,12 +61,13 @@ interface CliContext {
6261
function runCliCommand(ctx: CliContext, args: string[]): Promise<void> {
6362
return new Promise((resolve, reject) => {
6463
const fullArgs = [
65-
...getGlobalShellFlags(vscode.workspace.getConfiguration(), ctx.auth),
64+
...getGlobalFlags(vscode.workspace.getConfiguration(), ctx.auth),
6665
...args,
6766
createWorkspaceIdentifier(ctx.workspace),
6867
];
69-
const cmd = `${escapeCommandArg(ctx.binPath)} ${fullArgs.join(" ")}`;
70-
const proc = spawn(cmd, { shell: true });
68+
const proc = spawn(ctx.binPath, fullArgs);
69+
// Unexpected prompts EOF instead of hanging forever.
70+
proc.stdin.end();
7171

7272
proc.stdout.on("data", (data: Buffer) => {
7373
ctx.write(data.toString());

src/remote/remote.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ import { type LoginCoordinator } from "../login/loginCoordinator";
4141
import { OAuthSessionManager } from "../oauth/sessionManager";
4242
import {
4343
type CliAuth,
44-
getGlobalFlagsRaw,
4544
getGlobalShellFlags,
4645
getSshFlags,
46+
getUserGlobalFlags,
4747
resolveCliAuth,
4848
} from "../settings/cli";
4949
import { getHeaderCommand } from "../settings/headers";
@@ -436,7 +436,7 @@ export class Remote {
436436
setting: "coder.globalFlags",
437437
title: "Global Flags",
438438
getValue: () =>
439-
getGlobalFlagsRaw(vscode.workspace.getConfiguration()),
439+
getUserGlobalFlags(vscode.workspace.getConfiguration()),
440440
},
441441
{
442442
setting: "coder.headerCommand",

src/remote/workspaceStateMachine.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class WorkspaceStateMachine implements vscode.Disposable {
7878
workspace = updated;
7979
// Agent IDs may have changed after an update.
8080
this.agent = undefined;
81+
if (workspace.latest_build.status !== "running") return false;
8182
}
8283
break;
8384
}
@@ -106,8 +107,8 @@ export class WorkspaceStateMachine implements vscode.Disposable {
106107
workspace = updated;
107108
// Agent IDs may have changed after an update.
108109
this.agent = undefined;
109-
if (workspace.latest_build.status === "running") break;
110-
return false;
110+
if (workspace.latest_build.status !== "running") return false;
111+
break;
111112
}
112113
// Either we weren't in update mode, or the update failed: start.
113114
await this.triggerStart(workspace, workspaceName, progress);

src/settings/cli.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,21 @@ export type CliAuth =
1212
| { mode: "url"; url: string };
1313

1414
/**
15-
* Returns the raw global flags from user configuration.
15+
* Returns the user's `coder.globalFlags` with `${env:VAR}` references
16+
* substituted from `process.env`. Missing variables resolve to an empty
17+
* string, matching VS Code's behaviour for built-in `${env:VAR}` sites.
1618
*/
17-
export function getGlobalFlagsRaw(
19+
export function getUserGlobalFlags(
1820
configs: Pick<WorkspaceConfiguration, "get">,
1921
): string[] {
20-
return configs.get<string[]>("coder.globalFlags", []);
22+
return configs
23+
.get<string[]>("coder.globalFlags", [])
24+
.map((flag) =>
25+
flag.replace(
26+
/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,
27+
(_, name: string) => process.env[name] ?? "",
28+
),
29+
);
2130
}
2231

2332
/**
@@ -52,26 +61,25 @@ function buildGlobalFlags(
5261
? ["--url", esc(auth.url)]
5362
: ["--global-config", esc(auth.configDir)];
5463

55-
const raw = getGlobalFlagsRaw(configs);
56-
const filtered = stripManagedFlags(raw);
64+
const filtered = stripManagedFlags(getUserGlobalFlags(configs));
5765

5866
return [...filtered, ...authFlags, ...getHeaderArgs(configs)];
5967
}
6068

61-
function stripManagedFlags(rawFlags: string[]): string[] {
69+
function stripManagedFlags(flags: string[]): string[] {
6270
const filtered: string[] = [];
63-
for (let i = 0; i < rawFlags.length; i++) {
64-
if (isFlag(rawFlags[i], "--use-keyring")) {
71+
for (let i = 0; i < flags.length; i++) {
72+
if (isFlag(flags[i], "--use-keyring")) {
6573
continue;
6674
}
67-
if (isFlag(rawFlags[i], "--global-config")) {
75+
if (isFlag(flags[i], "--global-config")) {
6876
// Skip the next item too when the value is a separate entry.
69-
if (rawFlags[i] === "--global-config") {
77+
if (flags[i] === "--global-config") {
7078
i++;
7179
}
7280
continue;
7381
}
74-
filtered.push(rawFlags[i]);
82+
filtered.push(flags[i]);
7583
}
7684
return filtered;
7785
}

src/util.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ export function escapeCommandArg(arg: string): string {
212212
*/
213213
export function escapeShellArg(arg: string): string {
214214
if (os.platform() === "win32") {
215-
return escapeCommandArg(arg).replace(/%/g, "%%");
215+
const escaped = arg.replace(/"/g, '""').replace(/%/g, "%%");
216+
return `"${escaped}"`;
216217
}
217218
return `'${arg.replace(/'/g, "'\\''")}'`;
218219
}

test/unit/api/updateParameters.test.ts

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import {
88

99
import { workspace as createWorkspace } from "@repo/mocks";
1010

11-
import { shellQuote } from "../../utils/platform";
12-
1311
import type { Api } from "coder/site/src/api/api";
1412
import type { TemplateVersionParameter } from "coder/site/src/api/typesGenerated";
1513

@@ -145,14 +143,14 @@ describe("collectUpdateParameters", () => {
145143
param: { name: "environment" },
146144
mock: mockCreateInputBox,
147145
accept: { value: "dev" },
148-
expected: ["--parameter", shellQuote("environment=dev")],
146+
expected: ["--parameter", "environment=dev"],
149147
},
150148
{
151149
kind: "bool quick pick",
152150
param: { name: "enabled", type: "bool" },
153151
mock: mockCreateQuickPick,
154152
accept: { selectedItems: [{ value: "true" }] },
155-
expected: ["--parameter", shellQuote("enabled=true")],
153+
expected: ["--parameter", "enabled=true"],
156154
},
157155
{
158156
kind: "options quick pick",
@@ -165,7 +163,7 @@ describe("collectUpdateParameters", () => {
165163
},
166164
mock: mockCreateQuickPick,
167165
accept: { selectedItems: [{ value: "l" }] },
168-
expected: ["--parameter", shellQuote("size=l")],
166+
expected: ["--parameter", "size=l"],
169167
},
170168
{
171169
kind: "multi-select quick pick (JSON array)",
@@ -179,7 +177,7 @@ describe("collectUpdateParameters", () => {
179177
},
180178
mock: mockCreateQuickPick,
181179
accept: { selectedItems: [{ value: "us" }, { value: "eu" }] },
182-
expected: ["--parameter", shellQuote('regions=["us","eu"]')],
180+
expected: ["--parameter", 'regions=["us","eu"]'],
183181
},
184182
])(
185183
"collects the value via $kind",
@@ -195,18 +193,15 @@ describe("collectUpdateParameters", () => {
195193
},
196194
);
197195

198-
it("escapes shell metacharacters in server-controlled values", async () => {
196+
it("passes server-controlled values through verbatim (no shell expansion path)", async () => {
199197
const { restClient, workspace } = createCollectCtx([{ name: "evil" }]);
200198
const qi = mockCreateInputBox();
201199

202200
const result = collectUpdateParameters(restClient, workspace);
203201
await waitShown(qi);
204202
qi.accept({ value: "$(rm -rf /)" });
205203

206-
await expect(result).resolves.toEqual([
207-
"--parameter",
208-
shellQuote("evil=$(rm -rf /)"),
209-
]);
204+
await expect(result).resolves.toEqual(["--parameter", "evil=$(rm -rf /)"]);
210205
});
211206

212207
it("skips parameters that already have a value or default", async () => {
@@ -312,21 +307,6 @@ describe("parameter prompt validation", () => {
312307
input: "abc",
313308
expected: "Must be a number",
314309
},
315-
{
316-
kind: "regex mismatch with default message",
317-
param: { validation_regex: "^x" },
318-
input: "y",
319-
expected: "Invalid format",
320-
},
321-
{
322-
kind: "regex mismatch with {value} substitution",
323-
param: {
324-
validation_regex: "^x",
325-
validation_error: "Value {value} is not allowed",
326-
},
327-
input: "y",
328-
expected: "Value y is not allowed",
329-
},
330310
{
331311
kind: "number out-of-range with {min}/{max} substitution",
332312
param: {
@@ -345,6 +325,13 @@ describe("parameter prompt validation", () => {
345325
});
346326
});
347327

328+
it("does not evaluate validation_regex client-side (ReDoS guard)", async () => {
329+
await withInputBox({ name: "r", validation_regex: "^(a+)+$" }, (qi) => {
330+
qi.change("aaaaaaaaaaaaaaaaaaaaaaaaaaa!");
331+
expect(qi.mock.validationMessage).toBe("");
332+
});
333+
});
334+
348335
it("treats JSON null on validation_min/max as unset", async () => {
349336
await withInputBox(
350337
{

test/unit/api/workspace.test.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,11 @@ function controlSpawn() {
106106
const proc = new EventEmitter() as EventEmitter & {
107107
stdout: EventEmitter;
108108
stderr: EventEmitter;
109+
stdin: { end: ReturnType<typeof vi.fn> };
109110
};
110111
proc.stdout = new EventEmitter();
111112
proc.stderr = new EventEmitter();
113+
proc.stdin = { end: vi.fn() };
112114
const { promise: spawned, resolve: resolveSpawned } =
113115
Promise.withResolvers<void>();
114116
vi.mocked(spawn).mockImplementation(() => {
@@ -191,6 +193,23 @@ describe("updateWorkspace", () => {
191193
vi.clearAllMocks();
192194
});
193195

196+
it("runs coder update and resolves with the refreshed workspace", async () => {
197+
const { ctx, restClient, finalWorkspace } = createUpdateCtx();
198+
const sp = controlSpawn();
199+
200+
const result = updateWorkspace(ctx);
201+
await sp.close(0);
202+
203+
await expect(result).resolves.toBe(finalWorkspace);
204+
expect(spawn).toHaveBeenCalledWith("/usr/bin/coder", [
205+
"--url",
206+
"https://test.coder.com",
207+
"update",
208+
"testuser/test-workspace",
209+
]);
210+
expect(restClient.getWorkspace).toHaveBeenCalledWith(ctx.workspace.id);
211+
});
212+
194213
it("rejects when the process exits non-zero", async () => {
195214
const { ctx, restClient } = createUpdateCtx();
196215
const sp = controlSpawn();
@@ -264,10 +283,15 @@ describe("startWorkspace", () => {
264283
await sp.close(0);
265284

266285
await expect(result).resolves.toBe(finalWorkspace);
267-
expect(spawn).toHaveBeenCalledWith(
268-
'"/usr/bin/coder" --url "https://test.coder.com" start --yes --reason vscode_connection testuser/test-workspace',
269-
expect.objectContaining({ shell: true }),
270-
);
286+
expect(spawn).toHaveBeenCalledWith("/usr/bin/coder", [
287+
"--url",
288+
"https://test.coder.com",
289+
"start",
290+
"--yes",
291+
"--reason",
292+
"vscode_connection",
293+
"testuser/test-workspace",
294+
]);
271295
expect(restClient.getWorkspace).toHaveBeenCalledWith(ctx.workspace.id);
272296
});
273297

@@ -289,9 +313,12 @@ describe("startWorkspace", () => {
289313
await sp.close(0);
290314
await result;
291315

292-
expect(spawn).toHaveBeenCalledWith(
293-
'"/usr/bin/coder" --url "https://test.coder.com" start --yes testuser/test-workspace',
294-
expect.objectContaining({ shell: true }),
295-
);
316+
expect(spawn).toHaveBeenCalledWith("/usr/bin/coder", [
317+
"--url",
318+
"https://test.coder.com",
319+
"start",
320+
"--yes",
321+
"testuser/test-workspace",
322+
]);
296323
});
297324
});

0 commit comments

Comments
 (0)