fix(backend): harden principal child isolation
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }; } });
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user