Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
* text=auto eol=lf
pnpm-lock.yaml linguist-generated=true
flake.lock linguist-generated=true
6 changes: 6 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ jobs:
name: Windows,
electron-version: "latest",
}
- {
os: windows-11-arm,
name: Windows ARM64,
electron-version: "latest",
}
- { os: macos-15, name: macOS, electron-version: "latest" }

steps:
Expand All @@ -62,6 +67,7 @@ jobs:
shell: bash
env:
CI: true
EXPECTED_ARCH: ${{ runner.arch }}

test-integration:
name: Integration Test (${{ matrix.name }}, VS Code ${{ matrix.vscode-version }})
Expand Down
2 changes: 1 addition & 1 deletion .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ AGENTS.md
# Storybook
.storybook/**
storybook-static/**
**/*.stories.*
**/*.stories.*
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
from published versions since it shows up in the VS Code extension changelog
tab and is confusing to users. Add it back between releases if needed. -->

## Unreleased

### Fixed

- Windows: fixed connections failing with "Bad owner or permissions" on the
SSH config files the extension generates. The extension now repairs those
permissions on connect, so only you, SYSTEM, and Administrators can read
them. Your own SSH config is left untouched, and no admin rights are needed.

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

### Changed
Expand Down
21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,27 @@ Alternatively:
4. If your change is something users ought to be aware of, add an entry in the
changelog.

### Windows SSH config permissions

On Windows, the extension writes generated deployment configs to
`%APPDATA%\coder.coder-remote\ssh`, and OpenSSH refuses to read the whole
include when any of them is too permissive. Before each managed write,
`src/remote/windowsAcl.ts` runs `whoami.exe` to find the current user, then
`icacls.exe /reset` and `/inheritance:r /grant:r` on the directory, so only that
user, SYSTEM, and Administrators keep inheritable full control. Every `*.conf`
file in the directory is then reset to inherit it, which also repairs other
deployments and editors on the first connection after an upgrade.

Like VS Code, the code checks command exit codes but never reads ACLs back. It
needs no scripts, native addon, ownership change, or elevation, and it leaves
the user's own SSH config alone. Links and non-files are rejected before the
repair, because inheritable grants reach children even without `/T`. That stops
mistakes, not an attacker racing the check. The repair is not atomic either: a
failure after `/reset` can leave the directory with its parent's grants.

`windowsAcl.native.test.ts` drives the real `icacls.exe`, `whoami.exe`, and OpenSSH.
Run it unelevated as well as in CI to catch privilege assumptions.

## Node.js Version

This extension targets the Node.js version bundled with VS Code's Electron:
Expand Down
3 changes: 3 additions & 0 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
sshSupportsSetEnv,
type SshProperties,
} from "./sshSupport";
import { createManagedPermissions } from "./windowsAcl";
import { WorkspaceStateMachine } from "./workspaceStateMachine";

import type { Api } from "coder/site/src/api/api";
Expand Down Expand Up @@ -928,6 +929,8 @@ export class Remote {
const coderConfig = new SshConfig(
this.pathResolver.getSshConfigPath(safeHostname, hostEditorId(sshHost)),
this.logger,
undefined,
createManagedPermissions(),
);

// Options the user set themselves win the merge below, so they are exempt
Expand Down
105 changes: 91 additions & 14 deletions src/remote/sshConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
mkdir,
readFile,
readdir,
rename,
stat,
unlink,
Expand Down Expand Up @@ -34,10 +35,20 @@ export interface SshValues {
SetEnv?: string;
}

/**
* Restricts the Coder-managed config directory and the files it generates.
* A config without one is not Coder-managed, so it is written untouched.
*/
export interface ManagedPermissions {
prepareDirectory(directory: string): Promise<void>;
secure(filePath: string): Promise<void>;
}

