fix(authz): stop authorizeDecision leaking policy names in 403 bodies
This commit is contained in:
@@ -77,14 +77,26 @@ export function allDecisions<S, R>(...policies: DecisionPolicy<S, R>[]): Decisio
|
||||
return allow("all policies passed");
|
||||
};
|
||||
}
|
||||
export interface AuthorizeDecisionOptions {
|
||||
/**
|
||||
* Include `reason` and `policy` in the 403 body. Off by default: policy
|
||||
* names describe internal authorization structure and should not reach an
|
||||
* unauthenticated caller.
|
||||
*/
|
||||
exposeReason?: boolean;
|
||||
}
|
||||
|
||||
export function authorizeDecision(
|
||||
evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
|
||||
options: AuthorizeDecisionOptions = {},
|
||||
): Middleware {
|
||||
return async (ctx, next) => {
|
||||
const result = await evaluate(ctx);
|
||||
if (result.allowed) return next();
|
||||
return Response.json(
|
||||
{ ok: false, error: "Forbidden", reason: result.reason, policy: result.policy },
|
||||
options.exposeReason
|
||||
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
|
||||
: { ok: false, error: "Forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import {
|
||||
defineRbac,
|
||||
hasRole,
|
||||
authorize,
|
||||
authorizeDecision,
|
||||
requireRole,
|
||||
requirePermission,
|
||||
any,
|
||||
@@ -128,3 +129,32 @@ test("owner() denies rather than matching two absent ids", async () => {
|
||||
expect((await customOwns({ id: "u1" }, { ownerId: "u1" })).allowed).toBe(true);
|
||||
expect((await customOwns({ id: "u1" }, {})).allowed).toBe(false);
|
||||
});
|
||||
|
||||
describe("authorizeDecision disclosure", () => {
|
||||
const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context;
|
||||
const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" });
|
||||
|
||||
test("does not leak reason or policy by default", async () => {
|
||||
const res = await authorizeDecision(denier)(ctx, async () => new Response("ok"));
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
|
||||
});
|
||||
|
||||
test("exposeReason opts back in", async () => {
|
||||
const res = await authorizeDecision(denier, { exposeReason: true })(
|
||||
ctx,
|
||||
async () => new Response("ok"),
|
||||
);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.reason).toBe("secret internal rule");
|
||||
expect(body.policy).toBe("isVip");
|
||||
});
|
||||
|
||||
test("still calls next when allowed", async () => {
|
||||
const res = await authorizeDecision(async () => ({ allowed: true }))(
|
||||
ctx,
|
||||
async () => new Response("passed"),
|
||||
);
|
||||
expect(await res.text()).toBe("passed");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user