mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-19 11:22:21 +02:00
fix(windows): grant icacls ACEs by *SID, not unqualified username
An unqualified username handed to icacls is ambiguous: on a machine whose hostname equals the username (a common Windows setup), it resolves to the MACHINE account instead of the user. Combined with /inheritance:r, that leaves ~/.gstack with a single ACE matching nobody — the process that just "secured" the directory locks itself out, and icacls still reports success. Both icacls sites in the repo (restrictFilePermissions and restrictDirectoryPermissions in browse/src/file-permissions.ts — the only icacls call sites; setup has none) now grant via icacls' literal-SID form `*<SID>`, resolved once per process from System32\whoami.exe (pinned to System32 because a bare `whoami` under a bash-flavoured PATH picks up the MSYS build, which rejects /user). Fallback when the SID can't be resolved is the domain-qualified `USERDOMAIN\username` name, which is unambiguous where the bare username was not. Windows-only regression tests assert the hardened directory stays usable by the calling process (readdir + write), which is exactly the check that a not-toThrow assertion sailed past before. Contributed by @asizux2 (PR #2479); the same defect was independently fixed by @Icandi40, @chiragborse1, @IntegriGit and @voltapix26. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
81659f9456
commit
f96fd46b1c
@@ -42,6 +42,52 @@ import * as os from 'os';
|
|||||||
|
|
||||||
let warnedOnce = false;
|
let warnedOnce = false;
|
||||||
|
|
||||||
|
let cachedSid: string | null | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current user's SID, cached for the process lifetime.
|
||||||
|
*
|
||||||
|
* Returns null if `whoami` is unavailable or its output cannot be parsed,
|
||||||
|
* in which case callers fall back to a domain-qualified account name.
|
||||||
|
*/
|
||||||
|
function currentUserSid(): string | null {
|
||||||
|
if (cachedSid !== undefined) return cachedSid;
|
||||||
|
try {
|
||||||
|
// Pin to the System32 binary. A bare `whoami` resolves to the MSYS/Git
|
||||||
|
// Bash build under a bash-flavoured PATH, which rejects `/user` — the
|
||||||
|
// lookup would then silently fail on one of the most common Windows
|
||||||
|
// setups for this tool.
|
||||||
|
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
||||||
|
const out = execFileSync(`${systemRoot}\\System32\\whoami.exe`, ['/user', '/fo', 'csv', '/nh'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
const match = out.match(/S-1-[\d-]+/);
|
||||||
|
cachedSid = match ? match[0] : null;
|
||||||
|
} catch {
|
||||||
|
cachedSid = null;
|
||||||
|
}
|
||||||
|
return cachedSid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The principal to hand icacls for "the current user".
|
||||||
|
*
|
||||||
|
* An unqualified username is ambiguous: on a machine whose hostname equals
|
||||||
|
* the username, it fails to resolve to the user account and icacls silently
|
||||||
|
* writes an ACE for the machine SID instead. Combined with `/inheritance:r`
|
||||||
|
* that leaves a directory whose only ACE matches nobody — locking out the
|
||||||
|
* process that just created it.
|
||||||
|
*
|
||||||
|
* `*<SID>` is icacls' literal-SID form and is immune to that ambiguity.
|
||||||
|
* The domain-qualified name is the fallback.
|
||||||
|
*/
|
||||||
|
function currentUserPrincipal(): string {
|
||||||
|
const sid = currentUserSid();
|
||||||
|
if (sid) return `*${sid}`;
|
||||||
|
const domain = process.env.USERDOMAIN || os.hostname();
|
||||||
|
return `${domain}\\${os.userInfo().username}`;
|
||||||
|
}
|
||||||
|
|
||||||
function warnIcaclsFailure(fsPath: string, err: unknown): void {
|
function warnIcaclsFailure(fsPath: string, err: unknown): void {
|
||||||
if (warnedOnce) return;
|
if (warnedOnce) return;
|
||||||
warnedOnce = true;
|
warnedOnce = true;
|
||||||
@@ -67,7 +113,7 @@ function warnIcaclsFailure(fsPath: string, err: unknown): void {
|
|||||||
export function restrictFilePermissions(filePath: string): void {
|
export function restrictFilePermissions(filePath: string): void {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
try {
|
try {
|
||||||
const user = os.userInfo().username;
|
const user = currentUserPrincipal();
|
||||||
execFileSync(
|
execFileSync(
|
||||||
'icacls',
|
'icacls',
|
||||||
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
||||||
@@ -97,7 +143,7 @@ export function restrictFilePermissions(filePath: string): void {
|
|||||||
export function restrictDirectoryPermissions(dirPath: string): void {
|
export function restrictDirectoryPermissions(dirPath: string): void {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
try {
|
try {
|
||||||
const user = os.userInfo().username;
|
const user = currentUserPrincipal();
|
||||||
execFileSync(
|
execFileSync(
|
||||||
'icacls',
|
'icacls',
|
||||||
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ describe('restrictDirectoryPermissions', () => {
|
|||||||
fs.mkdirSync(d);
|
fs.mkdirSync(d);
|
||||||
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
|
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on Windows, the directory stays usable by the calling process', () => {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
const d = path.join(tmpDir, 'still-usable');
|
||||||
|
fs.mkdirSync(d);
|
||||||
|
fs.writeFileSync(path.join(d, 'before'), 'x');
|
||||||
|
|
||||||
|
restrictDirectoryPermissions(d);
|
||||||
|
|
||||||
|
// Regression: an unqualified username passed to icacls can resolve to
|
||||||
|
// the machine SID rather than the user account. Combined with
|
||||||
|
// /inheritance:r that leaves a directory whose only ACE matches nobody,
|
||||||
|
// so the process that just "secured" it can no longer enumerate or
|
||||||
|
// write to it. icacls still reports success, so a not-toThrow assertion
|
||||||
|
// sails straight past it — hence these access checks.
|
||||||
|
expect(() => fs.readdirSync(d)).not.toThrow();
|
||||||
|
expect(fs.readdirSync(d)).toContain('before');
|
||||||
|
expect(() => fs.writeFileSync(path.join(d, 'after'), 'y')).not.toThrow();
|
||||||
|
expect(fs.readFileSync(path.join(d, 'after'), 'utf8')).toBe('y');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('writeSecureFile', () => {
|
describe('writeSecureFile', () => {
|
||||||
@@ -138,6 +158,16 @@ describe('mkdirSecure', () => {
|
|||||||
expect(() => mkdirSecure(d)).not.toThrow();
|
expect(() => mkdirSecure(d)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on Windows, the created directory stays usable by the caller', () => {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
// The state-dir path that broke: mkdirSecure() creates .gstack/, hardens
|
||||||
|
// it, and the very next thing the daemon does is write a lockfile inside.
|
||||||
|
const d = path.join(tmpDir, 'state', '.gstack');
|
||||||
|
mkdirSecure(d);
|
||||||
|
expect(() => fs.writeFileSync(path.join(d, 'browse.json.lock'), '1')).not.toThrow();
|
||||||
|
expect(fs.readdirSync(d)).toContain('browse.json.lock');
|
||||||
|
});
|
||||||
|
|
||||||
test('recursive behavior: creates intermediate directories', () => {
|
test('recursive behavior: creates intermediate directories', () => {
|
||||||
const d = path.join(tmpDir, 'a', 'b', 'c');
|
const d = path.join(tmpDir, 'a', 'b', 'c');
|
||||||
mkdirSecure(d);
|
mkdirSecure(d);
|
||||||
|
|||||||
Reference in New Issue
Block a user