/** Injectable for tests. */
export interface FileSystem {
mkdir: typeof mkdir;
readFile: typeof readFile;
readdir: typeof readdir;
rename: typeof rename;
stat: typeof stat;
unlink: typeof unlink;
Expand All @@ -47,6 +58,7 @@ export interface FileSystem {
const defaultFileSystem: FileSystem = {
mkdir,
readFile,
readdir,
rename,
stat,
unlink,
Expand Down Expand Up @@ -299,15 +311,19 @@ export class SshConfig {
private readonly fileSystem: FileSystem;
private readonly logger: Logger;
private raw: string | undefined;
/** Marks this file as Coder-managed; absent for the user's own config. */
private readonly permissions: ManagedPermissions | undefined;

constructor(
filePath: string,
logger: Logger,
fileSystem: FileSystem = defaultFileSystem,
permissions?: ManagedPermissions,
) {
this.filePath = filePath;
this.logger = logger;
this.fileSystem = fileSystem;
this.permissions = permissions;
}

async load() {
Expand Down Expand Up @@ -442,39 +458,99 @@ export class SshConfig {

/** Atomically write raw via a temp file. */
private async save(): Promise<void> {
// Preserve the existing file mode.
const existingMode = await this.fileSystem
.stat(this.filePath)
.then((stat) => stat.mode)
.catch((ex: NodeJS.ErrnoException) => {
if (ex.code === "ENOENT") {
return 0o600;
}
throw ex;
});
await this.fileSystem.mkdir(path.dirname(this.filePath), {
const existingMode = await this.getFileMode();
const fileName = path.basename(this.filePath);
const dirName = path.dirname(this.filePath);
await this.fileSystem.mkdir(dirName, {
mode: 0o700,
recursive: true,
});
const fileName = path.basename(this.filePath);
const dirName = path.dirname(this.filePath);
// Must come before any file reset or temporary write in this directory.
await this.permissions?.prepareDirectory(dirName);
await this.repairIncludedFiles(dirName);
const tempPath = tempFilePath(
`${dirName}/.${fileName}`,
"vscode-coder-tmp",
);
await this.writeTemp(tempPath, existingMode);
await this.repairPermissions(tempPath);
await this.replaceWithTemp(tempPath);
}

/** Preserve the existing file mode, defaulting to owner-only access. */
private async getFileMode(): Promise<number> {
try {
return (await this.fileSystem.stat(this.filePath)).mode;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return 0o600;
}
throw error;
}
}

/** Repair every direct Include match; one unsafe sibling blocks every host. */
private async repairIncludedFiles(dirName: string): Promise<void> {
if (!this.permissions) return;
const entries = await this.fileSystem
.readdir(dirName, { withFileTypes: true })
.catch((error: unknown) => {
this.logger.warn(
"Failed to enumerate Coder-managed SSH config files",
error,
);
return [];
});
for (const entry of entries) {
if (!entry.name.toLowerCase().endsWith(SSH_CONFIG_EXT)) continue;
const filePath = path.join(dirName, entry.name);
// On Windows, fopen fails on a directory, so OpenSSH aborts the whole
// Include. No ACL change fixes that, so report it instead.
if (!entry.isFile()) {
throw new Error(
`SSH config entry ${filePath} is not a regular file. Move or rename it so it no longer matches *.conf, then reconnect.`,
);
}
await this.repairPermissions(filePath);
}
}

/** Create the temporary file exclusively, leaving any preexisting path alone. */
private async writeTemp(tempPath: string, mode: number): Promise<void> {
try {
await this.fileSystem.writeFile(tempPath, this.getRaw(), {
mode: existingMode,
encoding: "utf-8",
flag: "wx",
mode,
});
} catch (err) {
// On EEXIST this write did not create the path, so it must not delete it.
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
await this.discardTemp(tempPath);
}
throw new Error(
`Failed to write temporary SSH config file at ${tempPath}: ${err instanceof Error ? err.message : String(err)}. ` +
`Please check your disk space, permissions, and that the directory exists.`,
{ cause: err },
);
}
}

/** Log a repair failure without preventing an SSH connection attempt. */
private async repairPermissions(filePath: string): Promise<void> {
try {
await this.permissions?.secure(filePath);
} catch (error) {
this.logger.warn(
"Failed to repair SSH config permissions",
filePath,
error,
);
}
}

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

/** Attempt cleanup without hiding the original write or rename failure. */
private async discardTemp(tempPath: string): Promise<void> {
try {
await this.fileSystem.unlink(tempPath);
Expand Down
122 changes: 122 additions & 0 deletions src/remote/windowsAcl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { execFile } from "node:child_process";
import { lstat, readdir } from "node:fs/promises";
import * as path from "node:path";
import { promisify } from "node:util";

import { wrapError } from "../error/errorUtils";

import type { ManagedPermissions } from "./sshConfig";

const EXECUTE = promisify(execFile);

/** Protect the Coder-managed SSH directory and repair its files by inheritance. */
export const WINDOWS_ACL: ManagedPermissions = { prepareDirectory, secure };

/** Use DACL repair on Windows; other platforms rely on the file mode. */
export function createManagedPermissions(): ManagedPermissions | undefined {
return process.platform === "win32" ? WINDOWS_ACL : undefined;
}

/** Resolve a system tool without searching PATH or the working directory. */
export function system32(name: string): string {
const systemRoot = process.env.SystemRoot;
if (!systemRoot || !isFullyQualifiedWindowsPath(systemRoot)) {
throw new Error("SystemRoot must be a fully qualified Windows path");
}
return path.win32.join(systemRoot, "System32", name);
}

/** Grant inheritable full control to the user, SYSTEM, and Administrators only. */
async function prepareDirectory(target: string): Promise<void> {
const directory = path.win32.normalize(target);
try {
checkPath(directory);
if (!(await lstat(directory)).isDirectory()) {
throw new Error("Expected a Coder-managed directory without links");
}
// Inheritable grants reach children even without /T, so vet them first.
for (const entry of await readdir(directory)) {
await checkRegularFile(path.win32.join(directory, entry));
}
const sid = await currentUserSid();
// /grant:r alone leaves other trustees' explicit grants and denies.
await run("icacls.exe", [directory, "/reset"]);
await run("icacls.exe", [
directory,
"/inheritance:r",
"/grant:r",
`*${sid}:(OI)(CI)F`,
"*S-1-5-18:(OI)(CI)F", // SYSTEM
"*S-1-5-32-544:(OI)(CI)F", // Administrators
]);
} catch (error) {
throw wrapError(
"prepare SSH config directory permissions for",
directory,
error,
);
}
}

/** Drop a file's own permissions so it inherits the directory's. */
async function secure(target: string): Promise<void> {
const file = path.win32.normalize(target);
try {
await checkRegularFile(file);
await run("icacls.exe", [file, "/reset"]);
} catch (error) {
throw wrapError("repair SSH config permissions for", file, error);
}
}

/** Accept drive-qualified or UNC paths without wildcards or control characters. */
function isFullyQualifiedWindowsPath(target: string): boolean {
if (/[\0\r\n*?]/.test(target)) {
return false;
}
const normalized = path.win32.normalize(target);
return (
/^[A-Za-z]:\\/.test(normalized) ||
/^\\\\[^\\]+\\[^\\]+(?:\\|$)/.test(normalized)
);
}

/** Reject ambiguous paths and characters that can expand an icacls target. */
function checkPath(target: string): void {
if (!isFullyQualifiedWindowsPath(target)) {
throw new Error(
"Expected a fully qualified Windows path without wildcards or control characters",
);
}
}

/** Reject links and non-files, which share their ACL with another target. */
async function checkRegularFile(target: string): Promise<void> {
checkPath(target);
const stat = await lstat(target);
if (!stat.isFile() || stat.nlink !== 1) {
throw new Error(
`Expected a regular file with no links: ${target}. Move or rename linked and non-file entries out of the Coder-managed SSH directory, then reconnect.`,
);
}
}

/** Read the SID, not the localized account name, from whoami's CSV output. */
async function currentUserSid(): Promise<string> {
const { stdout } = await run("whoami.exe", ["/user", "/fo", "csv", "/nh"]);
const sid = /,"(S-\d+(?:-\d+)+)"\s*$/.exec(stdout)?.[1];
if (!sid) {
throw new Error("Could not read the current Windows user SID");
}
return sid;
}

/** Run a system tool without a shell, bounding its time and output. */
function run(name: string, args: string[]) {
return EXECUTE(system32(name), args, {
windowsHide: true,
timeout: 10_000,
maxBuffer: 64 * 1024,
encoding: "utf8",
});
}
Loading
Loading