Skip to content

Commit f096fe1

Browse files
authored
fix(ssh): repair managed Windows SSH config permissions (#1110)
Windows files inherit permissions from their parent directory, so a Coder-generated SSH config file could end up readable by other accounts. OpenSSH rejects files like that with "Bad owner or permissions" and aborts the whole config, which blocks every Coder host, not just one. This PR locks down the managed SSH directory before each write so files inherit clean permissions, using only built-in Windows tools with no elevation required. - Repair permissions on the managed SSH directory and its config files on Windows, fixing existing bad fragments on upgrade - Handle a race between two editor windows sharing the same SSH directory - Add Windows ARM64 test coverage in CI - Update CI caching to avoid unnecessary cache misses Fixes #1108
1 parent ab45bb9 commit f096fe1

15 files changed

Lines changed: 911 additions & 20 deletions

‎.gitattributes‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
* text=auto eol=lf
2+
pnpm-lock.yaml linguist-generated=true
3+
flake.lock linguist-generated=true

‎.github/actions/setup/action.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ runs:
1010
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
1111

1212
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
13+
with:
14+
# Caches the pnpm store, keyed by lockfile hash with a prefix fallback,
15+
# so a dependency bump reuses the rest of the store. setup-node's own
16+
# pnpm cache restores on an exact hash only.
17+
cache: true
1318

1419
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
1520
with:
1621
node-version: "22"
17-
cache: "pnpm"
1822

1923
- name: Install dependencies
2024
shell: bash

‎.github/workflows/ci.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ jobs:
4848
name: Windows,
4949
electron-version: "latest",
5050
}
51+
- {
52+
os: windows-11-arm,
53+
name: Windows ARM64,
54+
electron-version: "latest",
55+
}
5156
- { os: macos-15, name: macOS, electron-version: "latest" }
5257

5358
steps:
@@ -62,6 +67,7 @@ jobs:
6267
shell: bash
6368
env:
6469
CI: true
70+
EXPECTED_ARCH: ${{ runner.arch }}
6571

6672
test-integration:
6773
name: Integration Test (${{ matrix.name }}, VS Code ${{ matrix.vscode-version }})

‎.vscodeignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@ AGENTS.md
6262
# Storybook
6363
.storybook/**
6464
storybook-static/**
65-
**/*.stories.*
65+
**/*.stories.*

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
replay the buffered connection logs instead. Close codes never reached the
2222
reconnect logic, so these closes retried forever. Server-initiated normal
2323
closes (`1000`/`1001`) keep reconnecting.
24+
- Repair permissions on the Windows SSH config files the extension generates,
25+
so connections stop failing with "Bad owner or permissions". Only you,
26+
SYSTEM, and Administrators keep access to them. The extension leaves your own
27+
SSH config untouched and needs no admin rights.
2428

2529
## [v1.16.3](https://github.com/coder/vscode-coder/releases/tag/v1.16.3) 2026-09-14
2630

‎CONTRIBUTING.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,41 @@ command.
8787
Coder Remote periodically reads the `network-info-dir + "/" + matchingSSHPID`
8888
file to display network information.
8989

90+
### Windows SSH config permissions
91+
92+
Windows files inherit their permissions from the directory they live in, so a
93+
config the extension generates under `%APPDATA%\coder.coder-remote\ssh` can end
94+
up readable by other accounts. OpenSSH rejects such a file with "Bad owner or
95+
permissions" and skips the whole `Include`, which blocks every Coder host, not
96+
just the one it came from.
97+
98+
Before each managed write, `src/remote/windowsAcl.ts` locks the directory down
99+
and lets its files inherit from it:
100+
101+
| Step | Command |
102+
| -------------------------------------------------------- | ---------------------------------------------------- |
103+
| Read the current user's SID | `whoami.exe /user /fo csv /nh` |
104+
| Clear the directory's own grants | `icacls.exe <dir> /reset` |
105+
| Grant that user, SYSTEM, and Administrators full control | `icacls.exe <dir> /inheritance:r /grant:r <trustee>` |
106+
| Clear each `*.conf` file so it inherits the directory | `icacls.exe <file> /reset` |
107+
108+
Resetting every `*.conf` file, not only the one being written, also repairs
109+
files left behind by other deployments and editors.
110+
111+
Worth knowing:
112+
113+
- Like VS Code, the code checks exit codes but never reads ACLs back. It needs
114+
no script, native module, ownership change, or elevation, and it leaves the
115+
user's own SSH config alone.
116+
- Links and non-files are rejected before the repair, because inheritable
117+
grants reach children even without `/T`. That stops mistakes, not an attacker
118+
racing the check.
119+
- The repair is not atomic: a failure after `/reset` can leave the directory
120+
with its parent's grants.
121+
122+
`windowsAcl.native.test.ts` drives the real `icacls.exe`, `whoami.exe`, and
123+
OpenSSH. Run it unelevated as well as in CI to catch privilege assumptions.
124+
90125
## Other features
91126

92127
The extension provides several sidebar panels:
@@ -289,7 +324,8 @@ When updating the minimum Node.js version, update these files:
289324

290325
Some dependencies are not directly used in the source but are required anyway.
291326

292-
- `bufferutil` and `utf-8-validate` are peer dependencies of `ws`.
327+
- `bufferutil` and `utf-8-validate` are peer dependencies of `ws`. Their source
328+
builds are off, so Windows on ARM64 uses their JavaScript fallback.
293329
- `ua-parser-js` and `dayjs` are used by the Coder API client.
294330

295331
The coder client is vendored from coder/coder. Pin it to a release tag in

‎pnpm-workspace.yaml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@ dedupePeers: true
3232

3333
allowBuilds:
3434
"@vscode/vsce-sign": true
35-
bufferutil: true
35+
# Only win32-arm64 lacks a prebuild, and its JavaScript fallback is fine.
36+
bufferutil: false
3637
electron: true
3738
esbuild: true
3839
keytar: false
3940
odiff-bin: true
4041
unrs-resolver: true
41-
utf-8-validate: true
42+
utf-8-validate: false
4243

4344
overrides:
4445
"@vscode-elements/elements": ^2.5.1

‎src/remote/remote.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
sshSupportsSetEnv,
6868
type SshProperties,
6969
} from "./sshSupport";
70+
import { createManagedPermissions } from "./windowsAcl";
7071
import { WorkspaceStateMachine } from "./workspaceStateMachine";
7172

7273
import type { Api } from "coder/site/src/api/api";
@@ -929,6 +930,8 @@ export class Remote {
929930
const coderConfig = new SshConfig(
930931
this.pathResolver.getSshConfigPath(safeHostname, hostEditorId(sshHost)),
931932
this.logger,
933+
undefined,
934+
createManagedPermissions(),
932935
);
933936

934937
// Options the user set themselves win the merge below, so they are exempt

‎src/remote/sshConfig.ts‎

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
mkdir,
33
readFile,
4+
readdir,
45
rename,
56
stat,
67
unlink,
@@ -34,10 +35,20 @@ export interface SshValues {
3435
SetEnv?: string;
3536
}
3637

38+
/**
39+
* Restricts the Coder-managed config directory and the files it generates.
40+
* A config without one is not Coder-managed, so it is written untouched.
41+
*/
42+
export interface ManagedPermissions {
43+
prepareDirectory(directory: string): Promise<void>;
44+
secure(filePath: string): Promise<void>;
45+
}
46+
3747
/** Injectable for tests. */
3848
export interface FileSystem {
3949
mkdir: typeof mkdir;
4050
readFile: typeof readFile;
51+
readdir: typeof readdir;
4152
rename: typeof rename;
4253
stat: typeof stat;
4354
unlink: typeof unlink;
@@ -47,6 +58,7 @@ export interface FileSystem {
4758
const defaultFileSystem: FileSystem = {
4859
mkdir,
4960
readFile,
61+
readdir,
5062
rename,
5163
stat,
5264
unlink,
@@ -299,15 +311,19 @@ export class SshConfig {
299311
private readonly fileSystem: FileSystem;
300312
private readonly logger: Logger;
301313
private raw: string | undefined;
314+
/** Marks this file as Coder-managed; absent for the user's own config. */
315+
private readonly permissions: ManagedPermissions | undefined;
302316

303317
constructor(
304318
filePath: string,
305319
logger: Logger,
306320
fileSystem: FileSystem = defaultFileSystem,
321+
permissions?: ManagedPermissions,
307322
) {
308323
this.filePath = filePath;
309324
this.logger = logger;
310325
this.fileSystem = fileSystem;
326+
this.permissions = permissions;
311327
}
312328

313329
async load() {
@@ -442,39 +458,99 @@ export class SshConfig {
442458

443459
/** Atomically write raw via a temp file. */
444460
private async save(): Promise<void> {
445-
// Preserve the existing file mode.
446-
const existingMode = await this.fileSystem
447-
.stat(this.filePath)
448-
.then((stat) => stat.mode)
449-
.catch((ex: NodeJS.ErrnoException) => {
450-
if (ex.code === "ENOENT") {
451-
return 0o600;
452-
}
453-
throw ex;
454-
});
455-
await this.fileSystem.mkdir(path.dirname(this.filePath), {
461+
const existingMode = await this.getFileMode();
462+
const fileName = path.basename(this.filePath);
463+
const dirName = path.dirname(this.filePath);
464+
await this.fileSystem.mkdir(dirName, {
456465
mode: 0o700,
457466
recursive: true,
458467
});
459-
const fileName = path.basename(this.filePath);
460-
const dirName = path.dirname(this.filePath);
468+
// Must come before any file reset or temporary write in this directory.
469+
await this.permissions?.prepareDirectory(dirName);
470+
await this.repairIncludedFiles(dirName);
461471
const tempPath = tempFilePath(
462472
`${dirName}/.${fileName}`,
463473
"vscode-coder-tmp",
464474
);
475+
await this.writeTemp(tempPath, existingMode);
476+
await this.repairPermissions(tempPath);
477+
await this.replaceWithTemp(tempPath);
478+
}
479+
480+
/** Preserve the existing file mode, defaulting to owner-only access. */
481+
private async getFileMode(): Promise<number> {
482+
try {
483+
return (await this.fileSystem.stat(this.filePath)).mode;
484+
} catch (error) {
485+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
486+
return 0o600;
487+
}
488+
throw error;
489+
}
490+
}
491+
492+
/** Repair every direct Include match; one unsafe sibling blocks every host. */
493+
private async repairIncludedFiles(dirName: string): Promise<void> {
494+
if (!this.permissions) return;
495+
const entries = await this.fileSystem
496+
.readdir(dirName, { withFileTypes: true })
497+
.catch((error: unknown) => {
498+
this.logger.warn(
499+
"Failed to enumerate Coder-managed SSH config files",
500+
error,
501+
);
502+
return [];
503+
});
504+
for (const entry of entries) {
505+
if (!entry.name.toLowerCase().endsWith(SSH_CONFIG_EXT)) continue;
506+
const filePath = path.join(dirName, entry.name);
507+
// On Windows, fopen fails on a directory, so OpenSSH aborts the whole
508+
// Include. No ACL change fixes that, so report it instead.
509+
if (!entry.isFile()) {
510+
throw new Error(
511+
`SSH config entry ${filePath} is not a regular file. Move or rename it so it no longer matches *.conf, then reconnect.`,
512+
);
513+
}
514+
await this.repairPermissions(filePath);
515+
}
516+
}
517+
518+
/** Create the temporary file exclusively, leaving any preexisting path alone. */
519+
private async writeTemp(tempPath: string, mode: number): Promise<void> {
465520
try {
466521
await this.fileSystem.writeFile(tempPath, this.getRaw(), {
467-
mode: existingMode,
468522
encoding: "utf-8",
523+
flag: "wx",
524+
mode,
469525
});
470526
} catch (err) {
527+
// On EEXIST this write did not create the path, so it must not delete it.
528+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
529+
await this.discardTemp(tempPath);
530+
}
471531
throw new Error(
472532
`Failed to write temporary SSH config file at ${tempPath}: ${err instanceof Error ? err.message : String(err)}. ` +
473533
`Please check your disk space, permissions, and that the directory exists.`,
474534
{ cause: err },
475535
);
476536
}
537+
}
538+
539+
/** Log a repair failure without preventing an SSH connection attempt. */
540+
private async repairPermissions(filePath: string): Promise<void> {
541+
try {
542+
await this.permissions?.secure(filePath);
543+
} catch (error) {
544+
this.logger.warn(
545+
"Failed to repair SSH config permissions",
546+
filePath,
547+
error,
548+
);
549+
}
550+
}
477551

552+
/** Replace the destination atomically, cleaning up if the rename fails. */
553+
private async replaceWithTemp(tempPath: string): Promise<void> {
478554
try {
479555
await renameWithRetry(
480556
(src, dest) => this.fileSystem.rename(src, dest),
@@ -493,6 +569,7 @@ export class SshConfig {
493569
}
494570
}
495571

572+
/** Attempt cleanup without hiding the original write or rename failure. */
496573
private async discardTemp(tempPath: string): Promise<void> {
497574
try {
498575
await this.fileSystem.unlink(tempPath);

0 commit comments

Comments
 (0)