mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
refactor(ui): register ssh skill sources verbatim and share the https host rules
The ssh parser rebuilt the clone url it was given, stripping a trailing .git and appending one back. Git treats that suffix as optional, and the hosts whose clone paths are not org/repo break when it is forced on, so an Azure DevOps v3 or a CodeCommit v1/repos url registered through the form would no longer clone. It also carried its own host pattern, which demanded an alphabetic final label and so rejected internal hosts like gitlab.internal.k8s2 that the https path accepts. Rewrite the scp form into an ssh:// url purely to validate it, reuse the https host and credential checks through a shared isSafeHost, and store exactly what the user typed. Only a url that survives the round trip unchanged is accepted, which is what keeps traversal segments out of the feed, so the two ssh regexes, the dots-only guard and the clone-url builders all collapse into one function.
This commit is contained in:
parent
b3432abef7
commit
ecd72c51da
2 changed files with 65 additions and 55 deletions
|
|
@ -476,21 +476,30 @@ describe("parseSkillSource", () => {
|
|||
source: "url",
|
||||
url: "git@ghe.example.com:org/repo.git",
|
||||
});
|
||||
expect(parseSkillSource("git@ghe.example.com:org/repo")?.parsed).toEqual({
|
||||
source: "url",
|
||||
url: "git@ghe.example.com:org/repo.git",
|
||||
});
|
||||
expect(parseSkillSource("git@ghe.example.com:org/repo.git")?.suggestedName).toBe("repo");
|
||||
});
|
||||
|
||||
it("normalizes an ssh:// clone url and keeps a custom port", () => {
|
||||
expect(parseSkillSource("ssh://git@ghe.example.com/org/repo")?.parsed).toEqual({
|
||||
it("stores an ssh clone url exactly as typed, so a forced .git suffix cannot break azure devops or codecommit", () => {
|
||||
for (const url of [
|
||||
"git@ghe.example.com:org/repo",
|
||||
"git@ssh.dev.azure.com:v3/org/project/repo",
|
||||
"ssh://git@ghe.example.com/org/repo",
|
||||
"ssh://apka1234@git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo",
|
||||
"ssh://git@ghe.example.com:2222/org/nested/repo.git",
|
||||
]) {
|
||||
expect(parseSkillSource(url)?.parsed).toEqual({ source: "url", url });
|
||||
}
|
||||
expect(parseSkillSource("git@ssh.dev.azure.com:v3/org/project/repo")?.suggestedName).toBe("repo");
|
||||
});
|
||||
|
||||
it("accepts an internal host whose last label is not alphabetic, matching the https rule", () => {
|
||||
expect(parseSkillSource("git@gitlab.internal.k8s2:org/repo.git")?.parsed).toEqual({
|
||||
source: "url",
|
||||
url: "ssh://git@ghe.example.com/org/repo.git",
|
||||
url: "git@gitlab.internal.k8s2:org/repo.git",
|
||||
});
|
||||
expect(parseSkillSource("ssh://git@ghe.example.com:2222/org/nested/repo.git")?.parsed).toEqual({
|
||||
expect(parseSkillSource("https://gitlab.internal.k8s2/org/repo")?.parsed).toEqual({
|
||||
source: "url",
|
||||
url: "ssh://git@ghe.example.com:2222/org/nested/repo.git",
|
||||
url: "https://gitlab.internal.k8s2/org/repo",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -510,17 +519,22 @@ describe("parseSkillSource", () => {
|
|||
expect(parseSkillSource("ssh://ghe.example.com/org/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects ssh remotes with ip hosts or dot-only path segments", () => {
|
||||
it("rejects ssh remotes with ip hosts or traversal segments", () => {
|
||||
expect(parseSkillSource("git@10.0.0.5:org/repo.git")).toBeNull();
|
||||
expect(parseSkillSource("ssh://git@169.254.169.254/org/repo")).toBeNull();
|
||||
expect(parseSkillSource("git@ghe.example.com:../etc")).toBeNull();
|
||||
expect(parseSkillSource("ssh://git@ghe.example.com/org/../repo")).toBeNull();
|
||||
expect(parseSkillSource("git@ghe.example.com:org/../../etc/passwd")).toBeNull();
|
||||
expect(parseSkillSource("git@ghe.example.com:org/.github")?.parsed).toEqual({
|
||||
source: "url",
|
||||
url: "git@ghe.example.com:org/.github.git",
|
||||
url: "git@ghe.example.com:org/.github",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an ssh remote carrying a password, which would publish a secret on the feed", () => {
|
||||
expect(parseSkillSource("ssh://git:s3cret@ghe.example.com/org/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty and garbage input", () => {
|
||||
expect(parseSkillSource("")).toBeNull();
|
||||
expect(parseSkillSource(" ")).toBeNull();
|
||||
|
|
|
|||
|
|
@ -29,22 +29,33 @@ export const SHA256_REGEX = /^[0-9a-fA-F]{64}$/;
|
|||
|
||||
export const isValidSha256 = (digest: string): boolean => digest.trim() === "" || SHA256_REGEX.test(digest.trim());
|
||||
|
||||
// WHATWG normalizes obfuscated IPv4 (e.g. 2130706433, 0x7f.0.0.1) to dotted-decimal, so this
|
||||
// catches every IPv4 form; bracketed IPv6 is rejected separately.
|
||||
// WHATWG normalizes obfuscated IPv4 (e.g. 2130706433, 0x7f.0.0.1) to dotted-decimal on https, so
|
||||
// this catches every IPv4 form there; on a non-special scheme like ssh it catches the dotted form
|
||||
// only. Bracketed IPv6 is rejected separately.
|
||||
const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
|
||||
const GITHUB_ORG_REGEX = /^[A-Za-z0-9-]+$/;
|
||||
const GITHUB_REPO_REGEX = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
const SSH_SCP_REGEX = /^([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,}):([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i;
|
||||
const SSH_URL_REGEX =
|
||||
/^ssh:\/\/([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,})(:\d+)?\/([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i;
|
||||
const DOTS_ONLY_SEGMENT_REGEX = /^\.+$/;
|
||||
const SSH_SCHEME = "ssh://";
|
||||
const SSH_SCP_REGEX = /^([a-z0-9._-]+)@([^:/@]+):(?!\/)(.+)$/i;
|
||||
|
||||
const buildRepoUrl = (url: URL): string => `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
|
||||
|
||||
const pathSegments = (url: URL): string[] => url.pathname.split("/").filter((seg) => seg !== "");
|
||||
|
||||
const toUrl = (candidate: string): URL | null => {
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** One host rule for every scheme, so an ssh remote is neither more nor less trusted than its https twin. */
|
||||
const isSafeHost = (url: URL): boolean =>
|
||||
url.hostname.includes(".") && !url.hostname.startsWith("[") && !IPV4_HOST_REGEX.test(url.hostname);
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -57,20 +68,8 @@ const parseRepoUrl = (raw: string): URL | null => {
|
|||
return null;
|
||||
}
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(withScheme);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
!url.hostname.includes(".") ||
|
||||
url.hostname.startsWith("[") ||
|
||||
IPV4_HOST_REGEX.test(url.hostname)
|
||||
) {
|
||||
const url = toUrl(withScheme);
|
||||
if (!url || url.protocol !== "https:" || url.username !== "" || url.password !== "" || !isSafeHost(url)) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
|
|
@ -178,32 +177,29 @@ const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | nul
|
|||
return buildGitSourcePreview("Git", buildRepoUrl(url), repoName, subPath);
|
||||
};
|
||||
|
||||
interface SshRemote {
|
||||
cloneUrl: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
const buildSshRemote = (rawPath: string, toCloneUrl: (repoPath: string) => string): SshRemote | null => {
|
||||
if (rawPath.split("/").some((segment) => DOTS_ONLY_SEGMENT_REGEX.test(segment))) {
|
||||
/**
|
||||
* Parse an scp-style `git@host:org/repo` or `ssh://git@host/org/repo` clone URL, registering it
|
||||
* exactly as typed: git treats the `.git` suffix as optional, and forcing one on breaks hosts whose
|
||||
* paths are not `org/repo`, like Azure DevOps `v3/...` and CodeCommit `v1/repos/...`. The scp form is
|
||||
* rewritten to `ssh://` only to reuse the https host and credential rules, and only a URL that
|
||||
* survives that round trip unchanged is accepted, which keeps traversal segments off the feed.
|
||||
*/
|
||||
const parseSshSource = (raw: string, subPath?: string): SkillSourcePreview | null => {
|
||||
const trimmed = raw.trim();
|
||||
const scp = SSH_SCP_REGEX.exec(trimmed);
|
||||
const candidate = scp ? `${SSH_SCHEME}${scp[1]}@${scp[2]}/${scp[3]}` : trimmed;
|
||||
if (!candidate.toLowerCase().startsWith(SSH_SCHEME)) {
|
||||
return null;
|
||||
}
|
||||
const bare = rawPath.replace(/\.git$/i, "");
|
||||
return { cloneUrl: toCloneUrl(`${bare}.git`), repoName: lastSegment(bare) };
|
||||
};
|
||||
|
||||
const parseSshRemote = (raw: string): SshRemote | null => {
|
||||
const trimmed = raw.trim();
|
||||
const sshUrl = SSH_URL_REGEX.exec(trimmed);
|
||||
if (sshUrl) {
|
||||
const [, user, host, port, path] = sshUrl;
|
||||
return buildSshRemote(path, (repoPath) => `ssh://${user}@${host}${port ?? ""}/${repoPath}`);
|
||||
const url = toUrl(candidate);
|
||||
if (!url || url.username === "" || url.password !== "" || !isSafeHost(url)) {
|
||||
return null;
|
||||
}
|
||||
const scp = SSH_SCP_REGEX.exec(trimmed);
|
||||
if (scp) {
|
||||
const [, user, host, path] = scp;
|
||||
return buildSshRemote(path, (repoPath) => `${user}@${host}:${repoPath}`);
|
||||
const pathStart = candidate.indexOf("/", SSH_SCHEME.length);
|
||||
if (pathStart === -1 || url.pathname !== candidate.slice(pathStart) || pathSegments(url).length < 2) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
return buildGitSourcePreview("SSH", trimmed, lastSegment(url.pathname).replace(/\.git$/i, ""), subPath);
|
||||
};
|
||||
|
||||
const parseArchiveSource = (url: URL): SkillSourcePreview => ({
|
||||
|
|
@ -220,9 +216,9 @@ const parseArchiveSource = (url: URL): SkillSourcePreview => ({
|
|||
* with an optional subfolder turning it into git-subdir.
|
||||
*/
|
||||
export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => {
|
||||
const ssh = parseSshRemote(rawUrl);
|
||||
const ssh = parseSshSource(rawUrl, subPath);
|
||||
if (ssh) {
|
||||
return buildGitSourcePreview("SSH", ssh.cloneUrl, ssh.repoName, subPath);
|
||||
return ssh;
|
||||
}
|
||||
const url = parseRepoUrl(rawUrl);
|
||||
if (!url) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue