Skip to content

fix(ui): allow any git host on the skills add form (LIT-4053) - #31652

Merged
ryan-crabbe-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_lit_4053_skills_git_url
Jun 30, 2026
Merged

fix(ui): allow any git host on the skills add form (LIT-4053)#31652
ryan-crabbe-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_lit_4053_skills_git_url

Conversation

@ryan-crabbe-berri

@ryan-crabbe-berri ryan-crabbe-berri commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-4053
Resolves LIT-4149

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

UI-only change. To verify against a local proxy + dev UI (the dev server on :3000 runs this branch's code):

  1. Open the Skills page and click "Add Skill"
  2. Paste a GitLab repo URL, e.g. https://gitlab.com/gitlab-org/gitlab — the form now shows a "Detected: Git repo" preview instead of "Please enter a valid GitHub URL"
  3. Add a "Subfolder path" like plugins/my-skill — the preview switches to "Git subdir ... @ plugins/my-skill"
  4. Submit; the skill registers (POST /claude-code/plugins returns success and it appears in the Skill Hub)
  5. Sanity check the unchanged GitHub paths still work: github.com/org/repo (repo) and github.com/org/repo/tree/main/my-skill (subdir, field auto-disabled)

The backend already accepts these payloads, so no server call changes; the screenshots to attach are the form accepting the GitLab repo and the GitLab subfolder.

Screenshot 2026-06-30 at 10 13 30 AM Screenshot 2026-06-30 at 10 13 27 AM

Type

🐛 Bug Fix

Changes

The skills add form only accepted GitHub URLs. Its URL parser returned null for any host not starting with github.com, and the submit gate blocked on a null parse, so GitLab, Bitbucket, and self-hosted repos (and any repo subfolder on them) were rejected before a request was sent. There is no host allowlist anywhere; the backend's /claude-code/plugins endpoint already accepts arbitrary git hosts via its url and git-subdir sources (only the subfolder path is regex-validated server-side). So this was purely a client-side restriction.

The inline parseGitHubUrl is replaced by an exported, host-agnostic parseSkillSource in helpers.ts. GitHub URLs keep their existing github / git-subdir shorthand; any other host is treated as a raw repo url; and a new optional "Subfolder path" field turns any repo into a git-subdir source (url + path). When a pasted GitHub tree/blob URL already encodes a subfolder, the field is cleared and disabled so a contradictory source can never be submitted.

The parser is hardened to match the backend contract: query strings and fragments are stripped before parsing; the host match is case-insensitive and drops a leading www.; both the URL-extracted and field-entered subfolder paths are validated against the same regex the server uses; a real file-extension allowlist (not "any dot") decides whether a trailing blob segment is a file, so a folder literally named my.skill is kept; a branch-only tree/main URL falls back to the repo; non-GitHub URLs require at least an org/repo; and the auto-suggested skill name is kebab-cased so it satisfies the name field's own rule.

git-subdir is now handled in the display helpers (getSourceDisplayText, getSourceLink, formatInstallCommand), which previously rendered it as "Unknown source" with no link — a latent bug that also affected existing GitHub-subdir skills. The submit path is fully typed (RegisterPluginRequest plus an AddPluginFormValues interface), removing the two prior any usages and ratcheting the lint budget down; as a consequence an author entered with an email but no name is now dropped rather than sent, since the backend requires the author name. No backend changes.

Tests cover the full host/subfolder matrix at the parser level (GitHub repo, GitHub subdir, gitlab, self-hosted, query/fragment, uppercase/www. host, dotted folder, branch-only, bad paths, kebab name, bare host) plus form-submit assertions on the exact source payload for the GitHub-repo, GitHub-subdir, GitLab-url, and GitLab+subfolder cases.

Update: the skills add-flow API types are now synced to the generated OpenAPI types (schema.d.ts) instead of the hand-maintained duplicates that had already drifted (the networking helper's payload type was missing the git-subdir path field entirely). PluginAuthor aliases the generated schema, the registration payload is a SkillRegisterRequest (the generated RegisterPluginRequest envelope with source narrowed to our PluginSource union, since the backend types source as a loose string map, and version kept optional since the backend defaults it), registerClaudeCodePlugin takes that type, and the dead/mismatched RegisterPluginResponse is deleted. Syncing source itself precisely would need the backend to model it as a discriminated union rather than Dict[str, str]; that plus migrating the read-path types (Plugin/PluginListItem/ListPluginsResponse) are good follow-ups.

Also improved error handling on the form: it previously always showed "Failed to register skill" and swallowed the reason. It now surfaces the backend message ("Failed to register skill: "), and the networking helper falls back to the raw body/status when the error response is not JSON instead of throwing a parse error. A regression test asserts the backend message reaches the user.

Security: reject git URLs with embedded user-info (user:token@host). They previously passed the raw-host parser and were stored verbatim as the skill source, which is served on the unauthenticated /public/skill_hub and marketplace.json feeds, leaking the credentials (Veria finding).

Security hardening (supersedes the one-line credential guard): repository URL parsing now goes through a single WHATWG URL gate instead of ad-hoc string slicing, so every unsafe class is handled in one place and the URL stored on the public feeds is always canonical. It enforces https (rejecting http/ssh/git/file/javascript/data and protocol-relative //host), rejects embedded credentials (including userinfo-confusion like github.com@evil.com), rejects IP-literal hosts (loopback/private/cloud-metadata and obfuscated/IPv6 forms), and rebuilds the stored url from origin+pathname so query strings, fragments, and trailing slashes can't be published. The GitHub org/repo shorthand is charset-validated like the other paths. This closes both Veria findings (credentialed and http sources) and the adversarial-review follow-ups, with regression tests per class. Ran it back through the adversarial reviewer, which confirmed the http and credential classes are robustly closed and the published URL is clean.

The skills add form only accepted GitHub URLs: its URL parser bailed on
any host that did not start with github.com, so GitLab, Bitbucket, and
self-hosted repos (and any repo subfolder on them) were rejected before a
request was ever sent. The backend already accepts arbitrary git hosts
via its url and git-subdir sources, with no host allowlist, so this was a
client-side restriction only.

Generalize the parser into an exported, host-agnostic parseSkillSource:
GitHub URLs keep their github / git-subdir shorthand, every other host is
treated as a raw repo url, and an optional Subfolder path field turns any
repo into a git-subdir source (url + path). When a pasted GitHub
tree/blob URL already encodes a subfolder, the field is cleared and
disabled so a contradictory source can never be submitted.

The parser is hardened to match the backend contract: query strings and
fragments are stripped, the host match is case-insensitive and drops a
leading www., the extracted and field-entered subfolder paths are both
validated against the same regex the server uses, a real file-extension
allowlist (not "any dot") decides whether a trailing blob segment is a
file, a branch-only tree URL falls back to the repo, non-GitHub URLs
require at least an org/repo, and the suggested skill name is kebab-cased
so it satisfies the name field's own rule.

The git-subdir source is now handled in the display helpers
(getSourceDisplayText, getSourceLink, formatInstallCommand), which
previously showed it as "Unknown source" with no link. The submit path
is fully typed (RegisterPluginRequest plus an AddPluginFormValues
interface), removing the two prior any usages; as a result an
author with an email but no name is dropped rather than sent, since the
backend requires the author name.

No backend changes. Tests cover the full host/subfolder matrix at the
parser level plus form-submit assertions on the exact source payload.
@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the GitHub-only URL parser in the Skills add form with a host-agnostic parseSkillSource backed by a hardened WHATWG URL gate, allowing GitLab, Bitbucket, and self-hosted repos to be registered. It also adds a security layer that enforces HTTPS, rejects embedded credentials, IP-literal hosts, and obfuscated IPv4/IPv6 forms so the public skill feeds never serve unsafe clone URLs.

  • helpers.ts: New parseSkillSource handles GitHub shorthand (github/git-subdir) and raw URL sources; getSourceDisplayText, getSourceLink, and formatInstallCommand extended to cover git-subdir, fixing a latent bug where existing GitHub-subdir skills rendered as "Unknown source".
  • add_plugin_form.tsx: New "Subfolder path" field allows any repo to be turned into a git-subdir source; the field is auto-cleared and disabled when the pasted URL already encodes a subfolder (tree/blob URL); submit path is fully typed and error messages now surface the backend reason.
  • types.ts / networking.tsx: PluginAuthor and the registration request type are now derived from the generated OpenAPI schema instead of hand-maintained duplicates; registerClaudeCodePlugin handles non-JSON error responses gracefully.

Confidence Score: 5/5

Safe to merge — all changes are client-side, no backend modifications, and the security hardening is well-guarded by a comprehensive test suite.

The URL parsing logic is thorough: WHATWG URL normalization handles obfuscated hosts, the credential and IP-literal rejections are each unit-tested with adversarial inputs, the git-subdir state machine (subfolder field clearing on tree-URL paste) is exercised end-to-end in the form tests, and the typing changes align the frontend contract with the generated OpenAPI schema rather than loosening it. No correctness gaps were found in the changed paths.

No files require special attention.

Important Files Changed

Filename Overview
ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts New parseSkillSource replaces the GitHub-only parseGitHubUrl with a host-agnostic parser that goes through a hardened WHATWG URL gate; covers HTTPS enforcement, credential rejection, IPv4/IPv6 rejection, and www/case normalization. git-subdir handling added to getSourceDisplayText, getSourceLink, and formatInstallCommand.
ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx Form updated to accept any HTTPS git host; new optional "Subfolder path" field, recomputePreview keeps URL state and subfolder state in sync, and tree-URL-detected subdirs disable the field. Submit path is now fully typed and error messages surface the backend reason.
ui/litellm-dashboard/src/components/claude_code_plugins/types.ts PluginAuthor aliased to the generated OpenAPI schema; RegisterPluginRequest replaced by SkillRegisterRequest with source narrowed and version kept optional; dead RegisterPluginResponse and PluginFormData removed.
ui/litellm-dashboard/src/components/networking.tsx registerClaudeCodePlugin parameter type tightened to SkillRegisterRequest; error handling now catches non-JSON error responses with a fallback to raw body/status.
ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts New parseSkillSource test suite covers the full host/subfolder matrix plus a dedicated security-boundary section. git-subdir display/link/install helpers now have coverage they previously lacked.
ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.test.tsx New form tests assert the exact source payload shape for all four source types, the tree-URL-clears-subfolder interaction, and backend error propagation to the UI.

Reviews (4): Last reviewed commit: "fix(ui): validate skill repo URLs throug..." | Re-trigger Greptile

…ma, surface backend errors

Replace the hand-maintained, already-drifted API types for the skills add
flow with the generated ones from schema.d.ts: PluginAuthor now aliases
components["schemas"]["PluginAuthor"], the registration payload is a new
SkillRegisterRequest (the generated RegisterPluginRequest envelope with
source narrowed to our PluginSource union, since the backend types source
as a loose string map, and version kept optional since the backend
defaults it), and the dead, mismatched RegisterPluginResponse is deleted.
registerClaudeCodePlugin's inline payload type (which was missing the
git-subdir path field entirely) is replaced with SkillRegisterRequest, so
the networking layer and the form can no longer drift from the backend.

Error handling: the add-skill form swallowed the real failure and always
showed "Failed to register skill". registerClaudeCodePlugin already
derives the backend message and throws it, so the form now surfaces it
("Failed to register skill: <reason>"), and the networking helper falls
back to the raw body / status when the error response is not JSON instead
of throwing a JSON parse error. A regression test asserts the backend
message reaches the user.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts Outdated
@veria-ai

veria-ai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

A repo URL with embedded user-info (user:token@host) passed the raw-host
parser and was stored verbatim as the skill source, which is served on
the unauthenticated /public/skill_hub and marketplace.json feeds, leaking
the credentials. Reject any host segment containing '@'.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re review

Comment thread ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts Outdated
Replace the ad-hoc string parsing (stripScheme / splitHost / manual
scheme, @, ?# checks) with a single parseRepoUrl gate built on the URL
parser, so every malformed/unsafe class is handled in one place and the
URL stored on the public skill feeds is always canonical. It enforces
https (rejecting http/ssh/git/file/javascript/data and protocol-relative
//host), rejects embedded credentials (user:token@host, including
userinfo-confusion like github.com@evil.com), rejects IP-literal hosts
(loopback/private/metadata and obfuscated/IPv6 forms), and rebuilds the
stored url from origin+pathname so query strings, fragments, and trailing
slashes can never be published. The GitHub org/repo shorthand is now
charset-validated like the other paths, so junk can't reach the stored
repo. Closes both Veria findings (credentialed and http sources) plus the
adversarial-review follow-ups, with regression tests for each class.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re review

@ryan-crabbe-berri
ryan-crabbe-berri merged commit 7ed25de into litellm_internal_staging Jun 30, 2026
125 checks passed
@ryan-crabbe-berri
ryan-crabbe-berri deleted the litellm_lit_4053_skills_git_url branch June 30, 2026 17:29
tiannianzhu pushed a commit to tiannianzhu/litellm that referenced this pull request Jul 3, 2026
…I#31652)

* fix(ui): allow any git host on the skills add form (LIT-4053)

The skills add form only accepted GitHub URLs: its URL parser bailed on
any host that did not start with github.com, so GitLab, Bitbucket, and
self-hosted repos (and any repo subfolder on them) were rejected before a
request was ever sent. The backend already accepts arbitrary git hosts
via its url and git-subdir sources, with no host allowlist, so this was a
client-side restriction only.

Generalize the parser into an exported, host-agnostic parseSkillSource:
GitHub URLs keep their github / git-subdir shorthand, every other host is
treated as a raw repo url, and an optional Subfolder path field turns any
repo into a git-subdir source (url + path). When a pasted GitHub
tree/blob URL already encodes a subfolder, the field is cleared and
disabled so a contradictory source can never be submitted.

The parser is hardened to match the backend contract: query strings and
fragments are stripped, the host match is case-insensitive and drops a
leading www., the extracted and field-entered subfolder paths are both
validated against the same regex the server uses, a real file-extension
allowlist (not "any dot") decides whether a trailing blob segment is a
file, a branch-only tree URL falls back to the repo, non-GitHub URLs
require at least an org/repo, and the suggested skill name is kebab-cased
so it satisfies the name field's own rule.

The git-subdir source is now handled in the display helpers
(getSourceDisplayText, getSourceLink, formatInstallCommand), which
previously showed it as "Unknown source" with no link. The submit path
is fully typed (RegisterPluginRequest plus an AddPluginFormValues
interface), removing the two prior any usages; as a result an
author with an email but no name is dropped rather than sent, since the
backend requires the author name.

No backend changes. Tests cover the full host/subfolder matrix at the
parser level plus form-submit assertions on the exact source payload.

* refactor(ui): sync skill register types to the generated OpenAPI schema, surface backend errors

Replace the hand-maintained, already-drifted API types for the skills add
flow with the generated ones from schema.d.ts: PluginAuthor now aliases
components["schemas"]["PluginAuthor"], the registration payload is a new
SkillRegisterRequest (the generated RegisterPluginRequest envelope with
source narrowed to our PluginSource union, since the backend types source
as a loose string map, and version kept optional since the backend
defaults it), and the dead, mismatched RegisterPluginResponse is deleted.
registerClaudeCodePlugin's inline payload type (which was missing the
git-subdir path field entirely) is replaced with SkillRegisterRequest, so
the networking layer and the form can no longer drift from the backend.

Error handling: the add-skill form swallowed the real failure and always
showed "Failed to register skill". registerClaudeCodePlugin already
derives the backend message and throws it, so the form now surfaces it
("Failed to register skill: <reason>"), and the networking helper falls
back to the raw body / status when the error response is not JSON instead
of throwing a JSON parse error. A regression test asserts the backend
message reaches the user.

* fix(ui): reject credentialed git URLs on the skills form

A repo URL with embedded user-info (user:token@host) passed the raw-host
parser and was stored verbatim as the skill source, which is served on
the unauthenticated /public/skill_hub and marketplace.json feeds, leaking
the credentials. Reject any host segment containing '@'.

* fix(ui): validate skill repo URLs through one WHATWG URL gate

Replace the ad-hoc string parsing (stripScheme / splitHost / manual
scheme, @, ?# checks) with a single parseRepoUrl gate built on the URL
parser, so every malformed/unsafe class is handled in one place and the
URL stored on the public skill feeds is always canonical. It enforces
https (rejecting http/ssh/git/file/javascript/data and protocol-relative
//host), rejects embedded credentials (user:token@host, including
userinfo-confusion like github.com@evil.com), rejects IP-literal hosts
(loopback/private/metadata and obfuscated/IPv6 forms), and rebuilds the
stored url from origin+pathname so query strings, fragments, and trailing
slashes can never be published. The GitHub org/repo shorthand is now
charset-validated like the other paths, so junk can't reach the stored
repo. Closes both Veria findings (credentialed and http sources) plus the
adversarial-review follow-ups, with regression tests for each class.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants