Skip to content

Commit 3884f95

Browse files
committed
feat: Incorporate allowlist for destinations
1 parent fa537c0 commit 3884f95

6 files changed

Lines changed: 215 additions & 3 deletions

File tree

src/tools/create_integration_card/create_integration_card.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ejs from "ejs";
77
import {getLogger} from "@ui5/logger";
88
import {Destination, SupportedCardType} from "./schema.js";
99
import semver from "semver";
10+
import {getAllowedOdataV4Domains, isValidUrl} from "../../utils/URLHelper.js";
1011

1112
const log = getLogger("tools:create_integration_card:create_integration_card");
1213
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -41,6 +42,30 @@ export async function createIntegrationCard({
4142
throw new InvalidInputError("The provided manifest version is not valid!");
4243
}
4344

45+
if (destinations?.length) {
46+
const allowedDomains = getAllowedOdataV4Domains();
47+
48+
for (const destination of destinations) {
49+
if (!isValidUrl(destination.defaultUrl, allowedDomains)) {
50+
let allowedDomainsNote = "";
51+
if (allowedDomains.length) {
52+
allowedDomainsNote =
53+
`As per the MCP server configuration, only the following domains are currently allowed: ` +
54+
`'${allowedDomains.join("', '")}'. See https://github.com/UI5/mcp-server#configuration ` +
55+
`for information on how to configure the allow list.`;
56+
}
57+
throw new InvalidInputError(
58+
`The provided destination 'defaultUrl' service URL is not valid.` +
59+
`It must be either an absolute URL` +
60+
`starting with http:// or https:// or pathname like '/api/v1/serviceName' in case ` +
61+
`the service is exposed on the same server as the application. In this case, ` +
62+
`the protocol and host 'http://localhost:4004' will be assumed and used by this tool for inquiries ` +
63+
`about the service. ${allowedDomainsNote}`
64+
);
65+
}
66+
}
67+
}
68+
4469
try {
4570
// create target directory
4671
await mkdir(folderPath, {recursive: true});

src/utils/URLHelper.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {getLogger} from "@ui5/logger";
2+
import {InvalidInputError} from "../utils.js";
3+
4+
const log = getLogger("tools:create_ui5_app:create_ui5_app");
5+
6+
/**
7+
* Validates a URL against a set of specified rules, including protocol and domain allow lists.
8+
*
9+
* @param urlString The string to validate as a URL.
10+
* @param domainAllowList An array of allowed domain names.
11+
* - An empty array allows any domain.
12+
* - e.g., ["localhost", "example.com", "sub.example.com"]
13+
* - For wildcard subdomains, prefix the domain with a dot: ".example.com".
14+
* This will match "www.example.com" but not "example.com".
15+
* @param protocolAllowList An array of allowed protocols (without the trailing colon).
16+
* - An empty array allows any protocol.
17+
* - e.g., ["http", "https"]
18+
* @returns `true` if the URL is valid according to the rules, otherwise `false`.
19+
* @throws {InvalidInputError} if the URL's domain is not in the provided allow list.
20+
*/
21+
export function isValidUrl(
22+
urlString: string,
23+
domainAllowList: string[] = [],
24+
protocolAllowList: string[] = ["http", "https"]
25+
): boolean {
26+
let url: URL;
27+
28+
// 1. Validate URL structure using the WHATWG URL API.
29+
try {
30+
url = new URL(urlString);
31+
} catch (_err) {
32+
// If the URL constructor throws an error, the URL is malformed.
33+
return false;
34+
}
35+
36+
// 2. Validate the protocol.
37+
if (protocolAllowList.length > 0) {
38+
// The `protocol` property includes the colon (e.g., "https:").
39+
// We remove it for a clean comparison.
40+
const urlProtocol = url.protocol.slice(0, -1);
41+
if (!protocolAllowList.includes(urlProtocol)) {
42+
return false;
43+
}
44+
}
45+
46+
// 3. Validate the domain/hostname.
47+
if (domainAllowList.length > 0) {
48+
const hostname = url.hostname;
49+
50+
const isDomainAllowed = domainAllowList.some((allowedDomain) => {
51+
// Wildcard domain check (e.g., ".example.com")
52+
if (allowedDomain.startsWith(".")) {
53+
// Must match the suffix and not be the root domain itself.
54+
// e.g., "api.example.com" ends with ".example.com"
55+
// e.g., "example.com" does NOT end with ".example.com"
56+
return hostname.endsWith(allowedDomain);
57+
}
58+
59+
// Exact domain match
60+
return hostname === allowedDomain;
61+
});
62+
63+
if (!isDomainAllowed) {
64+
throw new InvalidInputError(
65+
`Domain "${hostname}" is not allowed. Allowed domains are: ${domainAllowList.join(", ")}. See ` +
66+
`https://github.com/UI5/mcp-server#configuration for information on how to configure the allow list.`);
67+
}
68+
}
69+
70+
// If all checks pass, the URL is valid.
71+
return true;
72+
};
73+
74+
export function getAllowedOdataV4Domains() {
75+
if ("UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS" in process.env) {
76+
const inputDomainList = process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS;
77+
if (!inputDomainList?.trim()) {
78+
// Empty list allows all domains
79+
log.verbose("Empty value for UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS, allowing all domains");
80+
return [];
81+
}
82+
// Use the environment variable if set
83+
const domainList = inputDomainList.split(",").map((d) => d.trim());
84+
// Validate domains to catch user errors
85+
for (const domain of domainList) {
86+
try {
87+
// Note that the dot prefix (which we use for wildcards) is valid in a domain
88+
new URL(`https://${domain}`);
89+
} catch (err) {
90+
throw new InvalidInputError(
91+
`Invalid domain '${domain}' in UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS: ` +
92+
(err instanceof Error ? err.message : String(err))
93+
);
94+
}
95+
}
96+
log.verbose(`${domainList.length} allowed OData V4 domains configured: ${domainList.join(", ")}`);
97+
return domainList;
98+
}
99+
return [
100+
// Default allowed domains for OData V4 services
101+
"localhost",
102+
"services.odata.org",
103+
];
104+
}

test/expected/create_integration_card/destinations/card/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
},
2727
"myapi": {
2828
"name": "myapi",
29-
"defaultUrl": "https://api.example.com/v1/"
29+
"defaultUrl": "http://localhost:8080/v1/"
3030
}
3131
},
3232
"editor": "./dt/Configuration"

