From f7e4b78f1d717e72a8905b32e95aac3b7bae2e88 Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Mon, 17 Nov 2025 16:28:54 +0100 Subject: [PATCH] Prevent slash duplication in request URLs Closes #44269 Signed-off-by: Jon Koops --- js/libs/keycloak-admin-client/package.json | 1 - .../src/resources/agent.ts | 14 +++++------ .../keycloak-admin-client/src/utils/auth.ts | 17 ++++++++++---- .../src/utils/joinPath.ts | 22 ++++++++++++++++++ .../keycloak-admin-client/test/groups.spec.ts | 2 +- .../test/joinPaths.spec.ts | 23 +++++++++++++++++++ js/pnpm-lock.yaml | 9 -------- 7 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 js/libs/keycloak-admin-client/src/utils/joinPath.ts create mode 100644 js/libs/keycloak-admin-client/test/joinPaths.spec.ts diff --git a/js/libs/keycloak-admin-client/package.json b/js/libs/keycloak-admin-client/package.json index cd497bf41b4..773cd4b2f0d 100644 --- a/js/libs/keycloak-admin-client/package.json +++ b/js/libs/keycloak-admin-client/package.json @@ -40,7 +40,6 @@ }, "dependencies": { "camelize-ts": "^3.0.0", - "url-join": "^5.0.0", "url-template": "^3.1.1" }, "devDependencies": { diff --git a/js/libs/keycloak-admin-client/src/resources/agent.ts b/js/libs/keycloak-admin-client/src/resources/agent.ts index 147d32781a4..9acf76aed8f 100644 --- a/js/libs/keycloak-admin-client/src/resources/agent.ts +++ b/js/libs/keycloak-admin-client/src/resources/agent.ts @@ -1,4 +1,3 @@ -import urlJoin from "url-join"; import { parseTemplate } from "url-template"; import type { KeycloakAdminClient } from "../client.js"; import { @@ -6,6 +5,7 @@ import { NetworkError, parseResponse, } from "../utils/fetchWithError.js"; +import { joinPath } from "../utils/joinPath.js"; import { stringifyQueryParams } from "../utils/stringifyQueryParams.js"; // constants @@ -53,7 +53,7 @@ export class Agent { #client: KeycloakAdminClient; #basePath: string; #getBaseParams?: () => Record; - #getBaseUrl?: () => string; + #getBaseUrl: () => string; constructor({ client, @@ -199,12 +199,6 @@ export class Agent { returnResourceIdInLocationHeader?: { field: string }; headers?: [string, string][] | Record | Headers; }) { - const newPath = urlJoin(this.#basePath, path); - - // Parse template and replace with values from urlParams - const pathTemplate = parseTemplate(newPath); - const parsedPath = pathTemplate.expand(urlParams); - const url = new URL(`${this.#getBaseUrl?.() ?? ""}${parsedPath}`); const requestOptions = { ...this.#client.getRequestOptions() }; const requestHeaders = new Headers([ ...new Headers(requestOptions.headers).entries(), @@ -243,6 +237,10 @@ export class Agent { Object.assign(searchParams, queryParams); } + const url = new URL(this.#getBaseUrl()); + const pathTemplate = parseTemplate(joinPath(this.#basePath, path)); + + url.pathname = joinPath(url.pathname, pathTemplate.expand(urlParams)); url.search = stringifyQueryParams(searchParams); try { diff --git a/js/libs/keycloak-admin-client/src/utils/auth.ts b/js/libs/keycloak-admin-client/src/utils/auth.ts index 9533d52d4e1..64a916bf4a2 100644 --- a/js/libs/keycloak-admin-client/src/utils/auth.ts +++ b/js/libs/keycloak-admin-client/src/utils/auth.ts @@ -1,6 +1,8 @@ import camelize from "camelize-ts"; +import { parseTemplate } from "url-template"; import { defaultBaseUrl, defaultRealm } from "./constants.js"; import { fetchWithError } from "./fetchWithError.js"; +import { joinPath } from "./joinPath.js"; import { stringifyQueryParams } from "./stringifyQueryParams.js"; export type GrantTypes = "client_credentials" | "password" | "refresh_token"; @@ -68,10 +70,17 @@ const encodeFormURIComponent = (data: string) => encodeRFC3986URIComponent(data).replaceAll("%20", "+"); export const getToken = async (settings: Settings): Promise => { - // Construct URL - const baseUrl = settings.baseUrl || defaultBaseUrl; - const realmName = settings.realmName || defaultRealm; - const url = `${baseUrl}/realms/${realmName}/protocol/openid-connect/token`; + const url = new URL(settings.baseUrl ?? defaultBaseUrl); + const pathTemplate = parseTemplate( + "/realms/{realmName}/protocol/openid-connect/token", + ); + + url.pathname = joinPath( + url.pathname, + pathTemplate.expand({ + realmName: settings.realmName ?? defaultRealm, + }), + ); // Prepare credentials for openid-connect token request // ref: http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint diff --git a/js/libs/keycloak-admin-client/src/utils/joinPath.ts b/js/libs/keycloak-admin-client/src/utils/joinPath.ts new file mode 100644 index 00000000000..ccf4eff9c62 --- /dev/null +++ b/js/libs/keycloak-admin-client/src/utils/joinPath.ts @@ -0,0 +1,22 @@ +const PATH_SEPARATOR = "/"; + +export function joinPath(...paths: string[]) { + const normalizedPaths = paths.map((path, index) => { + const isFirst = index === 0; + const isLast = index === paths.length - 1; + + // Strip out any leading slashes from the path. + if (!isFirst && path.startsWith(PATH_SEPARATOR)) { + path = path.slice(1); + } + + // Strip out any trailing slashes from the path. + if (!isLast && path.endsWith(PATH_SEPARATOR)) { + path = path.slice(0, -1); + } + + return path; + }, []); + + return normalizedPaths.join(PATH_SEPARATOR); +} diff --git a/js/libs/keycloak-admin-client/test/groups.spec.ts b/js/libs/keycloak-admin-client/test/groups.spec.ts index ad459654c4a..3bbf914ec3c 100644 --- a/js/libs/keycloak-admin-client/test/groups.spec.ts +++ b/js/libs/keycloak-admin-client/test/groups.spec.ts @@ -5,8 +5,8 @@ import { KeycloakAdminClient } from "../src/client.js"; import type ClientRepresentation from "../src/defs/clientRepresentation.js"; import type GroupRepresentation from "../src/defs/groupRepresentation.js"; import type RoleRepresentation from "../src/defs/roleRepresentation.js"; +import type { SubGroupQuery } from "../src/resources/groups.js"; import { credentials } from "./constants.js"; -import { SubGroupQuery } from "../src/resources/groups.js"; const expect = chai.expect; diff --git a/js/libs/keycloak-admin-client/test/joinPaths.spec.ts b/js/libs/keycloak-admin-client/test/joinPaths.spec.ts new file mode 100644 index 00000000000..556a662abaa --- /dev/null +++ b/js/libs/keycloak-admin-client/test/joinPaths.spec.ts @@ -0,0 +1,23 @@ +import { expect } from "chai"; +import { joinPath } from "../lib/utils/joinPath.js"; + +describe("joinPath", () => { + it("returns an empty string when no paths are provided", () => { + expect(joinPath()).to.equal(""); + }); + + it("joins paths", () => { + expect(joinPath("foo", "bar", "baz")).to.equal("foo/bar/baz"); + expect(joinPath("foo", "/bar", "baz")).to.equal("foo/bar/baz"); + expect(joinPath("foo", "bar/", "baz")).to.equal("foo/bar/baz"); + expect(joinPath("foo", "/bar/", "baz")).to.equal("foo/bar/baz"); + }); + + it("joins paths with leading slashes", () => { + expect(joinPath("/foo", "bar", "baz")).to.equal("/foo/bar/baz"); + }); + + it("joins paths with trailing slashes", () => { + expect(joinPath("foo", "bar", "baz/")).to.equal("foo/bar/baz/"); + }); +}); diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 2f7086f7f61..a71496d38cc 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -331,9 +331,6 @@ importers: camelize-ts: specifier: ^3.0.0 version: 3.0.0 - url-join: - specifier: ^5.0.0 - version: 5.0.0 url-template: specifier: ^3.1.1 version: 3.1.1 @@ -4206,10 +4203,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-join@5.0.0: - resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - url-template@3.1.1: resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -8702,8 +8695,6 @@ snapshots: dependencies: punycode: 2.3.1 - url-join@5.0.0: {} - url-template@3.1.1: {} use-react-router-breadcrumbs@4.0.1(react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):