diff --git a/src/selfhost/private-config.ts b/src/selfhost/private-config.ts index 4d099e5033..65eb391270 100644 --- a/src/selfhost/private-config.ts +++ b/src/selfhost/private-config.ts @@ -16,11 +16,18 @@ // per-repo file fully REPLACES the global fallback — "fallback" means "used only when no per-repo file exists", // not a deep merge). The slug is lowercased (GitHub repo full-names are case-insensitive; #1390 already lowercased). import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { RepoFocusManifestFetcher } from "../signals/focus-manifest-loader"; /** The bare config filenames tried inside a per-repo folder and at the dir root (global fallback), in priority order. */ const CONFIG_BASENAMES = [".gittensory.yml", ".gittensory.yaml", ".gittensory.json"] as const; +const GITHUB_OWNER_SEGMENT = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; +const GITHUB_REPO_SEGMENT = /^[a-z0-9._-]+$/; + +function isSafeRepoSegment(segment: string): boolean { + return segment !== "." && segment !== ".." && GITHUB_REPO_SEGMENT.test(segment); +} + /** Global-fallback candidates (relative to GITTENSORY_REPO_CONFIG_DIR): the dir-root `.gittensory.{yml,yaml,json}` * applied to any repo without its own per-repo file. */ export const GLOBAL_CONFIG_CANDIDATES: string[] = [...CONFIG_BASENAMES]; @@ -31,9 +38,10 @@ export const GLOBAL_CONFIG_CANDIDATES: string[] = [...CONFIG_BASENAMES]; * the lowercased repo name. An invalid repo full name (no single interior slash) yields no candidates. */ export function localConfigCandidates(repoFullName: string): string[] { const slash = repoFullName.indexOf("/"); - if (slash <= 0 || slash === repoFullName.length - 1) return []; + if (slash <= 0 || slash === repoFullName.length - 1 || slash !== repoFullName.lastIndexOf("/")) return []; const owner = repoFullName.slice(0, slash).toLowerCase(); const repo = repoFullName.slice(slash + 1).toLowerCase(); + if (!GITHUB_OWNER_SEGMENT.test(owner) || !isSafeRepoSegment(repo)) return []; const slug = `${owner}__${repo}`; return [ // 1. owner-qualified folder — `{owner}__{repo}/.gittensory.{yml,yaml,json}` @@ -52,14 +60,15 @@ export function localConfigCandidates(repoFullName: string): string[] { * candidates and is NOT served the global fallback (it is never a real webhook repo). A read error on one * candidate is swallowed so the next candidate is tried. */ export function makeLocalManifestReader(dir: string | undefined): RepoFocusManifestFetcher | null { - const base = (dir ?? "").trim(); - if (!base) return null; + const trimmed = (dir ?? "").trim(); + if (!trimmed) return null; + const base = resolve(trimmed); return async (repoFullName: string): Promise => { const perRepo = localConfigCandidates(repoFullName); if (perRepo.length === 0) return null; // invalid repo name → no per-repo file AND no global fallback for (const candidate of [...perRepo, ...GLOBAL_CONFIG_CANDIDATES]) { try { - return await readFile(join(base, candidate), "utf8"); + return await readFile(resolve(base, candidate), "utf8"); } catch { // ENOENT / unreadable → try the next candidate } diff --git a/test/unit/private-config.test.ts b/test/unit/private-config.test.ts index 3b3812f61d..16d0871e93 100644 --- a/test/unit/private-config.test.ts +++ b/test/unit/private-config.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { GLOBAL_CONFIG_CANDIDATES, localConfigCandidates, makeLocalManifestReader } from "../../src/selfhost/private-config"; @@ -25,6 +25,12 @@ describe("localConfigCandidates (container-private config paths)", () => { expect(localConfigCandidates("no-slash")).toEqual([]); // slash < 0 → slash <= 0 expect(localConfigCandidates("/leading")).toEqual([]); // slash at 0 → slash <= 0 expect(localConfigCandidates("trailing/")).toEqual([]); // slash at len-1 + expect(localConfigCandidates("owner/repo/extra")).toEqual([]); // more than one slash + expect(localConfigCandidates("owner/..")).toEqual([]); + expect(localConfigCandidates("owner/.")).toEqual([]); + expect(localConfigCandidates("owner/repo name")).toEqual([]); + expect(localConfigCandidates("bad_owner/repo")).toEqual([]); + expect(localConfigCandidates("-owner/repo")).toEqual([]); }); it("exposes the dir-root global-fallback candidates", () => { expect(GLOBAL_CONFIG_CANDIDATES).toEqual([".gittensory.yml", ".gittensory.yaml", ".gittensory.json"]); @@ -90,4 +96,11 @@ describe("makeLocalManifestReader (GITTENSORY_REPO_CONFIG_DIR)", () => { const reader = makeLocalManifestReader(dir); expect(await reader!("no-slash")).toBeNull(); // perRepo.length === 0 early return }); + + it("rejects traversal repo names instead of reading outside the private config directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + writeFileSync(join(dirname(dir), ".gittensory.yml"), "gate:\n enabled: true\n"); + const reader = makeLocalManifestReader(dir); + expect(await reader!("owner/..")).toBeNull(); + }); });