fix(backend): harden principal child isolation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -10,6 +10,25 @@ export interface PrincipalContext {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const principalEnvKeys = [
|
||||
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
|
||||
] as const;
|
||||
|
||||
export function clearPrincipalEnvironment(env: NodeJS.ProcessEnv): void {
|
||||
for (const key of principalEnvKeys) delete env[key];
|
||||
}
|
||||
|
||||
export function expandLocalHome(path: string, home = homedir()): string {
|
||||
if (path === "~") return home;
|
||||
if (path.startsWith("~/")) return join(home, path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function harden(path: string, mode: number): void {
|
||||
if (process.platform === "win32") return;
|
||||
try { chmodSync(path, mode); } catch { /* best-effort parity with harness local storage */ }
|
||||
}
|
||||
|
||||
const invalid = (value: string) => value.length === 0 || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value);
|
||||
|
||||
function required(value: unknown): string | undefined {
|
||||
@@ -34,12 +53,14 @@ export function upstreamPrincipal(headers: Record<string, unknown>): PrincipalCo
|
||||
}
|
||||
|
||||
export function localPrincipal(): PrincipalContext {
|
||||
const home = process.env.THT_HOME ?? join(homedir(), ".thothii");
|
||||
const home = expandLocalHome(process.env.THT_HOME ?? join(homedir(), ".thothii"));
|
||||
const identityPath = join(home, "identity.json");
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
harden(home, 0o700);
|
||||
try {
|
||||
const stored = JSON.parse(readFileSync(identityPath, "utf8"));
|
||||
if (stored?.issuer === "local" && typeof stored.subject === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(stored.subject)) {
|
||||
harden(identityPath, 0o600);
|
||||
return { issuer: "local", subject: stored.subject, isAdmin: false };
|
||||
}
|
||||
throw new Error("invalid local identity");
|
||||
@@ -48,6 +69,7 @@ export function localPrincipal(): PrincipalContext {
|
||||
const principal = { issuer: "local", subject: randomUUID() };
|
||||
try {
|
||||
writeFileSync(identityPath, JSON.stringify(principal) + "\n", { mode: 0o600, flag: "wx" });
|
||||
harden(identityPath, 0o600);
|
||||
return { ...principal, isAdmin: false };
|
||||
} catch (writeError: any) {
|
||||
// Another local request won the identity creation race; always converge on its UUID.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SessionBridge } from "../bridge/session-bridge.js";
|
||||
import type { ThtRunner } from "../tht/tht-runner.js";
|
||||
import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
import { principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
||||
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
||||
|
||||
export interface SessionRuntime {
|
||||
rpc: RpcClient;
|
||||
@@ -55,6 +55,7 @@ export class PiProcessManager {
|
||||
credentialFile: this.cfg.modelApiKeyFile,
|
||||
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
|
||||
});
|
||||
clearPrincipalEnvironment(env);
|
||||
if (principal) Object.assign(env, principalEnvironment(principal));
|
||||
// The Thoth gate executes the deterministic `tht` CLI as a Pi tool. Give only
|
||||
// this managed session process the adapter values already loaded by the core
|
||||
|
||||
@@ -397,7 +397,7 @@ export function sessionRoutes(
|
||||
try {
|
||||
const settings = await d.getSettings(principal);
|
||||
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
|
||||
await runnerFor(principal).setName(id, (req.body as any).name);
|
||||
await runnerFor(principal).setName(id, (req.body as any).name, settings.workspace);
|
||||
} catch { return storageFailure(reply); }
|
||||
return reply.code(204).send();
|
||||
});
|
||||
@@ -407,7 +407,7 @@ export function sessionRoutes(
|
||||
try {
|
||||
const settings = await d.getSettings(principal);
|
||||
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
|
||||
await runnerFor(principal).setGroup(id, (req.body as any).group);
|
||||
await runnerFor(principal).setGroup(id, (req.body as any).group, settings.workspace);
|
||||
} catch { return storageFailure(reply); }
|
||||
return reply.code(204).send();
|
||||
});
|
||||
@@ -417,7 +417,7 @@ export function sessionRoutes(
|
||||
try {
|
||||
const settings = await d.getSettings(principal);
|
||||
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
|
||||
await runnerFor(principal).archive(id);
|
||||
await runnerFor(principal).archive(id, settings.workspace);
|
||||
} catch { return storageFailure(reply); }
|
||||
return reply.code(204).send();
|
||||
});
|
||||
@@ -427,7 +427,7 @@ export function sessionRoutes(
|
||||
try {
|
||||
const settings = await d.getSettings(principal);
|
||||
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
|
||||
await runnerFor(principal).unarchive(id);
|
||||
await runnerFor(principal).unarchive(id, settings.workspace);
|
||||
} catch { return storageFailure(reply); }
|
||||
return reply.code(204).send();
|
||||
});
|
||||
@@ -458,7 +458,7 @@ export function sessionRoutes(
|
||||
try {
|
||||
const settings = await d.getSettings(principal);
|
||||
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
|
||||
return await runnerFor(principal).documents(id);
|
||||
return await runnerFor(principal).documents(id, settings.workspace);
|
||||
} catch { return storageFailure(reply); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
||||
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
||||
|
||||
export interface ThtConfig {
|
||||
thtBin: string;
|
||||
@@ -68,6 +68,7 @@ export class ThtRunner {
|
||||
return new Promise((resolve) => {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete env.THT_DATA_ROOT;
|
||||
clearPrincipalEnvironment(env);
|
||||
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
|
||||
if (this.principal) Object.assign(env, principalEnvironment(this.principal));
|
||||
const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
|
||||
@@ -159,15 +160,15 @@ export class ThtRunner {
|
||||
closeSession(id: string, workspace?: string) { return this.ok(["session", "close", id], workspace); }
|
||||
failSession(id: string, workspace?: string) { return this.ok(["session", "fail", id], workspace); }
|
||||
reopenSession(id: string, workspace?: string) { return this.ok(["session", "reopen", id], workspace); }
|
||||
setName(id: string, name: string) { return this.ok(["session", "set-name", id, "--name", name]); }
|
||||
setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); }
|
||||
archive(id: string) { return this.ok(["session", "archive", id]); }
|
||||
unarchive(id: string) { return this.ok(["session", "unarchive", id]); }
|
||||
setName(id: string, name: string, workspace?: string) { return this.ok(["session", "set-name", id, "--name", name], workspace); }
|
||||
setGroup(id: string, group: string, workspace?: string) { return this.ok(["session", "set-group", id, "--group", group], workspace); }
|
||||
archive(id: string, workspace?: string) { return this.ok(["session", "archive", id], workspace); }
|
||||
unarchive(id: string, workspace?: string) { return this.ok(["session", "unarchive", id], workspace); }
|
||||
async deleteSession(id: string, workspace?: string) {
|
||||
const { code, stderr } = await this.run(["session", "delete", id], workspace);
|
||||
if (code !== 0) throw new Error(`tht session delete exit ${code}: ${stderr.trim()}`);
|
||||
}
|
||||
documents(id: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"]); }
|
||||
documents(id: string, workspace?: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"], workspace); }
|
||||
|
||||
preferencesGet(workspace?: string) { return this.json<Record<string, unknown>>(["session", "preferences", "get", "--json"], workspace); }
|
||||
async preferencesSet(preferences: Record<string, unknown>, workspace?: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user