diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts index 1d07ca09df5..b747e6633ea 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts @@ -524,11 +524,22 @@ describe("parseSkillSource — security boundary", () => { } }); - it("rejects URLs with embedded credentials", () => { - expect(parseSkillSource("https://user:token@gitlab.com/org/repo")).toBeNull(); - expect(parseSkillSource("https://user@gitlab.com/org/repo")).toBeNull(); - // userinfo confusion: the real host is evil.com, not github.com - expect(parseSkillSource("https://github.com@evil.com/org/repo")).toBeNull(); + it("accepts URLs with credentials and strips them", () => { + // Credentials are stripped; the repo URL parses normally afterward + expect(parseSkillSource("https://user:token@gitlab.com/org/repo")?.parsed).toEqual({ + source: "url", + url: "https://gitlab.com/org/repo", + }); + expect(parseSkillSource("https://user@gitlab.com/org/repo")?.parsed).toEqual({ + source: "url", + url: "https://gitlab.com/org/repo", + }); + // userinfo confusion: the real host is evil.com — stripping credentials + // correctly identifies evil.com as the host, NOT github.com + expect(parseSkillSource("https://github.com@evil.com/org/repo")?.parsed).toEqual({ + source: "url", + url: "https://evil.com/org/repo", + }); }); it("rejects IP-literal hosts (loopback, private, metadata, obfuscated, IPv6)", () => { @@ -543,6 +554,18 @@ describe("parseSkillSource — security boundary", () => { } }); + it("preserves validated host when stripping credentials from //path URLs", () => { + // A path starting with // is a new authority if re-parsed after stripping. + // In-place credential clearing prevents the host from changing. + // The validated host (safe.com) stays as-is — not bypassed to 127.0.0.1. + const result = parseSkillSource("https://user@safe.com//127.0.0.1/repo"); + expect(result).not.toBeNull(); + expect(result?.parsed).toEqual({ + source: "url", + url: "https://safe.com//127.0.0.1/repo", + }); + }); + it("does not grant GitHub shorthand to a look-alike host", () => { expect(parseSkillSource("https://github.com.evil.com/org/repo")?.parsed).toEqual({ source: "url", diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index 5b3d4f20c1e..3f1bd8375de 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -36,7 +36,7 @@ const pathSegments = (url: URL): string[] => url.pathname.split("/").filter((seg /** * Validate and normalize a repository URL into a parsed URL, or null. Enforces https (rejects - * http/ssh/git/etc.), rejects embedded credentials, and requires a dotted host, so the public + * http/ssh/git/etc.), strips embedded credentials, and requires a dotted host, so the public * skill feeds never serve an insecure or credentialed clone URL. Everything downstream parses * this normalized object rather than the raw string. */ @@ -54,14 +54,15 @@ const parseRepoUrl = (raw: string): URL | null => { } if ( url.protocol !== "https:" || - url.username !== "" || - url.password !== "" || !url.hostname.includes(".") || url.hostname.startsWith("[") || IPV4_HOST_REGEX.test(url.hostname) ) { return null; } + // Strip embedded credentials in-place so the validated host cannot change + url.username = ""; + url.password = ""; return url; };