diff --git a/.vex/README.md b/.vex/README.md index fb9a1f8..e581041 100644 --- a/.vex/README.md +++ b/.vex/README.md @@ -36,3 +36,4 @@ different user model. Debian's status for a CVE is at | File | CVEs | Package | Why | | --- | --- | --- | --- | | `util-linux-mount-nsenter.vex.json` | CVE-2026-78408, CVE-2026-78409, CVE-2026-78410 | util-linux 2.41.5-0+deb13u1 (trixie, no fix planned) | nsenter and setuid mount(8) are never used; the runner stage strips setuid bits and the app runs unprivileged. | +| `zlib-gz-vacate.vex.json` | CVE-2026-85091 | zlib 1:1.3.dfsg+really1.3.1-1 (trixie, unfixed in every Debian release) | The gz* write API (gzwrite, gzprintf) is never used: git, git-lfs and Bun only use the deflate and inflate stream functions. | diff --git a/.vex/zlib-gz-vacate.vex.json b/.vex/zlib-gz-vacate.vex.json new file mode 100644 index 0000000..e11b899 --- /dev/null +++ b/.vex/zlib-gz-vacate.vex.json @@ -0,0 +1,26 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/RayLabsHQ/gitea-mirror/blob/main/.vex/zlib-gz-vacate.vex.json", + "author": "developer@arunavoray.dev", + "role": "Gitea Mirror maintainer", + "timestamp": "2026-09-07T01:20:00Z", + "version": 1, + "statements": [ + { + "vulnerability": { + "name": "CVE-2026-85091", + "description": "zlib gz_vacate() heap buffer overflow: a non-blocking gzwrite() that stalls leaves a stale external buffer pointer, and a later gzprintf() or gzvprintf() moves more bytes than the internal input buffer holds." + }, + "timestamp": "2026-09-07T01:20:00Z", + "products": [ + { "@id": "pkg:docker/raylabshq/gitea-mirror" }, + { "@id": "pkg:docker/ghcr.io/raylabshq/gitea-mirror" }, + { "@id": "pkg:docker/gitea-mirror" }, + { "@id": "pkg:docker/library/gitea-mirror" } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "The flaw sits in zlib's gz* file writing API (gzwrite, gzprintf, gzvprintf) and needs a gz stream opened for writing on a non-blocking descriptor that stalls. Nothing in this image writes gzip files through that API: git links only the deflate and inflate stream entry points for its objects and packs, git-lfs is a Go binary with its own compress/flate, and Bun's zlib bindings expose stream compression only. The application itself never opens gz streams. Debian ships zlib 1:1.3.dfsg+really1.3.1-1 in trixie and has no fix in any release (bug #1146895); upstream describes the affected range as 1.3.1.2 through 1.3.2." + } + ] +} diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index 30b2eca..fd4634a 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -708,7 +708,7 @@ export function ConfigTabs() { Configuration

- Configure your GitHub and Gitea connections, and set up automatic + Connect your sources and destination, and set up automatic mirroring.

