fix(auth): restore permission boundary safeguards
This commit is contained in:
@@ -22,3 +22,23 @@ export function requirePermission(
|
||||
if (hasPermission(principal, permission)) return principal;
|
||||
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
}
|
||||
|
||||
/** Permit non-browser clients and browsers whose declared origin matches the request host. */
|
||||
export function requireSameOriginOrNonBrowser(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): FastifyReply | undefined {
|
||||
const origin = request.headers.origin;
|
||||
if (origin === undefined) return undefined;
|
||||
if (typeof origin !== "string" || typeof request.headers.host !== "string") {
|
||||
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
}
|
||||
try {
|
||||
const supplied = new URL(origin);
|
||||
const expected = new URL(`${request.protocol}://${request.headers.host}`);
|
||||
if (supplied.origin === expected.origin) return undefined;
|
||||
} catch {
|
||||
// Invalid browser origins are forbidden below.
|
||||
}
|
||||
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import {
|
||||
isPrincipalContext,
|
||||
requirePermission,
|
||||
requireSameOriginOrNonBrowser,
|
||||
} from "../auth/authorization.js";
|
||||
import { PiManagementError, type PiManagementService } from "../pi/management.js";
|
||||
|
||||
export function piManagementRoutes(
|
||||
@@ -26,6 +30,10 @@ async function run<T>(
|
||||
): Promise<T | FastifyReply> {
|
||||
const principal = requirePermission(request, reply, "pi.manage");
|
||||
if (!isPrincipalContext(principal)) return principal;
|
||||
if (principal.issuer === "local" && isWrite(request.method)) {
|
||||
const csrfDenied = requireSameOriginOrNonBrowser(request, reply);
|
||||
if (csrfDenied) return csrfDenied;
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
@@ -36,3 +44,7 @@ async function run<T>(
|
||||
return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" });
|
||||
}
|
||||
}
|
||||
|
||||
function isWrite(method: string): boolean {
|
||||
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@ export function sessionRoutes(
|
||||
admissionLeases.set(req, release);
|
||||
});
|
||||
app.addHook("preHandler", async (req, reply) => {
|
||||
if (req.url === "/runtime/prewarm" || req.url === "/sessions" || req.url.startsWith("/sessions/")) {
|
||||
const pathname = req.url.split("?", 1)[0];
|
||||
if (pathname === "/runtime/prewarm" || pathname === "/sessions" || pathname.startsWith("/sessions/")) {
|
||||
const principal = requirePermission(req, reply, "session.use");
|
||||
if (!isPrincipalContext(principal)) return principal;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ThtRunner } from "../tht/tht-runner.js";
|
||||
import type { PrincipalContext } from "../auth/principal.js";
|
||||
import type { Settings } from "../settings/settings-store.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
|
||||
export function sqlRoutes(app: FastifyInstance, deps: {
|
||||
tht: ThtRunner; getSettings: (principal: PrincipalContext) => Promise<Settings>;
|
||||
@@ -13,11 +13,16 @@ export function sqlRoutes(app: FastifyInstance, deps: {
|
||||
const runner = deps.tht as any;
|
||||
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
|
||||
};
|
||||
const readPrincipal = (principal: PrincipalContext): PrincipalContext => ({
|
||||
...principal,
|
||||
// The harness still consumes this compatibility bit; it must never exceed session.read_all.
|
||||
isAdmin: hasPermission(principal, "session.read_all"),
|
||||
});
|
||||
const isNotFound = (error: unknown) => /not found|non trovata|inesistente|404/i.test(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
const locate = async (principal: PrincipalContext, id: string, legacyWorkspace?: string) => {
|
||||
const runner = runnerFor(principal);
|
||||
const runner = runnerFor(readPrincipal(principal));
|
||||
if (typeof runner.sessionShow !== "function") return { manifest: {}, workspace: legacyWorkspace };
|
||||
const registry = deps.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
const revisions = typeof registry.listRetainedSnapshots === "function"
|
||||
@@ -65,7 +70,7 @@ export function sqlRoutes(app: FastifyInstance, deps: {
|
||||
return reply.code(503).send({ error: "session storage is unavailable" });
|
||||
}
|
||||
try {
|
||||
return await runnerFor(principal).sqlPreview(id, { limit, offset }, workspace);
|
||||
return await runnerFor(readPrincipal(principal)).sqlPreview(id, { limit, offset }, workspace);
|
||||
} catch (error: any) {
|
||||
return reply.code(500).send({ error: error.message ?? String(error) });
|
||||
}
|
||||
@@ -87,7 +92,7 @@ export function sqlRoutes(app: FastifyInstance, deps: {
|
||||
return reply.code(503).send({ error: "session storage is unavailable" });
|
||||
}
|
||||
try {
|
||||
return await runnerFor(principal).sqlExport(id, workspace);
|
||||
return await runnerFor(readPrincipal(principal)).sqlExport(id, workspace);
|
||||
} catch (error: any) {
|
||||
return reply.code(500).send({ error: error.message ?? String(error) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user