fix(authz): stop authorizeDecision leaking policy names in 403 bodies

This commit is contained in:
2026-08-04 19:17:48 +05:30
parent 3f1fcd0d2d
commit e15422ed8d
2 changed files with 44 additions and 2 deletions
+13 -1
View File
@@ -77,14 +77,26 @@ export function allDecisions<S, R>(...policies: DecisionPolicy<S, R>[]): Decisio
return allow("all policies passed"); 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( export function authorizeDecision(
evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>, evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
options: AuthorizeDecisionOptions = {},
): Middleware { ): Middleware {
return async (ctx, next) => { return async (ctx, next) => {
const result = await evaluate(ctx); const result = await evaluate(ctx);
if (result.allowed) return next(); if (result.allowed) return next();
return Response.json( 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 }, { status: 403 },
); );
}; };
+31 -1
View File
@@ -1,9 +1,10 @@
import { test, expect } from "bun:test"; import { test, expect, describe } from "bun:test";
import { createContext } from "@wrnexus/core"; import { createContext } from "@wrnexus/core";
import { import {
defineRbac, defineRbac,
hasRole, hasRole,
authorize, authorize,
authorizeDecision,
requireRole, requireRole,
requirePermission, requirePermission,
any, 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" }, { ownerId: "u1" })).allowed).toBe(true);
expect((await customOwns({ id: "u1" }, {})).allowed).toBe(false); 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");
});
});