@@ -718,10 +718,10 @@ export function ConfigTabs() { disabled={isSyncing || !isGitHubConfigValid()} title={ !isGitHubConfigValid() - ? 'Please fill GitHub username and token fields' + ? 'Connect a source with a token first' : isSyncing ? 'Import in progress' - : 'Import GitHub Data' + : `Import ${sourceLabel} Data` } className="w-full bg-indigo-500 text-white hover:bg-indigo-600 disabled:bg-muted disabled:text-muted-foreground md:w-auto" > @@ -824,6 +824,7 @@ export function ConfigTabs() { void; /** Which card to render; defaults to both. */ part?: "selection" | "content" | "both"; + /** + * Connected sources. The GitHub-only switches stay available while any of + * them is GitHub; `githubConfig.provider` alone only describes the primary. + */ + sources?: ReadonlyArray<{ provider: string }>; } export function GitHubMirrorSettings({ @@ -94,6 +99,7 @@ export function GitHubMirrorSettings({ onAdvancedOptionsChange, destinationProvider, part = "both", + sources, }: GitHubMirrorSettingsProps) { // A GitHub or GitLab destination is pushed by the engine: branches and // tags only, so every content switch is off and disabled for it. @@ -103,7 +109,11 @@ export function GitHubMirrorSettings({ // Star lists, issues, pull requests, releases, labels and milestones all // read the GitHub API, so they are only offered for GitHub sources. const sourceProvider = githubConfig.provider ?? "github"; - const isGithubSource = sourceProvider === "github" && !isPushTarget; + const hasGithubSource = + sources && sources.length > 0 + ? sources.some((source) => source.provider === "github") + : sourceProvider === "github"; + const isGithubSource = hasGithubSource && !isPushTarget; const sourceLabel = SOURCE_PROVIDER_LABELS[sourceProvider]; const orgNoun = SOURCE_PROVIDER_ORG_NOUNS[sourceProvider]; const [starListsOpen, setStarListsOpen] = React.useState(false); diff --git a/src/components/config/SourcesCard.tsx b/src/components/config/SourcesCard.tsx index 009cbb5..c8b13fe 100644 --- a/src/components/config/SourcesCard.tsx +++ b/src/components/config/SourcesCard.tsx @@ -37,6 +37,7 @@ import { SOURCE_PROVIDER_DEFAULT_URLS, SOURCE_PROVIDER_LABELS, isBetaSourceProvider, + isValidSourceUrl, } from "@/lib/source-providers/kinds"; import { invalidateConfigCache } from "@/hooks/useConfigStatus"; import { HostLockNotice } from "./HostLockNotice"; @@ -125,6 +126,25 @@ function sourceHost(source: SourceApiRecord): string { return url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); } +/** + * The token settings page of an instance, or null when the URL is not an + * http(s) URL. The link is built scheme first from constant strings and the + * parsed host and path, never from the typed text itself, so a pasted + * `javascript:` URL can never become the link target. + */ +function tokenSettingsUrlFor(instanceUrl: string, settingsPath: string): string | null { + if (!isValidSourceUrl(instanceUrl)) return null; + let parsed: URL; + try { + parsed = new URL(instanceUrl); + } catch { + return null; + } + const scheme = parsed.protocol === "http:" ? "http://" : "https://"; + const basePath = parsed.pathname.replace(/\/+$/, ""); + return scheme + parsed.host + basePath + settingsPath; +} + function repositoryNoun(count: number): string { return `${count} ${count === 1 ? "repository" : "repositories"}`; } @@ -181,7 +201,11 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { const providerLabel = providerMeta.label; const defaultInstanceUrl = SOURCE_PROVIDER_DEFAULT_URLS[provider]; const instanceUrl = (editor?.url ?? "").trim() || defaultInstanceUrl; - const tokenSettingsUrl = `${instanceUrl.replace(/\/+$/, "")}${providerMeta.tokenSettingsPath}`; + // Falls back to the provider default while the typed URL is not usable yet. + const tokenSettingsUrl = + tokenSettingsUrlFor(instanceUrl, providerMeta.tokenSettingsPath) ?? + tokenSettingsUrlFor(defaultInstanceUrl, providerMeta.tokenSettingsPath) ?? + defaultInstanceUrl; // An empty token field while editing keeps the stored one, so testing // and saving both fall back to it. const effectiveToken = editor?.token.trim() || editing?.token || ""; @@ -573,7 +597,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { {sources.length === 0 && !editor ? (

- No sources connected yet — add one to start mirroring. + No sources connected yet. Add one to start mirroring.

diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx index c226dfa..debe434 100644 --- a/src/components/organizations/Organization.tsx +++ b/src/components/organizations/Organization.tsx @@ -51,7 +51,7 @@ export function Organization() { const [isLoading, setIsLoading] = useState(true); const [isDialogOpen, setIsDialogOpen] = useState(false); const { user } = useAuth(); - const { isGitHubConfigured, sourceProvider } = useConfigStatus(); + const { isGitHubConfigured, sourceProvider, sourceUrl } = useConfigStatus(); const { navigationKey } = useNavigation(); const { registerRefreshCallback } = useLiveRefresh(); const { filter, setFilter } = useFilterParams({ @@ -887,6 +887,7 @@ export function Organization() { await fetchOrganizations(false); }} sourceProvider={sourceProvider} + sourceUrl={sourceUrl} /> { diff --git a/src/components/organizations/OrganizationsList.tsx b/src/components/organizations/OrganizationsList.tsx index 3197937..1281b92 100644 --- a/src/components/organizations/OrganizationsList.tsx +++ b/src/components/organizations/OrganizationsList.tsx @@ -45,6 +45,8 @@ interface OrganizationListProps { onRefresh?: () => Promise; onDelete?: (orgId: string) => void; sourceProvider?: SourceProviderKind; + /** Normalized instance URL of the configured source; falls back to the cached config. */ + sourceUrl?: string; } // Helper function to get status badge variant and icon @@ -77,15 +79,15 @@ export function OrganizationList({ onRefresh, onDelete, sourceProvider = "github", + sourceUrl: sourceUrlProp, }: OrganizationListProps) { const { giteaConfig, mirrorOptions, advancedOptions } = useGiteaConfig(); const [overridesTarget, setOverridesTarget] = useState(null); const sourceLabel = SOURCE_PROVIDER_LABELS[sourceProvider]; - const sourceUrl = normalizeSourceUrl( - getCachedConfig()?.githubConfig?.url, - sourceProvider - ); + const sourceUrl = + sourceUrlProp ?? + normalizeSourceUrl(getCachedConfig()?.githubConfig?.url, sourceProvider); const sourceShortLabel = sourceProvider === "gitea" ? "Gitea" : sourceLabel; const SourceIcon = { github: SiGithub, gitlab: SiGitlab, gitea: SiGitea }[sourceProvider]; diff --git a/src/hooks/useConfigStatus.ts b/src/hooks/useConfigStatus.ts index 8b62643..8d50660 100644 --- a/src/hooks/useConfigStatus.ts +++ b/src/hooks/useConfigStatus.ts @@ -2,10 +2,10 @@ import { useCallback, useEffect, useState, useRef } from 'react'; import { useAuth } from './useAuth'; import { apiRequest } from '@/lib/utils'; import type { ConfigApiResponse, SourceApiRecord } from '@/types/config'; -import type { ConfigApiResponse } from '@/types/config'; import { DEFAULT_SOURCE_PROVIDER, normalizeSourceProviderKind, + normalizeSourceUrl, type SourceProviderKind, } from '@/lib/source-providers/kinds'; @@ -18,7 +18,9 @@ interface ConfigStatus { autoMirrorStarred: boolean; githubOwner: string; sources: SourceApiRecord[]; + /** The configured (primary) source kind and its normalized instance URL. */ sourceProvider: SourceProviderKind; + sourceUrl: string; } // Cache to prevent duplicate API calls across components @@ -47,6 +49,7 @@ export function useConfigStatus(): ConfigStatus { githubOwner: '', sources: [], sourceProvider: DEFAULT_SOURCE_PROVIDER, + sourceUrl: normalizeSourceUrl(undefined, DEFAULT_SOURCE_PROVIDER), }); // Track if this hook has already checked config to prevent multiple calls @@ -64,6 +67,7 @@ export function useConfigStatus(): ConfigStatus { githubOwner: '', sources: [], sourceProvider: DEFAULT_SOURCE_PROVIDER, + sourceUrl: normalizeSourceUrl(undefined, DEFAULT_SOURCE_PROVIDER), }); return; } @@ -102,6 +106,10 @@ export function useConfigStatus(): ConfigStatus { githubOwner: configResponse?.githubConfig?.username ?? '', sources: configResponse?.sources ?? [], sourceProvider: normalizeSourceProviderKind(configResponse?.githubConfig?.provider), + sourceUrl: normalizeSourceUrl( + configResponse?.githubConfig?.url, + normalizeSourceProviderKind(configResponse?.githubConfig?.provider) + ), }); return; } @@ -149,6 +157,10 @@ export function useConfigStatus(): ConfigStatus { githubOwner: configResponse?.githubConfig?.username ?? '', sources: configResponse?.sources ?? [], sourceProvider: normalizeSourceProviderKind(configResponse?.githubConfig?.provider), + sourceUrl: normalizeSourceUrl( + configResponse?.githubConfig?.url, + normalizeSourceProviderKind(configResponse?.githubConfig?.provider) + ), }); hasCheckedRef.current = true; @@ -163,6 +175,7 @@ export function useConfigStatus(): ConfigStatus { githubOwner: '', sources: [], sourceProvider: DEFAULT_SOURCE_PROVIDER, + sourceUrl: normalizeSourceUrl(undefined, DEFAULT_SOURCE_PROVIDER), }); hasCheckedRef.current = true; } diff --git a/src/lib/sources.test.ts b/src/lib/sources.test.ts index 8273b2f..6be7b2f 100644 --- a/src/lib/sources.test.ts +++ b/src/lib/sources.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { decryptSourceToken, deriveSourceName, @@ -137,6 +137,11 @@ describe("selectRepositoriesToRelink", () => { }); describe("decryptSourceToken", () => { + beforeAll(() => { + // encrypt() needs a secret; do not depend on the environment or on test order. + process.env.ENCRYPTION_SECRET ??= "sources-test-encryption-secret"; + }); + test("round-trips an encrypted token", () => { expect(decryptSourceToken(encrypt("ghp-secret"))).toBe("ghp-secret"); }); diff --git a/src/lib/utils/github-url.test.ts b/src/lib/utils/github-url.test.ts index 098b3a5..8e4fb5c 100644 --- a/src/lib/utils/github-url.test.ts +++ b/src/lib/utils/github-url.test.ts @@ -88,6 +88,14 @@ describe("parseGitHubOwnerReference", () => { expect(parseGitHubOwnerReference("")).toBeNull(); expect(parseGitHubOwnerReference(" ")).toBeNull(); }); + + it("follows the looser account naming of other hosts", () => { + expect(parseGitHubOwnerReference("https://gitlab.example.com/my_group")).toBe("my_group"); + expect(parseGitHubOwnerReference("https://codeberg.org/acme.io")).toBe("acme.io"); + expect(parseGitHubOwnerReference("https://gitlab.com/groups/acme/-/issues")).toBe("acme"); + // github.com keeps GitHub's rule. + expect(parseGitHubOwnerReference("https://github.com/my_org")).toBeNull(); + }); }); describe("looksLikeUrl", () => { diff --git a/src/lib/utils/github-url.ts b/src/lib/utils/github-url.ts index 7fb8307..13ee9a6 100644 --- a/src/lib/utils/github-url.ts +++ b/src/lib/utils/github-url.ts @@ -88,10 +88,6 @@ export function parseRepoReferenceParts(input: string): RepoReferenceParts | nul return segments.length > 0 ? { host, segments } : null; } -function toPathSegments(input: string): string[] | null { - return parseRepoReferenceParts(input)?.segments ?? null; -} - /** Segments that start the "rest of the page" after owner/repo on a flat host. */ const FLAT_HOST_DEEP_LINK_MARKERS = new Set([ "-", @@ -140,6 +136,16 @@ function isUsableAccount(segment: string | undefined): segment is string { return /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(segment); } +/** + * Account names on GitLab and Gitea/Forgejo may also contain underscores and + * dots (`my_group`, `acme.io`), which GitHub does not allow. + */ +function isUsableForgeAccount(segment: string | undefined): segment is string { + if (!segment) return false; + if (RESERVED_ROOT_SEGMENTS.has(segment.toLowerCase())) return false; + return /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(segment); +} + function isUsableRepoName(segment: string | undefined): segment is string { if (!segment) return false; return /^[A-Za-z0-9._-]+$/.test(segment); @@ -179,13 +185,20 @@ export function parseGitHubRepoReference( * Accepts the profile URL, the /orgs/ form, and a bare name. */ export function parseGitHubOwnerReference(input: string): string | null { - const segments = toPathSegments(input); - if (!segments) return null; + const parts = parseRepoReferenceParts(input); + if (!parts) return null; + const { host, segments } = parts; - const candidate = - segments[0]?.toLowerCase() === "orgs" ? segments[1] : segments[0]; + // GitHub lists organizations under /orgs/, GitLab under /groups/. + const root = segments[0]?.toLowerCase(); + const candidate = root === "orgs" || root === "groups" ? segments[1] : segments[0]; - return isUsableAccount(candidate) ? candidate : null; + // Only github.com gets GitHub's account rule; a URL pasted from another + // host follows that host's looser naming. + const isGitHubHost = + host === null || host === "github.com" || host.endsWith(".github.com"); + const usable = isGitHubHost ? isUsableAccount(candidate) : isUsableForgeAccount(candidate); + return usable ? candidate : null; } /** True when the input looks like a URL rather than a plain name. */