test/expected/create_integration_card/destinations/test/App.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ sap.ui.define(["sap/ui/integration/Host"], async (Host) => {
99
const resetBtn = document.getElementById("resetBtn");
1010
const destinations = {
1111
"northwind": "https://services.odata.org/V4/Northwind/Northwind.svc/",
12-
"myapi": "https://api.example.com/v1/"
12+
"myapi": "http://localhost:8080/v1/"
1313
};
1414
const host = new Host({
1515
resolveDestination: function(destinationName) {

test/lib/tools/create_integration_card/create_integration_card.integration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ test.serial("Generate card template with multiple destinations", async (t) => {
155155
},
156156
{
157157
name: "myapi",
158-
defaultUrl: "https://api.example.com/v1/",
158+
defaultUrl: "http://localhost:8080/v1/",
159159
},
160160
];
161161

test/lib/tools/create_integration_card/create_integration_card.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import esmock from "esmock";
33
import sinonGlobal from "sinon";
44
import {InvalidInputError} from "../../../../src/utils.js";
55
import path from "node:path";
6+
import {isValidUrl as realIsValidUrl, getAllowedOdataV4Domains} from "../../../../src/utils/URLHelper.js";
67

78
// Define test context type
89
const test = anyTest as TestFn<{
@@ -23,6 +24,7 @@ const test = anyTest as TestFn<{
2324
isLevelEnabled: sinonGlobal.SinonStub;
2425
};
2526
globbyStub: sinonGlobal.SinonStub;
27+
isValidUrlStub: sinonGlobal.SinonStub;
2628
createIntegrationCard: typeof import(
2729
"../../../../src/tools/create_integration_card/create_integration_card.js"
2830
).createIntegrationCard;
@@ -66,6 +68,9 @@ test.beforeEach(async (t) => {
6668
];
6769
t.context.globbyStub = t.context.sinon.stub().resolves(t.context.staticFiles);
6870

71+
// Create a stub that wraps the real isValidUrl function
72+
t.context.isValidUrlStub = t.context.sinon.stub().callsFake(realIsValidUrl);
73+
6974
const {createIntegrationCard} = await esmock(
7075
"../../../../src/tools/create_integration_card/create_integration_card.js", {
7176
"@ui5/logger": {
@@ -86,6 +91,10 @@ test.beforeEach(async (t) => {
8691
"../../../../src/utils.js": {
8792
dirExists: t.context.dirExistsStub,
8893
},
94+
"../../../../src/utils/URLHelper.js": {
95+
isValidUrl: t.context.isValidUrlStub,
96+
getAllowedOdataV4Domains,
97+
},
8998
}
9099
);
91100

@@ -224,3 +233,77 @@ test("Error processing template file", async (t) => {
224233
instanceOf: Error,
225234
});
226235
});
236+
237+
test.serial("Destinations: Throws error when there is not allowed domain", async (t) => {
238+
const {createIntegrationCard, mkdirStub} = t.context;
239+
const folderPath = "/some/folder/path/card";
240+
const destinations = [
241+
{
242+
name: "invalidDomain",
243+
defaultUrl: "https://invalid-domain.com/api/v1/",
244+
},
245+
];
246+
const currentAllowedDomains = process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS;
247+
const allowedDomains = ["allowed-domain.com"];
248+
249+
process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = "allowed-domain.com";
250+
251+
await t.throwsAsync(async () => {
252+
await createIntegrationCard({
253+
folderPath,
254+
cardType: "List",
255+
manifestVersion: "1.78.0",
256+
destinations,
257+
});
258+
}, {
259+
message: `Domain "invalid-domain.com" is not allowed. Allowed domains are: ${allowedDomains.join(", ")}. See ` +
260+
`https://github.com/UI5/mcp-server#configuration for information on how to configure the allow list.`,
261+
instanceOf: InvalidInputError,
262+
});
263+
264+
t.true(mkdirStub.notCalled);
265+
266+
// Assert that isValidUrl from URLHelper was called once
267+
t.true(t.context.isValidUrlStub.calledOnce, "isValidUrl should be called once");
268+
t.deepEqual(
269+
t.context.isValidUrlStub.firstCall.args,
270+
[destinations[0].defaultUrl, allowedDomains],
271+
"isValidUrl should be called with the destination URL and allowed domains"
272+
);
273+
274+
// Restore original allowed domains after test
275+
process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = currentAllowedDomains;
276+
});
277+
278+
test.serial("Destinations: Successfully generates card template with allowed destination domain", async (t) => {
279+
const {createIntegrationCard} = t.context;
280+
const folderPath = "/some/folder/path/card";
281+
const destinations = [
282+
{
283+
name: "validDomain",
284+
defaultUrl: "https://allowed-domain.com/api/v1/",
285+
},
286+
];
287+
const currentAllowedDomains = process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS;
288+
process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = "allowed-domain.com";
289+
290+
await t.notThrowsAsync(async () => {
291+
await createIntegrationCard({
292+
folderPath,
293+
cardType: "List",
294+
manifestVersion: "1.78.0",
295+
destinations,
296+
});
297+
});
298+
299+
// Assert that isValidUrl from URLHelper was called once with the correct parameters
300+
t.true(t.context.isValidUrlStub.calledOnce, "isValidUrl should be called once");
301+
t.deepEqual(
302+
t.context.isValidUrlStub.firstCall.args,
303+
[destinations[0].defaultUrl, ["allowed-domain.com"]],
304+
"isValidUrl should be called with the destination URL and allowed domains"
305+
);
306+
307+
// Restore original allowed domains after test
308+
process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = currentAllowedDomains;
309+
});

0 commit comments

Comments
 (0)