Skip to content
Closed
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
83 changes: 83 additions & 0 deletions packages/server/src/__tests__/tokens-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,89 @@ test("POST /v1/tokens/workspace-path", async (t) => {
});
});

await t.test("allows provider-subtree scopes that cover requested writeback paths", async () => {
const { app, authHeaders } = await createHarness({
authClaims: {
scopes: [
"relayauth:api-key:manage:*",
"relayfile:fs:read:*",
"relayfile:fs:write:*",
],
},
});

const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", {
body: {
workspaceId: "ws_tokens_route",
paths: ["/linear/issues/123.json"],
scopes: [
"relayfile:fs:read:/linear/**",
"relayfile:fs:write:/linear/**",
],
},
headers: authHeaders,
});

const body = await assertJsonResponse<WorkspacePathTokenPair>(response, 201);
assert.deepEqual(body.paths, ["/linear/issues/123.json"]);

const accessClaims = decodeJwtJsonSegment<RelayAuthTokenClaims>(body.accessToken, 1);
assert.deepEqual(accessClaims.scopes, [
"relayfile:fs:read:/linear/*",
"relayfile:fs:write:/linear/*",
]);
});

await t.test("rejects provider-subtree scopes that do not cover requested paths", async () => {
const { app, authHeaders } = await createHarness({
authClaims: {
scopes: [
"relayauth:api-key:manage:*",
"relayfile:fs:write:*",
],
},
});

const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", {
body: {
workspaceId: "ws_tokens_route",
paths: ["/linear/issues/123.json"],
scopes: ["relayfile:fs:write:/github/**"],
},
headers: authHeaders,
});

await assertJsonResponse<ErrorBody>(response, 400, (body) => {
assert.equal(body.code, "invalid_scope");
});
});

await t.test("continues rejecting whole-tree path-token scopes", async () => {
const { app, authHeaders } = await createHarness({
authClaims: {
scopes: [
"relayauth:api-key:manage:*",
"relayfile:fs:write:*",
],
},
});

for (const scopePath of ["*", "/", "/*", "/**"]) {
const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", {
body: {
workspaceId: "ws_tokens_route",
paths: ["/linear/issues/123.json"],
scopes: [`relayfile:fs:write:${scopePath}`],
},
headers: authHeaders,
});

await assertJsonResponse<ErrorBody>(response, 400, (body) => {
assert.equal(body.code, "invalid_scope");
});
}
});

await t.test("rejects degenerate or traversal paths", async () => {
const { app, authHeaders } = await createHarness({
authClaims: {
Expand Down
24 changes: 16 additions & 8 deletions packages/server/src/routes/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1490,15 +1490,23 @@ function normalizePathTokenScope(value: unknown): string | null {
}

function scopeWithinPaths(scope: string, paths: string[]): boolean {
return paths.some((path) => {
const readGrant = `relayfile:fs:read:${path}`;
const writeGrant = `relayfile:fs:write:${path}`;
try {
return matchScope(scope, [readGrant, writeGrant]);
} catch {
return false;
const match = /^relayfile:fs:(read|write):/.exec(scope);
if (!match) {
return false;
}

const operation = match[1];
const pathGrants = paths.map((path) => `relayfile:fs:${operation}:${path}`);

try {
if (pathGrants.some((grant) => matchScope(scope, [grant]))) {
return true;
}
});

return pathGrants.every((grant) => matchScope(grant, [scope]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject normalized root wildcard scopes

When a requested path-token scope uses duplicated slashes such as relayfile:fs:write://* or relayfile:fs:write:////, normalizePathTokenPath collapses it to /* after the explicit whole-tree checks. This new coverage check then accepts it because /* covers the requested path grant, so callers with a broad parent grant can mint a token scoped to the whole relayfile tree even though *, /, /*, and /** are intended to be rejected.

Useful? React with 👍 / 👎.

} catch {
return false;
}
}

function relayTokenPrefix(prefix: string | undefined): typeof RELAY_AGENT_TOKEN_PREFIX | typeof RELAY_PATH_TOKEN_PREFIX {
Expand Down
Loading