OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / git-shell-parse.js

94 lines · javascript ref:
/**
 * Parse + sanitize the git command a forced-command SSH session is allowed to
 * run (classic-git-auth Phase 2). The pgv3-git-shell wrapper receives the
 * client's request in $SSH_ORIGINAL_COMMAND; this turns it into a validated
 * { op, repo } or an error, refusing anything that isn't a git pack command.
 *
 * Pure + side-effect-free so it unit-tests without sshd. The wrapper stays a
 * thin shell around this + a UDS ACL check + exec of the real git binary.
 */

import { resolve, sep } from '#node:path'

// Strict allowlist — ONLY the two git pack commands (#74 Phase 4). This is the
// entire exec surface of the same-account forced command, so it stays minimal.
// git-upload-archive (`git archive --remote`) was deliberately DROPPED: it's an
// extra server-side exec rarely used here (clone + pgh/punch:// are the norm).
// Re-add a line here if a remote `git archive` workflow is ever needed.
const GIT_OPS = {
  'git-upload-pack': 'upload-pack',   // clone / fetch (read)
  'git-receive-pack': 'receive-pack'  // push (write)
}

/**
 * @param {string} cmd - raw $SSH_ORIGINAL_COMMAND
 * @returns {{ ok: true, op: string, repo: string } | { ok: false, error: string }}
 */
export function parseGitCommand (cmd) {
  if (typeof cmd !== 'string' || !cmd.trim()) {
    return { ok: false, error: 'interactive shell access is disabled — git only' }
  }
  // Accept both "git-receive-pack" and "git receive-pack" spellings.
  const s = cmd.trim().replace(/^git\s+(upload-pack|receive-pack)\b/, 'git-$1')

  const sp = s.indexOf(' ')
  if (sp === -1) return { ok: false, error: 'unsupported command: ' + clip(cmd) }

  const op = s.slice(0, sp)
  if (!GIT_OPS[op]) return { ok: false, error: 'only git fetch/push is permitted (got: ' + clip(op) + ')' }

  let arg = s.slice(sp + 1).trim()
  // strip ONE layer of surrounding single or double quotes
  if (arg.length >= 2 &&
      ((arg[0] === "'" && arg[arg.length - 1] === "'") ||
       (arg[0] === '"' && arg[arg.length - 1] === '"'))) {
    arg = arg.slice(1, -1)
  }

  // Normalize a clone path to a bare repo NAME: drop ~/, leading slashes,
  // and a trailing .git. Repos are flat (<name>.git); restrict to a safe
  // charset and reject traversal — belt-and-suspenders even though the wrapper
  // spawns git with an arg array (no shell), so the name is never interpreted.
  const repo = arg.replace(/^~\//, '').replace(/^\/+/, '').replace(/\.git$/, '')
  if (!repo || repo.includes('..') || !/^[A-Za-z0-9._-]+$/.test(repo)) {
    return { ok: false, error: 'invalid repository path: ' + clip(arg) }
  }

  return { ok: true, op: GIT_OPS[op], repo }
}

/** Is this op a write (push)? The read op (upload-pack) stays open. */
export function isWriteOp (op) {
  return op === 'receive-pack'
}

/**
 * Resolve a validated repo NAME to its absolute bare-repo path, and PROVE the
 * result is contained within reposDir (#74 Phase 4 — defense in depth).
 *
 * parseGitCommand already guarantees `repo` is a single safe segment (no `/`,
 * no `..`), so traversal is impossible upstream — but re-validating here means
 * a future loosening of the parser can't silently open a path-escape, and the
 * wrapper never spawns git on a path it hasn't proven safe. Lexical resolve is
 * sufficient: the client cannot create symlinks, so there is no realpath escape
 * to chase. Returns the absolute path, or null if anything is off.
 *
 * @param {string} reposDir - daemon's bare-repos dir (baked into the forced cmd)
 * @param {string} repo - bare repo name from parseGitCommand
 * @returns {string|null} absolute path to `<reposDir>/<repo>.git`, or null
 */
export function safeRepoPath (reposDir, repo) {
  if (typeof reposDir !== 'string' || !reposDir) return null
  if (typeof repo !== 'string' || repo.includes('..') || !/^[A-Za-z0-9._-]+$/.test(repo)) return null
  const base = resolve(reposDir)
  const full = resolve(base, repo + '.git')
  // Must be base itself (impossible here) or a child under base + separator.
  if (full !== base && !full.startsWith(base + sep)) return null
  return full
}

function clip (s) {
  s = String(s)
  return s.length > 40 ? s.slice(0, 40) + '…' : s
}