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.