Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/proxyabl/__tests__/middleware.scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import type { Request, Response } from "express";
import type { ProxyablConfig } from "@gatewaystack/proxyabl-core";
import { runWithGatewayContext } from "@gatewaystack/request-context";
import { createProxyablMiddleware } from "../src/middleware.js";

/**
* Regression tests for the trailing-slash scope-bypass.
*
* The middleware derives the tool name from the request path and enforces the
* scopes configured for that tool. A caller WITHOUT the required scope must be
* rejected regardless of trailing/duplicate slashes in the path — previously a
* trailing slash yielded an empty final segment and skipped the check.
*/

const config: ProxyablConfig = {
toolScopes: { deploy: ["deploy:write"] },
} as ProxyablConfig;

function mockReqRes(path: string) {
const req = { path, header: () => "Bearer x" } as unknown as Request;
let statusCode = 0;
let body: any = undefined;
const res = {
status(code: number) {
statusCode = code;
return this;
},
json(payload: any) {
body = payload;
return this;
},
} as unknown as Response;
return { req, res, get: () => ({ statusCode, body }) };
}

/** Run the middleware inside a gateway context carrying the given scopes. */
async function invoke(path: string, scopes: string[]) {
const mw = createProxyablMiddleware(config);
const { req, res, get } = mockReqRes(path);
let nextCalled = false;
await runWithGatewayContext(
{ identity: { sub: "user-1", issuer: "https://idp.example", scopes } as any },
async () => {
await mw(req, res, () => {
nextCalled = true;
});
},
);
return { nextCalled, ...get() };
}

describe("proxyabl middleware — tool scope enforcement", () => {
it("allows a caller that holds the required scope", async () => {
const r = await invoke("/proxy/deploy", ["deploy:write"]);
expect(r.nextCalled).toBe(true);
});

it("rejects a caller lacking the required scope", async () => {
const r = await invoke("/proxy/deploy", []);
expect(r.nextCalled).toBe(false);
expect(r.statusCode).toBe(403);
});

it("still enforces scopes when the path has a trailing slash", async () => {
const r = await invoke("/proxy/deploy/", []);
expect(r.nextCalled).toBe(false);
expect(r.statusCode).toBe(403);
});

it("still enforces scopes when the path has duplicate slashes", async () => {
const r = await invoke("/proxy//deploy//", []);
expect(r.nextCalled).toBe(false);
expect(r.statusCode).toBe(403);
});
});
8 changes: 7 additions & 1 deletion packages/proxyabl/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ export function createProxyablMiddleware(config: ProxyablConfig) {
try {
const identity = await verifyBearerFromRequest(config, req);

const tool = req.path.split("/").pop();
// Derive the tool name from the last NON-EMPTY path segment. Using
// `.split("/").pop()` alone returns "" for a trailing slash (and other
// empty segments from "//"), which previously skipped the scope check
// entirely — a request to `/proxy/deploy/` would bypass the scopes
// configured for `deploy`. Filtering empties makes the check
// trailing-slash- and double-slash-invariant.
const tool = req.path.split("/").filter(Boolean).pop();
if (tool) {
ensureToolScopesForRequest(config, tool, identity.scopes);
}
Expand Down
Loading