Skip to content

Commit d482086

Browse files
authored
fix(windows): grant owner ACE before ACL inheritance removal (lidge-jun#601)
/inheritance:r drops inherited ACEs immediately. Doing that before an explicit current-user Full Control grant left atomic-write temps with a protected zero-ACE DACL when a later icacls step timed out (lidge-jun#596).
1 parent 406a522 commit d482086

2 files changed

Lines changed: 74 additions & 13 deletions

File tree

src/lib/windows-secret-acl.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
*
44
* On Windows, `chmod` only controls POSIX-style bits in the ACE list and does NOT remove
55
* inherited permissions from other users. Real per-user isolation requires icacls to:
6-
* 1. Disable inheritance (icacls path /inheritance:r)
7-
* 2. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
8-
* 3. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
6+
* 1. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
7+
* 2. Disable inheritance (icacls path /inheritance:r)
8+
* 3. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
9+
*
10+
* Owner grant MUST precede `/inheritance:r`. That flag is destructive: it drops inherited
11+
* ACEs immediately. If a later step times out after inheritance is removed but before an
12+
* explicit owner ACE exists, the temp is left with a protected empty DACL — owned by the
13+
* current user yet unreadable/ununlinkable until Full Control is restored (issue #596).
914
*
1015
* On non-Windows platforms the helpers fall through to the caller's existing chmod-based
1116
* behaviour: they return ok:true without invoking any external process.
@@ -141,8 +146,8 @@ function currentWindowsUser(): string | undefined {
141146

142147
/**
143148
* Run icacls to harden a single file system entry.
144-
* - Disables inheritance (keeps nothing: /inheritance:r)
145-
* - Grants the current user Full Control
149+
* Order is intentional (issue #596): grant the owner ACE first so a later
150+
* `/inheritance:r` or `/remove:g` timeout cannot strand a protected zero-ACE DACL.
146151
*
147152
* We do NOT use a shell string; all arguments are passed as an array so no
148153
* shell injection is possible even for paths with unusual characters.
@@ -170,13 +175,20 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
170175
if (!result.success) throw icaclsError(step, result);
171176
};
172177

173-
// Step 1: disable inheritance and remove inherited ACEs
178+
// Step 1: grant current user full control BEFORE any destructive ACL change.
179+
// If this fails, inheritance is untouched and the writer keeps inherited access.
180+
const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
181+
runOrThrow("/grant:r", [targetPath, "/grant:r", grant]);
182+
183+
// Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
184+
// from step 1 survives this transition, so a later failure still leaves cleanup access.
174185
runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
175186

176-
// Step 2: remove broad explicit grants using stable SIDs (not localized names).
187+
// Step 3: remove broad explicit grants using stable SIDs (not localized names).
177188
// Missing ACEs can yield a non-zero exit; verify with locale-independent /findsid
178189
// before accepting the failure as harmless — a swallowed real failure would leave
179190
// Everyone/Users/Authenticated Users grants while reporting hardened.
191+
// `/remove:g` cannot remove the explicit current-user ACE installed in step 1.
180192
const removal = run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
181193
if (!removal.success) {
182194
if (removal.timedOut) throw icaclsError("/remove:g", removal);
@@ -191,10 +203,6 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
191203
}
192204
}
193205
}
194-
195-
// Step 3: grant current user full control.
196-
const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
197-
runOrThrow("/grant:r", [targetPath, "/grant:r", grant]);
198206
}
199207

200208
/**

tests/windows-secret-acl.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,14 +411,67 @@ describe("icacls failure paths (injected seams)", () => {
411411

412412
test("a thrown EPERM error on a required path still fails closed (no retry)", () => {
413413
let calls = 0;
414-
setIcaclsRunnerForTests(() => {
414+
const steps: string[] = [];
415+
setIcaclsRunnerForTests(args => {
415416
calls += 1;
417+
if (args.includes("/grant:r")) steps.push("grant-owner");
418+
else if (args.includes("/inheritance:r")) steps.push("remove-inheritance");
419+
else if (args.includes("/remove:g")) steps.push("remove-broad");
420+
else if (args.includes("/findsid")) steps.push("findsid");
421+
else steps.push("other");
416422
const err = new Error("icacls denied") as NodeJS.ErrnoException;
417423
err.code = "EPERM";
418424
throw err;
419425
});
420426

421427
expect(() => hardenSecretPath(secretFile(), { required: true })).toThrow(/permission denied/);
422-
expect(calls).toBe(1); // real failures do not consume the timeout retry
428+
// Grant runs first: a grant failure must not have already mutated inheritance (#596).
429+
expect(calls).toBe(1);
430+
expect(steps).toEqual(["grant-owner"]);
431+
});
432+
433+
test("successful harden runs grant-owner before inheritance removal (#596)", () => {
434+
const steps: string[] = [];
435+
setIcaclsRunnerForTests(args => {
436+
if (args.includes("/grant:r")) steps.push("grant-owner");
437+
else if (args.includes("/inheritance:r")) steps.push("remove-inheritance");
438+
else if (args.includes("/remove:g")) steps.push("remove-broad");
439+
else if (args.includes("/findsid")) steps.push("findsid");
440+
return ok;
441+
});
442+
443+
expect(hardenSecretPath(secretFile(), { required: true })).toEqual({ ok: true });
444+
expect(steps).toEqual(["grant-owner", "remove-inheritance", "remove-broad"]);
445+
});
446+
447+
test("remove:g timeout after owner grant leaves explicit Full Control (#596)", () => {
448+
// Models the production strand: inheritance already removed, then a later step
449+
// times out. With owner-first ordering the writer still has an explicit ACE.
450+
let ownerHasExplicitAce = false;
451+
let inheritanceRemoved = false;
452+
const timeoutOnRemove: IcaclsResult = {
453+
success: false,
454+
exitCode: null,
455+
timedOut: true,
456+
stdout: "",
457+
};
458+
setIcaclsRunnerForTests(args => {
459+
if (args.includes("/grant:r")) {
460+
ownerHasExplicitAce = true;
461+
return ok;
462+
}
463+
if (args.includes("/inheritance:r")) {
464+
inheritanceRemoved = true;
465+
return ok;
466+
}
467+
if (args.includes("/remove:g")) return timeoutOnRemove;
468+
return ok;
469+
});
470+
471+
const result = hardenSecretPath(secretFile(), { required: true });
472+
expect(result.ok).toBe(false);
473+
expect(result.diagnostics).toContain("ETIMEDOUT");
474+
expect(inheritanceRemoved).toBe(true);
475+
expect(ownerHasExplicitAce).toBe(true);
423476
});
424477
});

0 commit comments

Comments
 (0)