fix(backend): harden principal child isolation

This commit is contained in:
User
2026-07-16 18:38:40 +02:00
parent 458eb13c89
commit b454fb478b
9 changed files with 167 additions and 26 deletions
+23
View File
@@ -1,6 +1,10 @@
import { test, expect } from "vitest";
import Fastify from "fastify";
import { authPreHandler, getPrincipal } from "../src/auth/auth.js";
import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expandLocalHome, localPrincipal } from "../src/auth/principal.js";
test("local mode resolves a stable local principal", async () => {
const app = Fastify();
@@ -46,3 +50,22 @@ test("upstream mode accepts only normalized proxy principal headers", async () =
issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true,
});
});
test("local identity expands tilde homes and restores private POSIX permissions", () => {
expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test");
expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester");
const home = mkdtempSync(join(tmpdir(), "thoth-principal-"));
chmodSync(home, 0o755);
const previous = process.env.THT_HOME;
process.env.THT_HOME = home;
try {
localPrincipal();
if (process.platform !== "win32") {
expect(statSync(home).mode & 0o777).toBe(0o700);
expect(statSync(join(home, "identity.json")).mode & 0o777).toBe(0o600);
}
} finally {
if (previous === undefined) delete process.env.THT_HOME; else process.env.THT_HOME = previous;
rmSync(home, { recursive: true, force: true });
}
});
+25
View File
@@ -32,3 +32,28 @@ test("production spawnFn launches `pi --mode rpc` with no --approve (pi 0.73 dro
expect(args).not.toContain("--approve");
mgr.teardown("s1");
});
test("Pi child replaces stale principal env and omits absent display names", async () => {
const saved = Object.fromEntries([
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
].map((key) => [key, process.env[key]]));
Object.assign(process.env, {
THT_PRINCIPAL_ISSUER: "stale", THT_PRINCIPAL_SUBJECT: "stale", THT_PRINCIPAL_DISPLAY_NAME: "stale",
THT_PRINCIPAL_IS_ADMIN: "true",
});
try {
(nodeSpawn as any).mockClear();
const mgr = new PiProcessManager(loadConfig({}));
await mgr.spawnFor("s-principal", { principal: { issuer: "portal", subject: "42", isAdmin: false } });
const env = (nodeSpawn as any).mock.calls[0][2].env;
expect(env).toMatchObject({
THT_PRINCIPAL_ISSUER: "portal", THT_PRINCIPAL_SUBJECT: "42", THT_PRINCIPAL_IS_ADMIN: "false",
});
expect(env).not.toHaveProperty("THT_PRINCIPAL_DISPLAY_NAME");
mgr.teardown("s-principal");
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
});
+21
View File
@@ -1282,6 +1282,27 @@ test("POST /sessions/:id/rename calls setName", async () => {
expect(arg).toEqual({ id: "s1", name: "N" });
});
test("rename authorizes and mutates through the same selected workspace", async () => {
const workspaces: string[] = [];
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
withPrincipal: () => ({
sessionShow: async (_id: string, workspace: string) => { workspaces.push(`show:${workspace}`); return { id: "s1" }; },
setName: async (_id: string, _name: string, workspace: string) => { workspaces.push(`set:${workspace}`); },
}),
} as any,
getSettings: () => ({ workspace: "tenant-a" }) as any,
});
const res = await app.inject({
method: "POST", url: "/sessions/s1/rename", payload: { name: "N" },
headers: {
"x-thoth-principal-issuer": "portal", "x-thoth-principal-subject": "42", "x-thoth-is-admin": "false",
},
});
expect(res.statusCode).toBe(204);
expect(workspaces).toEqual(["show:tenant-a", "set:tenant-a"]);
});
test("POST /sessions/:id/group calls setGroup", async () => {
let arg: any;
const app = mutApp({ setGroup: async (id: string, group: string) => { arg = { id, group }; } });
+39 -12
View File
@@ -83,6 +83,31 @@ test("run omits ambient THT_DATA_ROOT when config does not provide one", async (
}
});
test("principal-bound tht child replaces stale principal env and omits an absent display name", async () => {
const saved = Object.fromEntries([
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
].map((key) => [key, process.env[key]]));
Object.assign(process.env, {
THT_PRINCIPAL_ISSUER: "stale-issuer", THT_PRINCIPAL_SUBJECT: "stale-subject",
THT_PRINCIPAL_DISPLAY_NAME: "Stale Name", THT_PRINCIPAL_IS_ADMIN: "true",
});
try {
(spawn as any).mockClear();
const runner = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" })
.withPrincipal({ issuer: "portal", subject: "42", isAdmin: false });
await runner.run(["session", "list", "--json"]);
const env = (spawn as any).mock.calls[0][2].env;
expect(env).toMatchObject({
THT_PRINCIPAL_ISSUER: "portal", THT_PRINCIPAL_SUBJECT: "42", THT_PRINCIPAL_IS_ADMIN: "false",
});
expect(env).not.toHaveProperty("THT_PRINCIPAL_DISPLAY_NAME");
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
});
test("run with exit != 0 propagates error with stderr", async () => {
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" });
@@ -137,23 +162,25 @@ test("setName builds the right argv", async () => {
const calls: string[][] = [];
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
r.run = async (args) => { calls.push(args); return { code: 0, stdout: "", stderr: "" }; };
await r.setName("sid", "Mio nome");
await r.setName("sid", "Mio nome", "tenant-a");
expect(calls[0]).toEqual(["session", "set-name", "sid", "--name", "Mio nome"]);
});
test("setGroup / archive / unarchive / deleteSession build argv", async () => {
const calls: string[][] = [];
test("mutation and document commands retain their requested workspace", async () => {
const calls: Array<{ args: string[]; workspace?: string }> = [];
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
r.run = async (args) => { calls.push(args); return { code: 0, stdout: "", stderr: "" }; };
await r.setGroup("sid", "G1");
await r.archive("sid");
await r.unarchive("sid");
await r.deleteSession("sid");
r.run = async (args, workspace) => { calls.push({ args, workspace }); return { code: 0, stdout: "[]", stderr: "" }; };
await r.setGroup("sid", "G1", "tenant-a");
await r.archive("sid", "tenant-a");
await r.unarchive("sid", "tenant-a");
await r.documents("sid", "tenant-a");
await r.deleteSession("sid", "tenant-a");
expect(calls).toEqual([
["session", "set-group", "sid", "--group", "G1"],
["session", "archive", "sid"],
["session", "unarchive", "sid"],
["session", "delete", "sid"],
{ args: ["session", "set-group", "sid", "--group", "G1"], workspace: "tenant-a" },
{ args: ["session", "archive", "sid"], workspace: "tenant-a" },
{ args: ["session", "unarchive", "sid"], workspace: "tenant-a" },
{ args: ["session", "documents", "sid", "--json"], workspace: "tenant-a" },
{ args: ["session", "delete", "sid"], workspace: "tenant-a" },
]);
});