From f9cebe9b732e2f3ee8497b2d025f7672531627f4 Mon Sep 17 00:00:00 2001 From: statxc <181730535+statxc@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:05:48 +0000 Subject: [PATCH] fix: harden GHE URL detection and extract shared GitHub helpers --- cli/src/commands/client/company.ts | 2 +- server/src/services/company-portability.ts | 24 +---------------- server/src/services/company-skills.ts | 30 +++++----------------- server/src/services/github-fetch.ts | 25 ++++++++++++++++++ ui/src/components/NewProjectDialog.tsx | 2 +- ui/src/components/ProjectProperties.tsx | 2 +- 6 files changed, 35 insertions(+), 50 deletions(-) create mode 100644 server/src/services/github-fetch.ts diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 5be1fbe5..5d82abd0 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -768,7 +768,7 @@ export function isHttpUrl(input: string): boolean { export function isGithubUrl(input: string): boolean { try { const url = new URL(input.trim()); - if (url.protocol !== "https:" && url.protocol !== "http:") return false; + if (url.protocol !== "https:") return false; const segments = url.pathname.split("/").filter(Boolean); return segments.length >= 2; } catch { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 1f7f6a31..256e98d3 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -45,6 +45,7 @@ import { writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; import { notFound, unprocessable } from "../errors.js"; +import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; @@ -2100,14 +2101,6 @@ function parseFrontmatterMarkdown(raw: string): MarkdownDoc { }; } -async function ghFetch(url: string, init?: RequestInit): Promise { - try { - return await fetch(url, init); - } catch { - throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); - } -} - async function fetchText(url: string) { const response = await ghFetch(url); if (!response.ok) { @@ -2619,21 +2612,6 @@ export function parseGitHubSourceUrl(rawUrl: string) { return { hostname, owner, repo, ref, basePath, companyPath }; } -function isGitHubDotCom(hostname: string) { - const h = hostname.toLowerCase(); - return h === "github.com" || h === "www.github.com"; -} - -function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) { - const p = filePath.replace(/^\/+/, ""); - return isGitHubDotCom(hostname) - ? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}` - : `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`; -} - -function gitHubApiBase(hostname: string) { - return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`; -} export function companyPortabilityService(db: Db, storage?: StorageService) { const companies = companyService(db); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index a9976644..8cefd2fd 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -30,6 +30,7 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { findServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; +import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; @@ -470,14 +471,6 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record { - try { - return await fetch(url, init); - } catch { - throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); - } -} - async function fetchText(url: string) { const response = await ghFetch(url); if (!response.ok) { @@ -498,14 +491,6 @@ async function fetchJson(url: string): Promise { return response.json() as Promise; } -function isGitHubDotCom(hostname: string) { - const h = hostname.toLowerCase(); - return h === "github.com" || h === "www.github.com"; -} - -function gitHubApiBase(hostname: string) { - return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`; -} async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) { const response = await fetchJson<{ default_branch?: string }>( @@ -566,12 +551,6 @@ async function resolveGitHubPinnedRef(parsed: ReturnType { try { const parsed = new URL(url); - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; - return parsed.pathname.split("/").filter(Boolean).length >= 2 && !parsed.pathname.endsWith(".md"); + if (parsed.protocol !== "https:") return false; + const h = parsed.hostname.toLowerCase(); + if (h.endsWith(".githubusercontent.com") || h === "gist.github.com") return false; + const segments = parsed.pathname.split("/").filter(Boolean); + return segments.length >= 2 && !parsed.pathname.endsWith(".md"); } catch { return false; } })(); if (isGitHubRepoUrl) { const parsed = parseGitHubSourceUrl(url); diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts new file mode 100644 index 00000000..787ae0ef --- /dev/null +++ b/server/src/services/github-fetch.ts @@ -0,0 +1,25 @@ +import { unprocessable } from "../errors.js"; + +function isGitHubDotCom(hostname: string) { + const h = hostname.toLowerCase(); + return h === "github.com" || h === "www.github.com"; +} + +export function gitHubApiBase(hostname: string) { + return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`; +} + +export function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) { + const p = filePath.replace(/^\/+/, ""); + return isGitHubDotCom(hostname) + ? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}` + : `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`; +} + +export async function ghFetch(url: string, init?: RequestInit): Promise { + try { + return await fetch(url, init); + } catch { + throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); + } +} diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 86756c93..b3c58e6a 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -121,7 +121,7 @@ export function NewProjectDialog() { const isGitHubRepoUrl = (value: string) => { try { const parsed = new URL(value); - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + if (parsed.protocol !== "https:") return false; const segments = parsed.pathname.split("/").filter(Boolean); return segments.length >= 2; } catch { diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 5939c3f5..87c1431d 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -346,7 +346,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa const isGitHubRepoUrl = (value: string) => { try { const parsed = new URL(value); - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + if (parsed.protocol !== "https:") return false; const segments = parsed.pathname.split("/").filter(Boolean); return segments.length >= 2; } catch {