Skip to content
Merged
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
7 changes: 6 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,13 @@ jobs:
exit-code: "1"
ignore-unfixed: false

# Populates the Security tab. Fork PRs run with a read-only GITHUB_TOKEN
# that cannot write security events, so this upload fails there ("No
# server is currently available"). Skip it on fork PRs only; same-repo PRs
# and pushes to main still upload and stay strict. The real gate is Trivy's
# `exit-code: 1` above, which runs regardless.
- name: Upload Trivy results to GitHub Security
if: always()
if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-results.sarif
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
resolveProviderRetryAttempts,
} from "./constants.js";
import { isFileNotFoundError } from "./fs-errors.js";
import { restrictDirToCurrentUser } from "./windows-acl.js";

export const openWikiEnvDir = path.join(os.homedir(), ".openwiki");
export const openWikiEnvPath = path.join(openWikiEnvDir, ".env");
Expand Down Expand Up @@ -285,6 +286,7 @@ export async function saveOpenWikiEnv(updates: EnvMap): Promise<void> {
mode: 0o700,
});
await chmod(openWikiEnvDir, 0o700);
await restrictDirToCurrentUser(openWikiEnvDir);

await writeFile(openWikiEnvPath, formatEnv(nextEnv), {
encoding: "utf8",
Expand Down
2 changes: 2 additions & 0 deletions src/openwiki-home.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { chmod, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { restrictDirToCurrentUser } from "./windows-acl.js";

export const openWikiHomeDir = path.join(os.homedir(), ".openwiki");
export const openWikiConnectorsDir = path.join(openWikiHomeDir, "connectors");
Expand Down Expand Up @@ -30,6 +31,7 @@ export function getConnectorLogsDir(connectorId: string): string {
export async function ensureOpenWikiHome(): Promise<void> {
await mkdir(openWikiHomeDir, { recursive: true, mode: 0o700 });
await chmodIfExists(openWikiHomeDir, 0o700);
await restrictDirToCurrentUser(openWikiHomeDir);
await mkdir(openWikiConnectorsDir, { recursive: true, mode: 0o700 });
await mkdir(openWikiLocalWikiDir, { recursive: true, mode: 0o700 });
await mkdir(openWikiSkillsDir, { recursive: true, mode: 0o700 });
Expand Down
41 changes: 41 additions & 0 deletions src/windows-acl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { execFile } from "node:child_process";
import os from "node:os";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

// Well-known SID for NT AUTHORITY\SYSTEM; the * prefix tells icacls it is a
// SID rather than an account name, so it resolves on any display language.
const SYSTEM_SID = "*S-1-5-18";

/**
* Mirrors the POSIX 0o700 owner-only intent on Windows, where fs.chmod only
* toggles the read-only attribute and leaves ACLs untouched: grants full
* control to the current user and SYSTEM (inheritable, so new children are
* covered), then removes inherited ACEs. The grant runs before the
* inheritance reset so a failed grant can never lock the user out of the
* directory. Best-effort by design: returns false instead of throwing so
* ACL tooling problems never block a run. No-op on non-Windows platforms.
*/
export async function restrictDirToCurrentUser(
dirPath: string,
): Promise<boolean> {
if (process.platform !== "win32") {
return false;
}

const userName = os.userInfo().username;

try {
await execFileAsync("icacls", [
dirPath,
"/grant:r",
`${userName}:(OI)(CI)F`,
`${SYSTEM_SID}:(OI)(CI)F`,
]);
await execFileAsync("icacls", [dirPath, "/inheritance:r"]);
return true;
} catch {
return false;
}
}
97 changes: 97 additions & 0 deletions test/windows-acl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import os from "node:os";
import { afterEach, describe, expect, test, vi } from "vitest";

const execFileMock = vi.hoisted(() =>
vi.fn(
(
_command: string,
_args: string[],
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "");
},
),
);

vi.mock("node:child_process", () => ({
execFile: execFileMock,
}));

import { restrictDirToCurrentUser } from "../src/windows-acl.ts";

const realPlatform = process.platform;

function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}

afterEach(() => {
setPlatform(realPlatform);
execFileMock.mockClear();
});

describe("restrictDirToCurrentUser", () => {
test("is a no-op on non-Windows platforms", async () => {
setPlatform("linux");

const restricted = await restrictDirToCurrentUser("/home/user/.openwiki");

expect(restricted).toBe(false);
expect(execFileMock).not.toHaveBeenCalled();
});

test("grants the current user and SYSTEM, then removes inheritance", async () => {
setPlatform("win32");

const restricted = await restrictDirToCurrentUser(
"C:\\Users\\u\\.openwiki",
);

expect(restricted).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(2);

const [grantCommand, grantArgs] = execFileMock.mock.calls[0] as unknown as [
string,
string[],
];
expect(grantCommand).toBe("icacls");
expect(grantArgs).toEqual([
"C:\\Users\\u\\.openwiki",
"/grant:r",
`${os.userInfo().username}:(OI)(CI)F`,
"*S-1-5-18:(OI)(CI)F",
]);

const [, inheritanceArgs] = execFileMock.mock.calls[1] as unknown as [
string,
string[],
];
expect(inheritanceArgs).toEqual([
"C:\\Users\\u\\.openwiki",
"/inheritance:r",
]);
});

test("does not remove inheritance when the grant fails, so a failed grant cannot lock the user out", async () => {
setPlatform("win32");
execFileMock.mockImplementationOnce(
(
_command: string,
_args: string[],
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("icacls failed"), "", "");
},
);

const restricted = await restrictDirToCurrentUser(
"C:\\Users\\u\\.openwiki",
);

expect(restricted).toBe(false);
expect(execFileMock).toHaveBeenCalledTimes(1);
});
});