diff --git a/index.d.ts b/index.d.ts index 8545b379..5a94769c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -627,6 +627,9 @@ declare module "semantic-release" { * Any valid git url format is supported (see * [git protocols](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols)) * + * The value must not begin with `-`, as it would otherwise be interpreted + * by `git` as a command-line option rather than a URL. + * * Default: `repository` property in `package.json`, or git origin url. */ repositoryUrl?: string | undefined; diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 23ceff82..056f436d 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -42,6 +42,21 @@ Please make sure to add the \`repositoryUrl\` to the [semantic-release configura }; } +export function EINVALIDREPOURL({ repositoryUrl }) { + return { + message: "The `repositoryUrl` option is not a valid git URL.", + details: `The [repositoryUrl option](${linkify( + "usage/configuration#repositoryurl" + )}) must be a valid git URL and cannot begin with \`-\`, as \`git\` would otherwise interpret it as a command-line option rather than a URL. + +The invalid \`repositoryUrl\` is \`${repositoryUrl}\`. + +Please verify the \`repository\` property in your \`package.json\` and the [repositoryUrl option](${linkify( + "usage/configuration#repositoryurl" + )}) in your [semantic-release configuration](${linkify("usage/configuration#configuration")}).`, + }; +} + export function EGITNOPERMISSION({ options: { repositoryUrl }, branch: { name } }) { return { message: "Cannot push to the Git repository.", diff --git a/lib/git.js b/lib/git.js index b6ffb46b..3f5e7281 100644 --- a/lib/git.js +++ b/lib/git.js @@ -67,7 +67,7 @@ export async function getCommits(from, to, execaOptions) { * @throws {Error} If the `git` command fails. */ export async function getBranches(repositoryUrl, execaOptions) { - return (await execa("git", ["ls-remote", "--heads", repositoryUrl], execaOptions)).stdout + return (await execa("git", ["ls-remote", "--heads", "--", repositoryUrl], execaOptions)).stdout .split("\n") .filter(Boolean) .map((branch) => branch.match(/^.+refs\/heads\/(?.+)$/)[1]); @@ -115,8 +115,8 @@ export async function fetch(repositoryUrl, branch, ciBranch, execaOptions) { "--unshallow", "--tags", ...(branch === ciBranch && !isDetachedHead - ? [repositoryUrl] - : ["--update-head-ok", repositoryUrl, `+refs/heads/${branch}:refs/heads/${branch}`]), + ? ["--", repositoryUrl] + : ["--update-head-ok", "--", repositoryUrl, `+refs/heads/${branch}:refs/heads/${branch}`]), ], execaOptions ); @@ -127,8 +127,8 @@ export async function fetch(repositoryUrl, branch, ciBranch, execaOptions) { "fetch", "--tags", ...(branch === ciBranch && !isDetachedHead - ? [repositoryUrl] - : ["--update-head-ok", repositoryUrl, `+refs/heads/${branch}:refs/heads/${branch}`]), + ? ["--", repositoryUrl] + : ["--update-head-ok", "--", repositoryUrl, `+refs/heads/${branch}:refs/heads/${branch}`]), ], execaOptions ); @@ -143,9 +143,9 @@ export async function fetch(repositoryUrl, branch, ciBranch, execaOptions) { */ export async function fetchNotes(repositoryUrl, execaOptions) { try { - await execa("git", ["fetch", "--unshallow", repositoryUrl, `+refs/notes/*:refs/notes/*`], execaOptions); + await execa("git", ["fetch", "--unshallow", "--", repositoryUrl, `+refs/notes/*:refs/notes/*`], execaOptions); } catch { - await execa("git", ["fetch", repositoryUrl, `+refs/notes/*:refs/notes/*`], { + await execa("git", ["fetch", "--", repositoryUrl, `+refs/notes/*:refs/notes/*`], { ...execaOptions, reject: false, }); @@ -204,7 +204,7 @@ export async function isGitRepo(execaOptions) { */ export async function verifyAuth(repositoryUrl, branch, execaOptions) { try { - await execa("git", ["push", "--dry-run", "--no-verify", repositoryUrl, `HEAD:${branch}`], execaOptions); + await execa("git", ["push", "--dry-run", "--no-verify", "--", repositoryUrl, `HEAD:${branch}`], execaOptions); } catch (error) { debug(error); throw error; @@ -233,7 +233,7 @@ export async function tag(tagName, ref, execaOptions) { * @throws {Error} if the push failed. */ export async function push(repositoryUrl, execaOptions) { - await execa("git", ["push", "--tags", repositoryUrl], execaOptions); + await execa("git", ["push", "--tags", "--", repositoryUrl], execaOptions); } /** @@ -245,7 +245,7 @@ export async function push(repositoryUrl, execaOptions) { * @throws {Error} if the push failed. */ export async function pushNotes(repositoryUrl, ref, execaOptions) { - await execa("git", ["push", repositoryUrl, `refs/notes/${GIT_NOTE_REF}-${ref}`], execaOptions); + await execa("git", ["push", "--", repositoryUrl, `refs/notes/${GIT_NOTE_REF}-${ref}`], execaOptions); } /** @@ -292,7 +292,9 @@ export async function verifyBranchName(branch, execaOptions) { export async function isBranchUpToDate(repositoryUrl, branch, execaOptions) { return ( (await getGitHead(execaOptions)) === - (await execa("git", ["ls-remote", "--heads", repositoryUrl, branch], execaOptions)).stdout.match(/^(?\w+)?/)[1] + (await execa("git", ["ls-remote", "--heads", "--", repositoryUrl, branch], execaOptions)).stdout.match( + /^(?\w+)?/ + )[1] ); } diff --git a/lib/verify.js b/lib/verify.js index 62419f71..b9a68254 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -15,6 +15,10 @@ export default async (context) => { errors.push(getError("ENOGITREPO", { cwd })); } else if (!repositoryUrl) { errors.push(getError("ENOREPOURL")); + } else if (repositoryUrl.startsWith("-")) { + // A `repositoryUrl` beginning with `-` would be interpreted by `git` as a + // command-line option rather than a URL, enabling argument injection. + errors.push(getError("EINVALIDREPOURL", { repositoryUrl })); } // Verify that compiling the `tagFormat` produce a valid Git tag diff --git a/test/git.test.js b/test/git.test.js index 9f384aa6..8b8ea0be 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; import test from "ava"; import { temporaryDirectory } from "tempy"; import { @@ -423,3 +425,30 @@ test("Fetch all notes on a detached head repository", async (t) => { t.is(await gitGetNote(commit.hash, { cwd }), '{"note":"note"}'); }); + +test("Does not execute a `repositoryUrl` injected as an `--upload-pack` git option", async (t) => { + const { cwd } = await gitRepo(); + await gitCommits(["First"], { cwd }); + const marker = path.join(temporaryDirectory(), "upload-pack-rce"); + // A `repositoryUrl` beginning with `-` would be parsed by git as a + // command-line option rather than a positional argument. `--upload-pack` + // lets the injected value run an arbitrary binary when fetching refs. + const repositoryUrl = `--upload-pack=touch ${marker}`; + + await t.throwsAsync(isBranchUpToDate(repositoryUrl, "master", { cwd })); + + t.false(existsSync(marker)); +}); + +test("Does not execute a `repositoryUrl` injected as a `--receive-pack` git option", async (t) => { + const { cwd } = await gitRepo(true); + await gitCommits(["First"], { cwd }); + const marker = path.join(temporaryDirectory(), "receive-pack-rce"); + // `--receive-pack` is the push-side equivalent: it lets the injected value + // run an arbitrary binary when pushing to the (configured) remote. + const repositoryUrl = `--receive-pack=touch ${marker}`; + + await t.throwsAsync(push(repositoryUrl, { cwd })); + + t.false(existsSync(marker)); +}); diff --git a/test/verify.test.js b/test/verify.test.js index 75cff5e7..1d08298b 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -109,6 +109,18 @@ test("Throw a SemanticReleaseError for each invalid branch", async (t) => { t.truthy(errors[5].details); }); +test('Throw a SemanticReleaseError if the "repositoryUrl" starts with "-"', async (t) => { + const { cwd } = await gitRepo(true); + const options = { repositoryUrl: "--upload-pack=/bin/sh", tagFormat: `v\${version}`, branches: [{ name: "master" }] }; + + const errors = [...(await t.throwsAsync(verify({ cwd, options }))).errors]; + + t.is(errors[0].name, "SemanticReleaseError"); + t.is(errors[0].code, "EINVALIDREPOURL"); + t.truthy(errors[0].message); + t.truthy(errors[0].details); +}); + test('Return "true" if all verification pass', async (t) => { const { cwd, repositoryUrl } = await gitRepo(true); const options = { repositoryUrl, tagFormat: `v\${version}`, branches: [{ name: "master" }] };