SPB Git

Repository Public

git.ts 4.5 KB 110 lines typescript

1/**2 * The only two git invocations of the server: `git init` and `git clone`. Always an argv array3 * (never a shell), a minimal environment (no API keys, no prompts), a hard timeout, and a URL4 * whitelist. Everything else about a repository is read from files, never by running git in it.5 */6import { execFile } from 'node:child_process';7import fs from 'node:fs';8import path from 'node:path';9import { HttpError } from '../../http.js';1011/**12 * Accepted clone URLs: `https://…`, `ssh://…`, `git@host:path`, `gitsrv:path` (the cluster's git13 * alias). No whitespace or control characters, no leading `-`, no `ext::`/`file:`/local paths.14 */15const URL_PATTERNS = [16  /^https:\/\/[A-Za-z0-9._~:@%+-]+(\/[A-Za-z0-9._~%+@/-]*)?$/,17  /^ssh:\/\/[A-Za-z0-9._~@-]+(:\d{1,5})?\/[A-Za-z0-9._~%+@/-]+$/,18  /^git@[A-Za-z0-9.-]+:[A-Za-z0-9._~/-]+$/,19  /^gitsrv:[A-Za-z0-9._~/-]+$/,20];21export function validateGitUrl(url: string): string {22  if (url.length > 500 || !URL_PATTERNS.some((re) => re.test(url)) || url.includes('..')) {23    throw new HttpError(400, 'invalid_git_url', 'git URL not allowed (https://, ssh://, git@host:path or gitsrv:path)');24  }25  return url;26}2728/** Removes `user:password@` from an http(s) URL so credentials are never stored, logged or shown. */29export function sanitizeRemote(url: string | null): string | null {30  if (!url) return null;31  const clean = url.trim().slice(0, 500);32  return /^https?:\/\//i.test(clean) ? clean.replace(/^(https?:\/\/)[^/@]*@/i, '$1') : clean;33}3435export function gitEnv(): NodeJS.ProcessEnv {36  const env: NodeJS.ProcessEnv = {37    PATH: process.env.PATH ?? '/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin',38    HOME: process.env.HOME ?? '/',39    LANG: 'C',40    GIT_TERMINAL_PROMPT: '0',41    GIT_ASKPASS: '/usr/bin/false',42    SSH_ASKPASS: '/usr/bin/false',43    GIT_SSH_COMMAND: 'ssh -oBatchMode=yes',44    GIT_CONFIG_NOSYSTEM: '1',45  };46  if (process.env.SSH_AUTH_SOCK) env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;47  return env;48}4950function git(args: string[], opts: { cwd?: string; timeoutMs: number }): Promise<void> {51  return new Promise((resolve, reject) => {52    execFile('git', args, { cwd: opts.cwd, env: gitEnv(), timeout: opts.timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, windowsHide: true }, (err) => {53      if (err) reject(err);54      else resolve();55    });56  });57}5859export async function gitInit(dir: string): Promise<void> {60  await git(['init', '--quiet'], { cwd: dir, timeoutMs: 30_000 });61}6263/**64 * Clones into `dest` (which must not exist). On ANY failure the partial directory is removed.65 * Dangerous transports are disabled even if a redirect or submodule asks for them.66 */67export async function gitClone(url: string, dest: string, timeoutMs: number): Promise<void> {68  try {69    await git(['-c', 'protocol.ext.allow=never', '-c', 'protocol.file.allow=never', '-c', 'core.hooksPath=/dev/null', 'clone', '--quiet', '--no-recurse-submodules', '--', url, dest], { timeoutMs });70  } catch (err) {71    fs.rmSync(dest, { recursive: true, force: true });72    const e = err as NodeJS.ErrnoException & { killed?: boolean };73    if (e.code === 'ENOENT') throw new HttpError(503, 'git_unavailable', 'git is not installed on this host');74    if (e.killed) throw new HttpError(504, 'clone_timeout', 'git clone timed out');75    // stderr may quote the URL (and credentials embedded in it): never forwarded.76    throw new HttpError(422, 'clone_failed', 'git clone failed (repository unreachable, not found or access denied)');77  }78}7980/** Current branch from `.git/HEAD` (null when detached, not a repository, or unreadable). */81export function readBranch(dir: string): string | null {82  try {83    const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8').slice(0, 400).trim();84    const m = /^ref: refs\/heads\/([^\s]{1,200})$/.exec(head);85    return m?.[1] ?? null;86  } catch {87    return null;88  }89}9091/** `remote.origin.url` from `.git/config`, sanitised (null when absent). */92export function readOriginUrl(dir: string): string | null {93  try {94    const stat = fs.statSync(path.join(dir, '.git', 'config'));95    if (!stat.isFile() || stat.size > 256 * 1024) return null;96    let inOrigin = false;97    for (const line of fs.readFileSync(path.join(dir, '.git', 'config'), 'utf8').split('\n')) {98      const t = line.trim();99      if (t.startsWith('[')) inOrigin = /^\[remote\s+"origin"\]$/.test(t);100      else if (inOrigin) {101        const m = /^url\s*=\s*(\S+)$/.exec(t);102        if (m?.[1]) return sanitizeRemote(m[1]);103      }104    }105  } catch {106    /* not a repository */107  }108  return null;109}110
Files main
Menu

Theme