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
87 changes: 78 additions & 9 deletions src/console/console-config-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { constants, type BigIntStats, type Stats } from "node:fs";
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
import { isAbsolute, join, relative, resolve } from "node:path";
import { createConfigMigrationSource } from "../cli/migrate-config.js";
import { verifyWindowsConfigPathSecurity } from "../cli/windows-config-acl.js";
import {
verifyWindowsConfigPathSecurity,
verifyWindowsConfigPathsSecurity,
type WindowsPrivatePath
} from "../cli/windows-config-acl.js";
import { loadConfigFromText } from "../config/load-config.js";
import {
consoleInitializedConfigMetadata,
Expand All @@ -26,6 +30,8 @@ export interface ConsoleConfigCatalogDiscoveryOptions {
readonly ownerUid?: number;
/** Test seam for Windows DACL verification. */
readonly windowsAclVerifier?: WindowsConfigAclVerifier;
/** Test seam for batched Windows DACL verification of one stable boundary. */
readonly windowsAclPathsVerifier?: WindowsConfigAclPathsVerifier;
/** Test-only opaque diagnostic observer for one catalog invocation. */
readonly candidateStageObserver?: ConsoleConfigCatalogCandidateStageObserver;
/** Test-only comparison of number and BigInt file-handle identities. */
Expand All @@ -34,6 +40,8 @@ export interface ConsoleConfigCatalogDiscoveryOptions {

export type WindowsConfigAclVerifier = (path: string, kind: "file" | "directory") => Promise<boolean>;

export type WindowsConfigAclPathsVerifier = (paths: readonly WindowsPrivatePath[]) => Promise<boolean>;

/** Opaque test-only stages for diagnosing a rejected catalog candidate. */
export type ConsoleConfigCatalogCandidateStage =
| "acl"
Expand Down Expand Up @@ -81,6 +89,10 @@ export type ConsoleConfigCatalogCandidateIdentityDiagnosticObserver = (

type TrustedConfigurationFileHandle = Awaited<ReturnType<typeof open>>;

interface WindowsCatalogAclBoundary {
verified: boolean;
}

function observeCandidateStage(
observer: ConsoleConfigCatalogCandidateStageObserver | undefined,
candidateIndex: number,
Expand Down Expand Up @@ -168,14 +180,19 @@ function defaultOwnerUid(platform: NodeJS.Platform): number | undefined {
}

async function hasTrustedWindowsAcl(
path: string,
kind: "file" | "directory",
paths: readonly WindowsPrivatePath[],
platform: NodeJS.Platform,
verifier: WindowsConfigAclVerifier
verifier: WindowsConfigAclVerifier,
pathsVerifier: WindowsConfigAclPathsVerifier,
useBatchedVerifier: boolean
): Promise<boolean> {
if (platform !== "win32") return true;
try {
return await verifier(path, kind);
if (useBatchedVerifier) return await pathsVerifier(paths);
for (const path of paths) {
if (!(await verifier(path.path, path.kind))) return false;
}
return true;
} catch {
return false;
}
Expand All @@ -185,7 +202,9 @@ async function trustedDirectory(
directory: string,
ownerUid: number | undefined,
platform: NodeJS.Platform,
windowsAclVerifier: WindowsConfigAclVerifier
windowsAclVerifier: WindowsConfigAclVerifier,
windowsAclPathsVerifier: WindowsConfigAclPathsVerifier,
useBatchedWindowsAclVerifier: boolean
): Promise<string | undefined> {
let observed: BigIntStats;
try {
Expand All @@ -200,7 +219,13 @@ async function trustedDirectory(
if (!isTrustedDirectory(resolved, ownerUid, platform) || !sameBigIntFileIdentity(observed, resolved)) {
throw new Error("unsafe configuration directory");
}
if (!(await hasTrustedWindowsAcl(canonical, "directory", platform, windowsAclVerifier))) {
if (!useBatchedWindowsAclVerifier && !(await hasTrustedWindowsAcl(
[{ path: canonical, kind: "directory" }],
platform,
windowsAclVerifier,
windowsAclPathsVerifier,
false
))) {
throw new Error("unsafe configuration directory");
}
return canonical;
Expand All @@ -227,6 +252,9 @@ async function readTrustedConfiguration(
ownerUid: number | undefined,
platform: NodeJS.Platform,
windowsAclVerifier: WindowsConfigAclVerifier,
windowsAclPathsVerifier: WindowsConfigAclPathsVerifier,
useBatchedWindowsAclVerifier: boolean,
windowsAclBoundary: WindowsCatalogAclBoundary,
candidateIndex: number,
candidateStageObserver: ConsoleConfigCatalogCandidateStageObserver | undefined,
candidateIdentityObserver: ConsoleConfigCatalogCandidateIdentityDiagnosticObserver | undefined
Expand All @@ -244,10 +272,20 @@ async function readTrustedConfiguration(
if (!isWithin(directory, canonical)) return undefined;
const resolved = await stat(canonical, { bigint: true });
if (!isTrustedFile(resolved, ownerUid, platform) || !sameBigIntFileIdentity(observed, resolved)) return undefined;
if (!(await hasTrustedWindowsAcl(canonical, "file", platform, windowsAclVerifier))) {
const aclPaths: readonly WindowsPrivatePath[] = useBatchedWindowsAclVerifier
? [{ path: directory, kind: "directory" }, { path: canonical, kind: "file" }]
: [{ path: canonical, kind: "file" }];
if (!(await hasTrustedWindowsAcl(
aclPaths,
platform,
windowsAclVerifier,
windowsAclPathsVerifier,
useBatchedWindowsAclVerifier
))) {
observeCandidateStage(candidateStageObserver, candidateIndex, "acl", "rejected");
return undefined;
}
if (useBatchedWindowsAclVerifier) windowsAclBoundary.verified = true;
observeCandidateStage(candidateStageObserver, candidateIndex, "acl", "success");

let handle: TrustedConfigurationFileHandle;
Expand Down Expand Up @@ -353,11 +391,22 @@ export async function discoverConsoleConfigCatalog(
const platform = options.platform ?? process.platform;
const ownerUid = options.ownerUid ?? defaultOwnerUid(platform);
const windowsAclVerifier = options.windowsAclVerifier ?? verifyWindowsConfigPathSecurity;
const windowsAclPathsVerifier = options.windowsAclPathsVerifier ?? verifyWindowsConfigPathsSecurity;
const useBatchedWindowsAclVerifier = platform === "win32" && (
options.windowsAclPathsVerifier !== undefined || options.windowsAclVerifier === undefined
);
const candidateStageObserver = options.candidateStageObserver;
const candidateIdentityObserver = options.candidateIdentityObserver;
let directory: string | undefined;
try {
directory = await trustedDirectory(resolve(options.configDirectory), ownerUid, platform, windowsAclVerifier);
directory = await trustedDirectory(
resolve(options.configDirectory),
ownerUid,
platform,
windowsAclVerifier,
windowsAclPathsVerifier,
useBatchedWindowsAclVerifier
);
} catch {
return {
catalog: { source: "standard-config-directory", discoveryState: "unavailable", configurations: [] },
Expand All @@ -371,6 +420,10 @@ export async function discoverConsoleConfigCatalog(
};
}

// The default Windows path verifies the canonical directory and each
// candidate together below. Names are not surfaced until that bounded
// trusted boundary succeeds; an empty or rejected catalog still verifies
// the directory alone before it can report readiness.
let names: readonly string[];
try {
names = (await readdir(directory)).filter((name) => configurationFileName.test(name)).sort((left, right) => left.localeCompare(right));
Expand All @@ -384,6 +437,7 @@ export async function discoverConsoleConfigCatalog(
const identities = new Set<string>();
const numberIdentities = candidateIdentityObserver === undefined ? undefined : new Set<string>();
const configurations: DiscoveredConsoleConfiguration[] = [];
const windowsAclBoundary: WindowsCatalogAclBoundary = { verified: !useBatchedWindowsAclVerifier };
for (const [candidateIndex, name] of names.entries()) {
try {
const discovered = await readTrustedConfiguration(
Expand All @@ -392,6 +446,9 @@ export async function discoverConsoleConfigCatalog(
ownerUid,
platform,
windowsAclVerifier,
windowsAclPathsVerifier,
useBatchedWindowsAclVerifier,
windowsAclBoundary,
candidateIndex,
candidateStageObserver,
candidateIdentityObserver
Expand Down Expand Up @@ -441,6 +498,18 @@ export async function discoverConsoleConfigCatalog(
// A malformed, raced, or untrusted candidate is never a Console entry.
}
}
if (useBatchedWindowsAclVerifier && !windowsAclBoundary.verified && !(await hasTrustedWindowsAcl(
[{ path: directory, kind: "directory" }],
platform,
windowsAclVerifier,
windowsAclPathsVerifier,
true
))) {
return {
catalog: { source: "standard-config-directory", discoveryState: "unavailable", configurations: [] },
configurations: []
};
}
configurations.sort((left, right) =>
left.metadata.name.localeCompare(right.metadata.name) || left.metadata.id.localeCompare(right.metadata.id)
);
Expand Down
44 changes: 42 additions & 2 deletions tests/console-dashboard-application-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
discoverConsoleConfigCatalog,
sameBigIntFileIdentity,
type ConsoleConfigCatalogCandidateIdentityDiagnosticEvent,
type ConsoleConfigCatalogCandidateStageEvent
type ConsoleConfigCatalogCandidateStageEvent,
type ConsoleConfigCatalogDiscoveryOptions
} from "../src/console/console-config-catalog.js";
import { ConsoleDashboardApplicationService } from "../src/console/console-dashboard-application-service.js";
import { buildPresetConfig } from "../src/config/presets.js";
Expand Down Expand Up @@ -1087,6 +1088,45 @@ describe("Console dashboard application service", () => {
});
});

it("batches Windows ACL verification for a trusted directory and candidate", async () => {
const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-windows-acl-batch-"));
temporaryDirectories.push(root);
const directory = await createPrivateConsoleDirectory(root);
const configPath = join(directory, "gsc.json");
await writeConfig(configPath, {
version: "3",
name: "gsc",
defaultProfile: "default",
upstream: { transport: "stdio", command: "node", args: [] },
profiles: { default: {} }
});

const legacyCalls: Array<{ readonly path: string; readonly kind: "file" | "directory" }> = [];
const batches: Array<readonly { readonly path: string; readonly kind: "file" | "directory" }[]> = [];
const options: ConsoleConfigCatalogDiscoveryOptions = {
configDirectory: directory,
platform: "win32" as const,
windowsAclVerifier: async (path: string, kind: "file" | "directory") => {
legacyCalls.push({ path, kind });
return false;
},
windowsAclPathsVerifier: async (paths) => {
batches.push(paths);
return true;
}
};

await expect(discoverConsoleConfigCatalog(options)).resolves.toMatchObject({
catalog: { discoveryState: "ready", configurations: [{ name: "gsc" }] }
});
const [canonicalDirectory, canonicalConfigPath] = await Promise.all([realpath(directory), realpath(configPath)]);
expect(legacyCalls).toEqual([]);
expect(batches).toEqual([[
{ path: canonicalDirectory, kind: "directory" },
{ path: canonicalConfigPath, kind: "file" }
]]);
});

it.skipIf(process.platform === "win32")("revalidates a selected configuration before any later Console operation", async () => {
const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-revalidate-"));
temporaryDirectories.push(directory);
Expand Down
32 changes: 18 additions & 14 deletions tests/console-windows-first-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,13 @@ describe("Console Windows first-run boundary", () => {

expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledWith(parent, configDirectory);
// Catalog discovery independently verifies the created configuration after
// publication. The creator must not add a second directory probe before it.
expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2);
expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, expect.stringMatching(/[/\\]miftah$/u), "directory");
expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah\.json$/u), "file");
expect(aclMocks.verifyPaths).not.toHaveBeenCalled();
// publication, but it keeps the directory and file in one trusted boundary.
expect(aclMocks.verifyPath).not.toHaveBeenCalled();
expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(1);
expect(aclMocks.verifyPaths).toHaveBeenCalledWith([
{ path: expect.stringMatching(/[/\\]miftah$/u), kind: "directory" },
{ path: expect.stringMatching(/[/\\]miftah\.json$/u), kind: "file" }
]);
expect(aclMocks.writePrivateFile).toHaveBeenCalledWith(configPath, expect.any(String));
});

Expand Down Expand Up @@ -193,13 +195,15 @@ describe("Console Windows first-run boundary", () => {
scopes: ["openid"]
})).resolves.toMatchObject({ changed: true, write: true });

expect(aclMocks.verifyPaths).toHaveBeenCalledWith([
expect(aclMocks.verifyPaths).toHaveBeenNthCalledWith(1, [
{ path: parent, kind: "directory" },
{ path: configDirectory, kind: "directory" }
]);
expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2);
expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, expect.stringMatching(/[/\\]miftah$/u), "directory");
expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah\.json$/u), "file");
expect(aclMocks.verifyPaths).toHaveBeenNthCalledWith(2, [
{ path: expect.stringMatching(/[/\\]miftah$/u), kind: "directory" },
{ path: expect.stringMatching(/[/\\]miftah\.json$/u), kind: "file" }
]);
expect(aclMocks.verifyPath).not.toHaveBeenCalled();
});

it("fails closed before audit or config creation when the standard directory cannot be verified", async () => {
Expand Down Expand Up @@ -287,8 +291,8 @@ describe("Console Windows first-run boundary", () => {
expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledTimes(2);
expect(aclMocks.secureFile).toHaveBeenCalledOnce();
expect(aclMocks.writePrivateFile).toHaveBeenCalledOnce();
expect(aclMocks.verifyPath).toHaveBeenCalledTimes(5);
expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(3);
expect(aclMocks.verifyPath).toHaveBeenCalledOnce();
expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(5);
});

it("attributes Windows ACL helper launches across the complete first-run draft lifecycle", async () => {
Expand Down Expand Up @@ -343,9 +347,9 @@ describe("Console Windows first-run boundary", () => {
expect({ afterSave, afterLoad, afterPublication, afterDraftRead, afterConfiguredRead }).toEqual({
afterSave: { create: 0, createInParent: 1, secure: 1, write: 0, verify: 0, verifyBatch: 0 },
afterLoad: { create: 0, createInParent: 1, secure: 1, write: 0, verify: 0, verifyBatch: 1 },
afterPublication: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 3, verifyBatch: 2 },
afterDraftRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 3, verifyBatch: 3 },
afterConfiguredRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 5, verifyBatch: 3 }
afterPublication: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 1, verifyBatch: 3 },
afterDraftRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 1, verifyBatch: 4 },
afterConfiguredRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 1, verifyBatch: 5 }
});
});

Expand Down