Skip to content
Closed
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
49 changes: 27 additions & 22 deletions src/lib/windows-secret-acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@

import { existsSync, statSync } from "node:fs";
import { env, platform } from "node:process";
import {
resolveCurrentWindowsPrincipal,
resolveCurrentWindowsPrincipalAsync,
} from "./windows-user-principal";

const hardenedDirectories = new Map<string, HardenedIdentity>();
const hardenedPaths = new Map<string, HardenedIdentity>();
Expand Down Expand Up @@ -393,18 +397,23 @@ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException
return err;
}

/**
* Return the current Windows username from the environment.
* Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
* The value is used directly in icacls arguments, so it must be present.
*/
function currentWindowsUser(): string | undefined {
const username = env["USERNAME"];
const domain = env["USERDOMAIN"];
if (!username) return undefined;
// USERDOMAIN is the machine/domain name; USERNAME is the account name.
// icacls accepts "DOMAIN\User" or just "User" for local accounts.
return domain ? `${domain}\\${username}` : username;
// POSIX CI deliberately drives the Windows ACL branch through platformOverride.
// The synthetic SID exists only for that test seam; production Windows always
// resolves the effective token and never falls back to USERDOMAIN/USERNAME.
const FORCED_NON_WINDOWS_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001";

function currentWindowsPrincipal(deadline: number): string {
if (platformOverride === "win32" && platform !== "win32") {
return FORCED_NON_WINDOWS_TEST_PRINCIPAL;
}
return resolveCurrentWindowsPrincipal(deadline - nowFn());
}

async function currentWindowsPrincipalAsync(deadline: number): Promise<string> {
if (platformOverride === "win32" && platform !== "win32") {
return FORCED_NON_WINDOWS_TEST_PRINCIPAL;
}
return resolveCurrentWindowsPrincipalAsync(deadline - nowFn());
}

/**
Expand All @@ -424,10 +433,7 @@ function grantAce(user: string, directory: boolean): string {
}

function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
const user = currentWindowsUser();
if (!user) {
throw new Error("Cannot determine current Windows user for ACL hardening");
}
const principal = currentWindowsPrincipal(deadline);

// The deadline is owned by hardenEntry (total budget incl. retry + verification).
const run = (step: string, args: string[]): IcaclsResult => {
Expand All @@ -444,7 +450,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo

// Step 1: grant current user full control BEFORE any destructive ACL change.
// If this fails, inheritance is untouched and the writer keeps inherited access.
runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);
runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);

// Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
// from step 1 survives this transition, so a later failure still leaves cleanup access.
Expand Down Expand Up @@ -473,10 +479,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo

/** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */
async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise<void> {
const user = currentWindowsUser();
if (!user) {
throw new Error("Cannot determine current Windows user for ACL hardening");
}
const principal = await currentWindowsPrincipalAsync(deadline);

const run = async (step: string, args: string[]): Promise<IcaclsResult> => {
const remaining = deadline - nowFn();
Expand All @@ -490,7 +493,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline:
if (!result.success) throw icaclsError(step, result);
};

await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);
await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);
await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);

