fix(auth): restore permission boundary safeguards

This commit is contained in:
2026-08-16 18:03:36 +02:00
parent 2e0489ce22
commit f99bddcdf0
8 changed files with 126 additions and 14 deletions
+19 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "vitest";
import Fastify from "fastify";
import { authPreHandler, getPrincipal } from "../src/auth/auth.js";
import { hasPermission } from "../src/auth/authorization.js";
import { hasPermission, requireSameOriginOrNonBrowser } from "../src/auth/authorization.js";
import type { PrincipalContext } from "../src/auth/principal.js";
import type { Permission } from "../src/auth/types.js";
@@ -54,3 +54,21 @@ test("compatibility adapters derive roles and permissions before routes inspect
expect(elevated.json()).toMatchObject({ roles: ["admin"], isAdmin: true });
expect(malformed.json()).toMatchObject({ roles: ["user"], isAdmin: false });
});
test("same-origin guard permits non-browser and same-origin calls but rejects a cross-origin browser", async () => {
const app = Fastify();
app.get("/guard", async (request, reply) => {
const denied = requireSameOriginOrNonBrowser(request, reply);
return denied ?? { ok: true };
});
expect((await app.inject({ method: "GET", url: "/guard" })).statusCode).toBe(200);
expect((await app.inject({
method: "GET", url: "/guard", headers: { host: "127.0.0.1:8080", origin: "http://127.0.0.1:8080" },
})).statusCode).toBe(200);
const denied = await app.inject({
method: "GET", url: "/guard", headers: { host: "127.0.0.1:8080", origin: "https://evil.example" },
});
expect(denied.statusCode).toBe(403);
expect(denied.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
});
+7 -7
View File
@@ -105,9 +105,9 @@ test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () =
}
});
// Route authorization is permission-based; loopback none-mode derives its local admin principal
// from trusted installation configuration rather than a browser Origin check.
test("loopback-only management accepts cross-origin writes for its local administrator", async () => {
// A local implicit administrator has pi.manage, but a browser origin still cannot borrow that
// authority to mutate local configuration or trigger provider work.
test("loopback-only management rejects cross-origin writes for its local administrator", async () => {
const service = fakeService();
const app = appWith(service);
try {
@@ -121,10 +121,10 @@ test("loopback-only management accepts cross-origin writes for its local adminis
headers: { host: "127.0.0.1:8080", origin: "https://evil.example" },
});
expect(configured.statusCode).toBe(200);
expect(smoke.statusCode).toBe(200);
expect(service.configure).toHaveBeenCalledOnce();
expect(service.test).toHaveBeenCalledOnce();
expect(configured.statusCode).toBe(403);
expect(smoke.statusCode).toBe(403);
expect(service.configure).not.toHaveBeenCalled();
expect(service.test).not.toHaveBeenCalled();
} finally {
await app.close();
}
+25
View File
@@ -10,6 +10,8 @@ import { SseHub } from "../src/sse/sse-hub.js";
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.js";
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
import { validateDeclarativePiConfig } from "../src/pi/managed-config.js";
import Fastify from "fastify";
import { sessionRoutes } from "../src/routes/sessions.js";
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
@@ -99,6 +101,29 @@ test("upstream requests without a principal fail before a Pi runtime can be crea
expect(created).toBe(false);
});
test("GET /sessions with a query still requires session.use", async () => {
const app = Fastify();
app.addHook("preHandler", async (request) => {
request.principal = { issuer: "oidc", subject: "no-role", roles: [], permissions: [], isAdmin: false };
});
sessionRoutes(app, {
mgr: { get: () => undefined } as any,
tht: { sessionList: async () => [] } as any,
hub: {} as any,
getSettings: async () => ({}),
readiness: {} as any,
listModels: async () => [],
workspaceRegistry: { list: async () => [] } as any,
workspaceRuntimeSupport: () => true,
maintenanceBarrier: new MaintenanceBarrier(),
});
const response = await app.inject({ method: "GET", url: "/sessions?scope=mine" });
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
});
test("maintenance rejects new and resumed session admission without interrupting running sessions", async () => {
const maintenanceBarrier = new MaintenanceBarrier();
await maintenanceBarrier.activate();
+31
View File
@@ -1,6 +1,8 @@
import { test, expect, vi } from "vitest";
import Fastify from "fastify";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { sqlRoutes } from "../src/routes/sql.js";
// ---------------------------------------------------------------------------
// SQL routes
@@ -29,6 +31,35 @@ test("POST /sessions/:id/sql/preview returns rows from injected thtRunner stub",
expect(res.json()).toEqual(previewResult);
});
test("SQL lookup and execution derive harness admin compatibility from session.read_all", async () => {
const seen: boolean[] = [];
const app = Fastify();
app.addHook("preHandler", async (request) => {
request.principal = {
issuer: "portal", subject: "inconsistent", roles: ["user"], permissions: ["session.use"], isAdmin: true,
};
});
const runner = {
sessionShow: async () => ({ id: "s1" }),
sqlPreview: async () => ({ columns: [], rows: [], execution_ms: 0, truncated: false }),
};
sqlRoutes(app, {
tht: {
withPrincipal: (principal: { isAdmin: boolean }) => {
seen.push(principal.isAdmin);
return runner;
},
} as any,
getSettings: async () => ({ workspace: "legacy" }),
workspaceRegistry: { list: async () => [] } as any,
});
const response = await app.inject({ method: "POST", url: "/sessions/s1/sql/preview", payload: {} });
expect(response.statusCode).toBe(200);
expect(seen).toEqual([false, false]);
});
test("POST /sessions/:id/sql/preview passes limit and offset to thtRunner", async () => {
let captured: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {