Skip to content

Commit 1e5d6b7

Browse files
fix(security): apply the settings ACL for real and assert it
`restrictWindowsAcl` passed the program name as the first argument, so icacls received two operands ("icacls" and the target) and exited with ERROR_INVALID_PARAMETER (87). The failure was swallowed as best-effort, so the ACL was never touched while the caller kept going: on Windows the settings file (which stores the API key) stayed readable by whatever the parent directory grants, and the 0600 intent was silently unmet. - drop the duplicated "icacls" operand - resolve the principal from USERDOMAIN/USERNAME before falling back to whoami, which under MSYS/Git-Bash answers with a bare, ambiguous name - return whether the ACL is now user-only instead of failing invisibly - tests: assert that no inherited ACE survives (this is the assertion that catches the no-op), read the exact principal back instead of matching a loose pattern, and skip loudly when icacls cannot run Verified on Windows: the file and its directory now carry a single explicit ACE for the current user, with no Authenticated Users/Everyone entry.
1 parent 1e8d52a commit 1e5d6b7

2 files changed

Lines changed: 66 additions & 37 deletions

File tree

‎packages/core/src/common/private-storage.ts‎

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,21 @@ export const PRIVATE_DIRECTORY_MODE = 0o700;
2121
/**
2222
* Resolve the current Windows user as a fully-qualified principal
2323
* (``DOMAIN\user``) so ACL grants are unambiguous across machines/domains.
24+
*
25+
* ``USERDOMAIN``/``USERNAME`` win when present: they are set by Windows for
26+
* every process, whereas under MSYS/Git-Bash a POSIX ``whoami`` earlier in
27+
* PATH answers with a bare, ambiguous name.
28+
*
2429
* Returns null when the identity cannot be resolved (callers no-op).
2530
*/
2631
export function windowsIdentity(): string | null {
2732
if (process.platform !== "win32") {
2833
return null;
2934
}
35+
const { USERDOMAIN, USERNAME } = process.env;
36+
if (USERDOMAIN && USERNAME) {
37+
return `${USERDOMAIN}\\${USERNAME}`;
38+
}
3039
try {
3140
const stdout = execFileSync("whoami", {
3241
encoding: "utf8",
@@ -51,20 +60,24 @@ export function windowsIdentity(): string | null {
5160
* 2. ``icacls /grant:r <user>:F`` grants the current user exclusive full
5261
* control (``:r`` replaces, does not append).
5362
*
54-
* Failures are swallowed (best-effort, like POSIX chmod) — the file is still
55-
* created; only its ACL may be more permissive than intended.
63+
* Returns whether the ACL now grants the current user alone. Failures are
64+
* reported, never thrown: callers decide whether an unprotected path is
65+
* acceptable. Note the argv must not repeat the program name — passing
66+
* ``icacls icacls <path>`` makes icacls exit with ERROR_INVALID_PARAMETER (87)
67+
* and leave the ACL untouched, which is how this helper silently no-opped
68+
* before.
5669
*/
57-
export function restrictWindowsAcl(targetPath: string): void {
70+
export function restrictWindowsAcl(targetPath: string): boolean {
5871
if (process.platform !== "win32") {
59-
return;
72+
return true;
6073
}
6174
const identity = windowsIdentity();
6275
if (!identity) {
63-
return;
76+
return false;
6477
}
6578
for (const args of [
66-
["icacls", targetPath, "/inheritance:r"],
67-
["icacls", targetPath, "/grant:r", `${identity}:F`],
79+
[targetPath, "/inheritance:r"],
80+
[targetPath, "/grant:r", `${identity}:F`],
6881
]) {
6982
try {
7083
execFileSync("icacls", args, {
@@ -74,34 +87,33 @@ export function restrictWindowsAcl(targetPath: string): void {
7487
stdio: ["ignore", "pipe", "ignore"],
7588
});
7689
} catch {
77-
return; // best-effort; keep the caller moving
90+
return false; // best-effort: keep the caller moving, but do not pretend
7891
}
7992
}
93+
return true;
8094
}
8195

8296
/**
8397
* Write a private file with user-only permissions on every platform.
8498
*
85-
* - POSIX: mode 0600 (subject to umask, which typically keeps it at 0600).
99+
* - POSIX: mode 0600 (applied by the write itself, subject to umask).
86100
* - Windows: mode bits are ignored by the OS, so we remove inherited ACEs
87101
* and grant the current user exclusive full control.
102+
*
103+
* Returns true when the platform's permission model was applied as requested.
88104
*/
89-
export function writePrivateFile(targetPath: string, contents: string): void {
105+
export function writePrivateFile(targetPath: string, contents: string): boolean {
90106
fs.writeFileSync(targetPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
91-
if (process.platform === "win32") {
92-
restrictWindowsAcl(targetPath);
93-
}
107+
return process.platform === "win32" ? restrictWindowsAcl(targetPath) : true;
94108
}
95109

96110
/**
97111
* Ensure a directory exists with user-only permissions (0700 on POSIX;
98-
* current-user-only ACL on Windows).
112+
* current-user-only ACL on Windows). Returns true when applied as requested.
99113
*/
100-
export function ensurePrivateDirectory(dirPath: string): void {
114+
export function ensurePrivateDirectory(dirPath: string): boolean {
101115
fs.mkdirSync(dirPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
102-
if (process.platform === "win32") {
103-
restrictWindowsAcl(dirPath);
104-
}
116+
return process.platform === "win32" ? restrictWindowsAcl(dirPath) : true;
105117
}
106118

107119
/** Home directory used for DeepCode user state. */

‎packages/core/src/tests/private-storage.test.ts‎

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import * as os from "os";
66
import * as path from "path";
77
import {
88
PRIVATE_FILE_MODE,
9+
PRIVATE_DIRECTORY_MODE,
910
ensurePrivateDirectory,
11+
windowsIdentity,
1012
writePrivateFile,
11-
restrictWindowsAcl,
1213
} from "../common/private-storage";
1314

1415
const tempDirs: string[] = [];
@@ -32,7 +33,9 @@ describe("private-storage", () => {
3233
}
3334
const dir = tempDir("dc-priv-posix-");
3435
const file = path.join(dir, "secret.json");
35-
writePrivateFile(file, "{}");
36+
// The strict return value is part of the contract: true means the caller
37+
// has a private file, false must never masquerade as success.
38+
assert.equal(writePrivateFile(file, "{}"), true, "writePrivateFile must report success");
3639
const mode = fs.statSync(file).mode & 0o777;
3740
assert.equal(mode, PRIVATE_FILE_MODE, "file must be 0600");
3841
});
@@ -43,41 +46,55 @@ describe("private-storage", () => {
4346
}
4447
const base = tempDir("dc-priv-dir-");
4548
const dir = path.join(base, ".deepcode", "nested");
46-
ensurePrivateDirectory(dir);
49+
assert.equal(ensurePrivateDirectory(dir), true, "ensurePrivateDirectory must report success");
4750
const mode = fs.statSync(dir).mode & 0o777;
48-
assert.equal(mode, 0o700, "directory must be 0700");
51+
assert.equal(mode, PRIVATE_DIRECTORY_MODE, "directory must be 0700");
4952
});
5053

51-
it("restricts Windows ACL to the current user (Windows only)", () => {
54+
it("restricts the Windows ACL to the current user (Windows only)", (t) => {
5255
if (process.platform !== "win32") {
5356
return;
5457
}
5558
const dir = tempDir("dc-priv-acl-");
5659
const file = path.join(dir, "credentials.json");
57-
writePrivateFile(file, "{}");
60+
const applied = writePrivateFile(file, "{}");
61+
if (!applied) {
62+
// Say "not verified" out loud instead of passing vacuously: a file that
63+
// icacls could not touch must never look like a green ACL test.
64+
t.skip("icacls could not restrict the ACL in this environment");
65+
return;
66+
}
5867

5968
const out = execFileSync("icacls", [file], {
6069
encoding: "utf8",
6170
windowsHide: true,
6271
});
63-
// Dangerous inherited ACEs must be gone.
64-
for (const dangerous of ["Authenticated Users", "Everyone"]) {
65-
assert.ok(!out.includes(dangerous), `must not contain ${dangerous}`);
72+
73+
// 1. /inheritance:r really ran: not one inherited ACE may survive. This is
74+
// the assertion that catches a silent no-op, where the file keeps the
75+
// permissive ACEs it inherited from its parent directory.
76+
assert.ok(!out.includes("(I)"), `inherited ACEs still apply:\n${out}`);
77+
78+
// 2. The current user keeps full control. icacls prints `DOMAIN\user:(F)`
79+
// for an explicit ACE and `DOMAIN\user:(I)(F)` when it is inherited, so
80+
// read the exact principal back instead of matching a loose pattern.
81+
const identity = windowsIdentity();
82+
assert.ok(identity, "windowsIdentity() must resolve the current user");
83+
assert.ok(out.includes(`${identity}:(F)`), `${identity} must keep (F):\n${out}`);
84+
85+
// 3. No broad principal keeps access. These are the English Windows names;
86+
// a localized build passes this trivially, which assertion 1 covers.
87+
for (const broad of ["Authenticated Users", "Everyone"]) {
88+
assert.ok(!out.includes(broad), `must not grant ${broad}:\n${out}`);
6689
}
67-
// The current user keeps full control (with or without inherited flag).
68-
// icacls prints either `user:(F)` or `user:(I)(F)` depending on the ACE.
69-
assert.match(out, /:\(I?\)?\(F\)/, "current user must retain full control");
7090
});
7191

7292
it("is idempotent when called repeatedly", () => {
7393
const dir = tempDir("dc-priv-again-");
7494
const file = path.join(dir, "x.json");
75-
writePrivateFile(file, "1");
76-
writePrivateFile(file, "2");
77-
assert.equal(fs.readFileSync(file, "utf8"), "2");
78-
if (process.platform === "win32") {
79-
// Second call must not throw and must keep the ACL restricted.
80-
restrictWindowsAcl(file);
81-
}
95+
const first = writePrivateFile(file, "1");
96+
const second = writePrivateFile(file, "2");
97+
assert.equal(fs.readFileSync(file, "utf8"), "2", "last write must win");
98+
assert.equal(second, first, "repeated writes must report the same outcome");
8299
});
83100
});

0 commit comments

Comments
 (0)