const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
Expand Down Expand Up @@ -524,6 +527,8 @@ function sanitizeDiagnostics(error: unknown): string {
return `ACL hardening failed (${code}) — permission denied running icacls`;
case "EICACLS":
return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs";
case "EACLIDENTITY":
return "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved";
default:
return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`;
}
Expand Down
215 changes: 215 additions & 0 deletions src/lib/windows-user-principal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Resolve the effective Windows token to the locale-independent SID form that
* icacls accepts ("*S-1-..."). Environment values such as USERDOMAIN are not
* an authority for the current token: on workgroup machines USERDOMAIN may be
* the literal WORKGROUP even though the account belongs to the local computer.
*/

import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation";

const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
const SID_EXPRESSION =
"[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";

export interface WindowsPrincipalLookupResult {
success: boolean;
exitCode: number | null;
timedOut: boolean;
stdout: string;
}

export type WindowsPrincipalRunner = (
timeoutMs: number,
) => WindowsPrincipalLookupResult;

export type AsyncWindowsPrincipalRunner = (
timeoutMs: number,
) => Promise<WindowsPrincipalLookupResult>;

const POWERSHELL_ARGS = [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-Command",
SID_EXPRESSION,
] as const;

function windowsPrincipalPowerShellCommand(): string[] {
return [resolveTrustedWindowsPowerShellExe(), ...POWERSHELL_ARGS];
}

/** Test-only readback of the exact trusted executable and static arguments. */
export function windowsPrincipalPowerShellCommandForTests(): string[] {
return windowsPrincipalPowerShellCommand();
}

function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLookupResult {
const result = Bun.spawnSync(windowsPrincipalPowerShellCommand(), {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
timeout: Math.max(1, timeoutMs),
windowsHide: true,
});
return {
success: result.success,
exitCode: result.exitCode,
timedOut: result.exitedDueToTimeout ?? false,
stdout: result.stdout ? result.stdout.toString() : "",
};
}

async function defaultAsyncWindowsPrincipalRunner(
timeoutMs: number,
): Promise<WindowsPrincipalLookupResult> {
const proc = Bun.spawn(windowsPrincipalPowerShellCommand(), {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
windowsHide: true,
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
try { proc.kill(); } catch { /* already exited */ }
}, Math.max(1, timeoutMs));
let exitCode: number | null = null;
try {
exitCode = await proc.exited;
} finally {
clearTimeout(timer);
}
const stdout = proc.stdout
? await new Response(proc.stdout).text().catch(() => "")
: "";
return {
success: !timedOut && exitCode === 0,
exitCode: timedOut ? null : exitCode,
timedOut,
stdout,
};
}

let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner;
let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner;
let cachedPrincipal: string | null = null;
let asyncLookupInFlight: Promise<string> | null = null;

function identityError(reason: string): NodeJS.ErrnoException {
const error = new Error(`Windows effective-account SID lookup ${reason}`) as NodeJS.ErrnoException;
// Keep identity lookup failures distinct from icacls timeouts. In particular,
// they must not populate windows-secret-acl's destination timeout memo.
error.code = "EACLIDENTITY";
return error;
}

function principalFromResult(result: WindowsPrincipalLookupResult): string {
if (!result.success) {
throw identityError(result.timedOut
? "timed out"
: `exited ${result.exitCode ?? "null"}`);
}
const sid = result.stdout.trim();
if (!SID_PATTERN.test(sid)) {
throw identityError(sid ? "returned an invalid SID" : "returned an empty SID");
}
return `*${sid.toUpperCase()}`;
}

/** Resolve and process-cache the effective token SID for synchronous ACL paths. */
export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
if (cachedPrincipal) return cachedPrincipal;
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
let result: WindowsPrincipalLookupResult;
try {
result = principalRunner(timeoutMs);
} catch {
throw identityError("could not start");
}
const principal = principalFromResult(result);
cachedPrincipal = principal;
return principal;
}

async function waitForExistingLookup(
lookup: Promise<string>,
timeoutMs: number,
): Promise<string> {
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
lookup,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(identityError("timed out while awaiting the shared lookup")),
Math.max(1, timeoutMs),
);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}

/**
* Async counterpart. Concurrent callers share one owned child lookup; a later
* caller may exhaust its own budget without cancelling the lookup owned by the
* first caller. The first caller owns that child and its process timeout; a
* later, longer budget deliberately does not extend an already-running child.
*/
export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise<string> {
if (cachedPrincipal) return cachedPrincipal;
if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs);
if (timeoutMs <= 0) throw identityError("had no remaining deadline");

const lookup = (async (): Promise<string> => {
let result: WindowsPrincipalLookupResult;
try {
result = await asyncPrincipalRunner(timeoutMs);
} catch {
throw identityError("could not start");
}
const principal = principalFromResult(result);
cachedPrincipal = principal;
return principal;
})();
asyncLookupInFlight = lookup;
try {
return await lookup;
} finally {
if (asyncLookupInFlight === lookup) asyncLookupInFlight = null;
}
}

/** Test seam: replace the sync resolver process and clear its successful cache. */
export function setWindowsPrincipalRunnerForTests(
runner: WindowsPrincipalRunner | null,
): void {
if (asyncLookupInFlight) {
throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
}
principalRunner = runner ?? defaultWindowsPrincipalRunner;
cachedPrincipal = null;
}

/** Test seam: replace the async resolver process and clear its successful cache. */
export function setAsyncWindowsPrincipalRunnerForTests(
runner: AsyncWindowsPrincipalRunner | null,
): void {
if (asyncLookupInFlight) {
throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
}
asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner;
cachedPrincipal = null;
}

/** Test seam: clear only process-local principal state. */
export function resetWindowsPrincipalForTests(): void {
if (asyncLookupInFlight) {
throw new Error("Cannot reset the Windows principal while a lookup is in flight.");
}
cachedPrincipal = null;
}
Loading
Loading