fix: argument Injection via repositoryUrl in package.json (#4245)

Co-Authored-By: Abhijith S abh78i00@gmail.com
This commit is contained in:
Gregor Martynus
2026-07-12 22:23:08 -07:00
committed by GitHub
parent 7abb56289a
commit c46dbdaeda
6 changed files with 76 additions and 11 deletions
Vendored
+3
View File
@@ -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;
+15
View File
@@ -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.",
+13 -11
View File
@@ -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\/(?<branch>.+)$/)[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(/^(?<ref>\w+)?/)[1]
(await execa("git", ["ls-remote", "--heads", "--", repositoryUrl, branch], execaOptions)).stdout.match(
/^(?<ref>\w+)?/
)[1]
);
}
+4
View File
@@ -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
+29
View File
@@ -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));
});
+12
View File
@@ -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" }] };