mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
fix: review follow-ups for the organizations dialog and multi-source gating (#406)
Review follow-ups after #403 and #404: dialog copy and placeholder, forge-aware owner parser, GitHub only mirror switches available while any connected source is GitHub, configuration header copy, self-contained encryption test. Also builds the Sources card token settings link from validated URL parts (CodeQL alert) and adds a VEX statement for CVE-2026-85091 in zlib, which nothing in the image can reach.
This commit is contained in:
1 parent
7dfb867202
commit
7d17194e51
12 files changed
+136
-25
No files matched your search
@@ -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. |
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -708,7 +708,7 @@ export function ConfigTabs() {
|
||||
Configuration
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your GitHub and Gitea connections, and set up automatic
|
||||
Connect your sources and destination, and set up automatic
|
||||
mirroring.
|
||||
</p>
|
||||
</div>
|
||||
@@ -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() {
|
||||
<GitHubMirrorSettings
|
||||
part="content"
|
||||
githubConfig={config.githubConfig}
|
||||
sources={sources}
|
||||
destinationProvider={config.giteaConfig.provider}
|
||||
mirrorOptions={config.mirrorOptions}
|
||||
advancedOptions={config.advancedOptions}
|
||||
|
||||
@@ -83,6 +83,11 @@ interface GitHubMirrorSettingsProps {
|
||||
onAdvancedOptionsChange: (options: AdvancedOptions) => 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);
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No sources connected yet — add one to start mirroring.
|
||||
No sources connected yet. Add one to start mirroring.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={openAddEditor}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
@@ -37,6 +37,8 @@ interface AddOrganizationDialogProps {
|
||||
force?: boolean;
|
||||
}) => Promise<void>;
|
||||
sourceProvider?: SourceProviderKind;
|
||||
/** Normalized instance URL of the configured source, for the placeholder. */
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export default function AddOrganizationDialog({
|
||||
@@ -44,6 +46,7 @@ export default function AddOrganizationDialog({
|
||||
setIsDialogOpen,
|
||||
onAddOrganization,
|
||||
sourceProvider = "github",
|
||||
sourceUrl,
|
||||
}: AddOrganizationDialogProps) {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [org, setOrg] = useState<string>("");
|
||||
@@ -54,7 +57,10 @@ export default function AddOrganizationDialog({
|
||||
const providerLabel = SOURCE_PROVIDER_LABELS[sourceProvider];
|
||||
const orgNoun = SOURCE_PROVIDER_ORG_NOUNS[sourceProvider];
|
||||
const orgNounCapitalized = orgNoun.charAt(0).toUpperCase() + orgNoun.slice(1);
|
||||
const urlPlaceholder = `${SOURCE_PROVIDER_DEFAULT_URLS[sourceProvider]}/your-${orgNoun}`;
|
||||
// "an organization" but "a group".
|
||||
const orgNounWithArticle = `${/^[aeiou]/i.test(orgNoun) ? "an" : "a"} ${orgNoun}`;
|
||||
const instanceUrl = (sourceUrl || SOURCE_PROVIDER_DEFAULT_URLS[sourceProvider]).replace(/\/+$/, "");
|
||||
const urlPlaceholder = `${instanceUrl}/your-${orgNoun}`;
|
||||
|
||||
const resetForm = () => {
|
||||
setError("");
|
||||
@@ -161,8 +167,8 @@ export default function AddOrganizationDialog({
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{urlIsUnparsed
|
||||
? "Could not read an organization from that."
|
||||
: `Paste a ${orgNoun} URL and the name below fills in.`}
|
||||
? `Could not read ${orgNounWithArticle} from that.`
|
||||
: `Paste ${orgNounWithArticle} URL and the name below fills in.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Organization() {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(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}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
@@ -894,6 +895,7 @@ export function Organization() {
|
||||
isDialogOpen={isDialogOpen}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
sourceProvider={sourceProvider}
|
||||
sourceUrl={sourceUrl}
|
||||
/>
|
||||
|
||||
<Dialog open={isDuplicateOrgDialogOpen} onOpenChange={(open) => {
|
||||
|
||||
@@ -45,6 +45,8 @@ interface OrganizationListProps {
|
||||
onRefresh?: () => Promise<void>;
|
||||
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<Organization | null>(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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/<name>, GitLab under /groups/<path>.
|
||||
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. */
|
||||
|
||||
Reference in new issue
Block a user