Skip to content

Commit 1892b9d

Browse files
dpaquetteCopilot
andauthored
Enforce org boundary on wiki page URL input (#1369)
## Summary The `wiki_get_page_content` tool accepts a full wiki page URL and previously used only the project/wiki path segments while **ignoring the organization** in the URL, silently issuing the request against the configured org. A URL pointing at a different organization would therefore return content from the configured org instead of erroring — a cross-organization boundary violation ## Changes - Add `getOrgFromUrl` helper in `src/utils.ts` that extracts the org from recognized Azure DevOps hosts only: - `https://dev.azure.com/{org}/...` -> first path segment - `https://{org}.visualstudio.com/...` -> host subdomain - any other host returns `null` (treated as a boundary violation) - result is normalized to lowercase for case-insensitive comparison - In `wiki_get_page_content`, reject any user-supplied URL whose org does not match the connected org (`connection.serverUrl`), returning an `isError` result without issuing the request. - Tests: unit tests for `getOrgFromUrl` (incl. non-ADO hosts, casing) and cross-org rejection tests for the wiki tool; updated existing same-org fixtures. ## Scope / risk The other wiki tools (`get_wiki`, `list_wikis`, `list_pages`, `get_page` metadata, `create_or_update_page`) build their URLs from `connection.serverUrl` and take only `wikiIdentifier`/`project`/`path` as input, so they cannot be redirected to another org and are unaffected. ## Validation - `npm run build` ✅ - Full test suite: 974 passing (148 in the touched utils + wiki suites) ✅ - `npm run eslint` ✅ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2f8250c commit 1892b9d

4 files changed

Lines changed: 146 additions & 13 deletions

File tree

src/tools/wiki.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import { WebApi } from "azure-devops-node-api";
66
import { z } from "zod";
77
import { WikiPagesBatchRequest } from "azure-devops-node-api/interfaces/WikiInterfaces.js";
8-
import { apiVersion, extractAdoStreamError } from "../utils.js";
8+
import { apiVersion, extractAdoStreamError, getOrgFromUrl } from "../utils.js";
99
import { createExternalContentResponse } from "../shared/content-safety.js";
1010

1111
const WIKI_TOOLS = {
@@ -222,6 +222,23 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise<stri
222222
return { content: [{ type: "text", text: `Error fetching wiki page content: ${parsed.error}` }], isError: true };
223223
}
224224

225+
// Guard against cross-organization requests: a user-supplied URL must target the
226+
// same organization the server is connected to. Otherwise the org segment in the
227+
// URL would be silently ignored and content fetched from the configured org instead.
228+
const configuredOrg = getOrgFromUrl(connection.serverUrl);
229+
const urlOrg = getOrgFromUrl(url);
230+
if (configuredOrg && urlOrg !== configuredOrg) {
231+
return {
232+
content: [
233+
{
234+
type: "text",
235+
text: `Error fetching wiki page content: The provided URL targets organization '${urlOrg ?? "unknown"}', which does not match the configured organization '${configuredOrg}'. Cross-organization requests are not allowed.`,
236+
},
237+
],
238+
isError: true,
239+
};
240+
}
241+
225242
resolvedProject = parsed.project;
226243
resolvedWiki = parsed.wikiIdentifier;
227244

src/utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,37 @@ export function extractAdoStreamError(content: string): string | null {
9696
return null;
9797
}
9898

99+
/**
100+
* Extracts the Azure DevOps organization identifier from a URL.
101+
*
102+
* Only recognized Azure DevOps hosts are accepted; any other host returns null
103+
* so that callers can treat unrecognized URLs as a boundary violation.
104+
*
105+
* Supports both modern and legacy organization URL forms:
106+
* - https://dev.azure.com/{org}/... -> org is the first path segment
107+
* - https://{org}.visualstudio.com/... -> org is the host subdomain
108+
*
109+
* @param url Any Azure DevOps URL (e.g. a wiki page link or a connection serverUrl).
110+
* @returns The lowercased organization name, or null if it cannot be determined.
111+
*/
112+
export function getOrgFromUrl(url: string): string | null {
113+
try {
114+
const u = new URL(url);
115+
const host = u.hostname.toLowerCase();
116+
if (host === "visualstudio.com" || host.endsWith(".visualstudio.com")) {
117+
const subdomain = host.split(".")[0];
118+
return subdomain && subdomain !== "visualstudio" ? subdomain : null;
119+
}
120+
if (host === "dev.azure.com" || host.endsWith(".dev.azure.com")) {
121+
const firstSegment = u.pathname.split("/").filter(Boolean)[0];
122+
return firstSegment ? firstSegment.toLowerCase() : null;
123+
}
124+
return null;
125+
} catch {
126+
return null;
127+
}
128+
}
129+
99130
/**
100131
* Convert a Node.js ReadableStream to a string.
101132
* Shared utility for consistent stream handling across tools.

test/src/tools/wiki.test.ts

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ describe("configureWikiTools", () => {
700700
};
701701
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
702702

703-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki?wikiVersion=GBmain&pagePath=%2FDocs%2FIntro";
703+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki?wikiVersion=GBmain&pagePath=%2FDocs%2FIntro";
704704
const result = await handler({ url });
705705

706706
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/Docs/Intro", undefined, undefined, true);
@@ -733,7 +733,7 @@ describe("configureWikiTools", () => {
733733
json: jest.fn().mockResolvedValue({ content: "# Page Title\nBody" }),
734734
});
735735

736-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title";
736+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki/123/Page-Title";
737737
const result = await handler({ url });
738738

739739
// Current implementation may fallback to root path stream retrieval
@@ -766,7 +766,7 @@ describe("configureWikiTools", () => {
766766
};
767767
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
768768

769-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/999/Some-Page";
769+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki/999/Some-Page";
770770
const result = await handler({ url });
771771

772772
// Implementation currently falls back to root path if path not resolved prior to fallback
@@ -780,7 +780,7 @@ describe("configureWikiTools", () => {
780780
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content");
781781
if (!call) throw new Error("wiki_get_page_content tool not registered");
782782
const [, , , handler] = call;
783-
const result = await handler({ url: "https://dev.azure.com/org/project/_wiki/wikis/wiki1?pagePath=%2FHome", wikiIdentifier: "wiki1", project: "project" });
783+
const result = await handler({ url: "https://dev.azure.com/testorg/project/_wiki/wikis/wiki1?pagePath=%2FHome", wikiIdentifier: "wiki1", project: "project" });
784784
expect(result.isError).toBe(true);
785785
expect(result.content[0].text).toContain("Provide either 'url' OR 'wikiIdentifier'");
786786
});
@@ -817,6 +817,59 @@ describe("configureWikiTools", () => {
817817
expect(result.content[0].text).toContain("Error fetching wiki page content: Invalid URL format");
818818
});
819819

820+
it("should reject a URL pointing to a different organization (dev.azure.com)", async () => {
821+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
822+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content");
823+
if (!call) throw new Error("wiki_get_page_content tool not registered");
824+
const [, , , handler] = call;
825+
826+
const url = "https://dev.azure.com/otherorg/project/_wiki/wikis/myWiki?pagePath=%2FHome";
827+
const result = await handler({ url });
828+
829+
expect(result.isError).toBe(true);
830+
expect(result.content[0].text).toContain("does not match the configured organization 'testorg'");
831+
expect(result.content[0].text).toContain("Cross-organization requests are not allowed");
832+
expect(mockWikiApi.getPageText).not.toHaveBeenCalled();
833+
});
834+
835+
it("should reject a legacy visualstudio.com URL pointing to a different organization", async () => {
836+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
837+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content");
838+
if (!call) throw new Error("wiki_get_page_content tool not registered");
839+
const [, , , handler] = call;
840+
841+
const url = "https://otherorg.visualstudio.com/project/_wiki/wikis/myWiki?pagePath=%2FHome";
842+
const result = await handler({ url });
843+
844+
expect(result.isError).toBe(true);
845+
expect(result.content[0].text).toContain("organization 'otherorg'");
846+
expect(mockWikiApi.getPageText).not.toHaveBeenCalled();
847+
});
848+
849+
it("should allow a URL that matches the configured organization regardless of casing", async () => {
850+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
851+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content");
852+
if (!call) throw new Error("wiki_get_page_content tool not registered");
853+
const [, , , handler] = call;
854+
855+
const mockStream = {
856+
setEncoding: jest.fn(),
857+
on: function (event: string, cb: (chunk?: unknown) => void) {
858+
if (event === "data") setImmediate(() => cb("same org content"));
859+
if (event === "end") setImmediate(() => cb());
860+
return this;
861+
},
862+
};
863+
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
864+
865+
const url = "https://dev.azure.com/TestOrg/project/_wiki/wikis/myWiki?pagePath=%2FHome";
866+
const result = await handler({ url });
867+
868+
expect(result.isError).toBeUndefined();
869+
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/Home", undefined, undefined, true);
870+
expect(result.content[0].text).toContain("same org content");
871+
});
872+
820873
it("should handle URL with pageId that returns 404", async () => {
821874
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
822875
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content");
@@ -832,7 +885,7 @@ describe("configureWikiTools", () => {
832885
status: 404,
833886
});
834887

835-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/999/NonExistent-Page";
888+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki/999/NonExistent-Page";
836889
const result = await handler({ url });
837890

838891
expect(result.isError).toBe(true);
@@ -868,7 +921,7 @@ describe("configureWikiTools", () => {
868921
};
869922
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
870923

871-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/not-a-number/Some-Page";
924+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki/not-a-number/Some-Page";
872925
const result = await handler({ url });
873926

874927
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true);
@@ -898,7 +951,7 @@ describe("configureWikiTools", () => {
898951
};
899952
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
900953

901-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title";
954+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki/123/Page-Title";
902955
const result = await handler({ url });
903956

904957
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true);
@@ -921,7 +974,7 @@ describe("configureWikiTools", () => {
921974
};
922975
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
923976

924-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki?pagePath=Home";
977+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki?pagePath=Home";
925978
const result = await handler({ url });
926979

927980
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/Home", undefined, undefined, true);
@@ -944,7 +997,7 @@ describe("configureWikiTools", () => {
944997
};
945998
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
946999

947-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki";
1000+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki";
9481001
const result = await handler({ url });
9491002

9501003
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true);
@@ -1035,7 +1088,7 @@ describe("configureWikiTools", () => {
10351088
};
10361089
mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown);
10371090

1038-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title";
1091+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki/123/Page-Title";
10391092
const result = await handler({ url });
10401093

10411094
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true);
@@ -1771,7 +1824,7 @@ describe("configureWikiTools", () => {
17711824
json: jest.fn().mockResolvedValue({ content: pageContent }),
17721825
});
17731826

1774-
const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title";
1827+
const url = "https://dev.azure.com/testorg/project/_wiki/wikis/myWiki/123/Page-Title";
17751828
const result = await handler({ url });
17761829

17771830
const responseText = result.content[0].text;

test/src/utils.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT License.
33

44
import { AlertType, AlertValidityStatus, Confidence, Severity, State } from "azure-devops-node-api/interfaces/AlertInterfaces";
5-
import { createEnumMapping, encodeFormattedValue, extractAdoStreamError, getEnumKeys, mapStringArrayToEnum, mapStringToEnum, safeEnumConvert } from "../../src/utils";
5+
import { createEnumMapping, encodeFormattedValue, extractAdoStreamError, getEnumKeys, getOrgFromUrl, mapStringArrayToEnum, mapStringToEnum, safeEnumConvert } from "../../src/utils";
66

77
describe("utils", () => {
88
describe("createEnumMapping", () => {
@@ -523,3 +523,35 @@ describe("encodeFormattedValue", () => {
523523
});
524524
});
525525
});
526+
527+
describe("getOrgFromUrl", () => {
528+
it("extracts org from a dev.azure.com URL as the first path segment", () => {
529+
expect(getOrgFromUrl("https://dev.azure.com/contoso")).toBe("contoso");
530+
expect(getOrgFromUrl("https://dev.azure.com/contoso/project/_wiki/wikis/myWiki?pagePath=%2FHome")).toBe("contoso");
531+
});
532+
533+
it("extracts org from a legacy visualstudio.com URL as the host subdomain", () => {
534+
expect(getOrgFromUrl("https://contoso.visualstudio.com/project/_wiki/wikis/myWiki")).toBe("contoso");
535+
});
536+
537+
it("lowercases the org for case-insensitive comparison", () => {
538+
expect(getOrgFromUrl("https://dev.azure.com/Contoso/project")).toBe("contoso");
539+
expect(getOrgFromUrl("https://Contoso.visualstudio.com/project")).toBe("contoso");
540+
});
541+
542+
it("returns null for invalid URLs", () => {
543+
expect(getOrgFromUrl("not-a-url")).toBeNull();
544+
expect(getOrgFromUrl("")).toBeNull();
545+
});
546+
547+
it("returns null for non-Azure DevOps hosts", () => {
548+
expect(getOrgFromUrl("https://example.com/contoso/project/_wiki/wikis/myWiki")).toBeNull();
549+
expect(getOrgFromUrl("https://dev.azure.com.evil.com/contoso/project")).toBeNull();
550+
expect(getOrgFromUrl("https://notvisualstudio.com/contoso")).toBeNull();
551+
});
552+
553+
it("returns null when no org segment is present", () => {
554+
expect(getOrgFromUrl("https://dev.azure.com/")).toBeNull();
555+
expect(getOrgFromUrl("https://dev.azure.com")).toBeNull();
556+
});
557+
});

0 commit comments

Comments
 (0)