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");
|
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 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user