Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/dockerfiles/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ RUN /dashboard/airgap.sh -i /dashboard/packages/devfile-registry/air-gap/index.j

FROM docker.io/node:18.19.1-alpine3.19

RUN apk --no-cache add curl
RUN apk --no-cache add curl git
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t forget to include Git in the downstream image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


ENV FRONTEND_LIB=/dashboard/packages/dashboard-frontend/lib/public
ENV BACKEND_LIB=/dashboard/packages/dashboard-backend/lib
Expand Down
4 changes: 4 additions & 0 deletions packages/common/src/dto/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export interface IGitConfig {
};
}

export interface IGitBranches {
branches: string[];
}

export interface IWorkspacesDefaultPlugins {
editor: string;
plugins: string[];
Expand Down
3 changes: 3 additions & 0 deletions packages/dashboard-backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { registerDockerConfigRoutes } from '@/routes/api/dockerConfig';
import { registerEditorsRoutes } from '@/routes/api/editors';
import { registerEventsRoutes } from '@/routes/api/events';
import { registerGettingStartedSamplesRoutes } from '@/routes/api/gettingStartedSample';
import { registerGitBranchesRoute } from '@/routes/api/gitBranches';
import { registerGitConfigRoutes } from '@/routes/api/gitConfig';
import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient';
import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken';
Expand Down Expand Up @@ -119,6 +120,8 @@ export default async function buildApp(server: FastifyInstance): Promise<unknown

registerUserProfileRoute(server),

registerGitBranchesRoute(server),

registerDataResolverRoute(server),

registerDevworkspaceResourcesRoute(server),
Expand Down
10 changes: 10 additions & 0 deletions packages/dashboard-backend/src/constants/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ export const namespacedSchema: JSONSchema7 = {
required: ['namespace'],
};

export const gitBranchSchema: JSONSchema7 = {
type: 'object',
properties: {
url: {
type: 'string',
},
},
required: ['url'],
};

export const namespacedKubeConfigSchema: JSONSchema7 = {
type: 'object',
properties: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2018-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/

export async function run<T>(exec: () => Promise<T>): Promise<T> {
return exec();
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* Red Hat, Inc. - initial API and implementation
*/

import { ChildProcessWithoutNullStreams } from 'node:child_process';

import { helpers } from '@eclipse-che/common';
import { spawn } from 'child_process';
import { stringify } from 'querystring';
Expand Down Expand Up @@ -123,9 +125,10 @@ export async function exec(
return { stdOut, stdError };
}

export function run(cmd: string, args?: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const command = spawn(cmd, args);
export function run(cmd: string, args?: string[], timeOut?: number): Promise<string> {
let command: ChildProcessWithoutNullStreams;
const promise: Promise<string> = new Promise((resolve, reject) => {
command = spawn(cmd, args);
let result = '';
command.stdout.on('data', data => {
result += data.toString();
Expand All @@ -137,4 +140,17 @@ export function run(cmd: string, args?: string[]): Promise<string> {
reject(err);
});
});
if (timeOut && timeOut > 0) {
const timeoutPromise: Promise<string> = new Promise((_, reject) => {
setTimeout(() => {
if (command && command.exitCode == null) {
command.kill();
reject(new Error('Timeout exceeded'));
}
}, timeOut);
});
return Promise.race([promise, timeoutPromise]);
} else {
return promise;
}
}
4 changes: 4 additions & 0 deletions packages/dashboard-backend/src/models/restParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export interface IEditorsDevfileParams {
'che-editor': string;
}

export interface IUrlParams {
url: string;
}

// --- Namespaced Params ---
export interface INamespacedParams {
namespace: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2018-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/

import { FastifyInstance } from 'fastify';

import { baseApiPath } from '@/constants/config';
import { setup, teardown } from '@/utils/appBuilder';

jest.mock('../helpers/getDevWorkspaceClient.ts');
jest.mock('../helpers/getServiceAccountToken.ts');
const response =
'c9440b0ca811e5d8e7abeee8467e1219d5ca4cb6\trefs/heads/master ' +
'0fa45f3539ca69615d0ccd8e0277fb7f12ee7715\trefs/heads/new/branch ' +
'42c6289f142a5589f206425d812d0b125ab87990\trefs/heads/newBranch ' +
'0e647bc78ac310d96251d581e5498b1503729e87\trefs/tags/test ' +
'fb3a99a405876f16e2dcb231a061d5a3f735b2aa\trefs/pull/809/head';
jest.mock('@/devworkspaceClient/services/helpers/exec', () => {
return {
run: async () => response,
};
});

describe('GitBranches Route', () => {
let app: FastifyInstance;
const url = 'url';

beforeEach(async () => {
app = await setup();
});

afterEach(() => {
teardown(app);
});

test('GET ${baseApiPath}/gitbranches:url', async () => {
const res = await app.inject().get(`${baseApiPath}/gitbranches/${url}`);

expect(res.statusCode).toEqual(200);
expect(res.json()).toEqual({ branches: ['master', 'new/branch', 'newBranch', 'test'] });
});
});
34 changes: 34 additions & 0 deletions packages/dashboard-backend/src/routes/api/gitBranches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2018-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/

import { FastifyInstance, FastifyRequest } from 'fastify';

import { baseApiPath } from '@/constants/config';
import { gitBranchSchema } from '@/constants/schemas';
import { restParams } from '@/models';
import { getBranches } from '@/services/gitClient';
import { getSchema } from '@/services/helpers';

const tags = ['GitBranches'];

export function registerGitBranchesRoute(instance: FastifyInstance) {
instance.register(async server => {
server.get(
`${baseApiPath}/gitbranches/:url`,
getSchema({ tags, params: gitBranchSchema }),
async function (request: FastifyRequest) {
const { url } = request.params as restParams.IUrlParams;
return getBranches(url);
},
);
});
}
34 changes: 34 additions & 0 deletions packages/dashboard-backend/src/services/gitClient/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2018-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/

import { api } from '@eclipse-che/common';

import { run } from '@/devworkspaceClient/services/helpers/exec';

export async function getBranches(url: string): Promise<api.IGitBranches | undefined> {
try {
return new Promise((resolve, reject) => {
run(`git`, ['ls-remote', '--refs', url], 1000)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vinokurig please sanitize the url to prevent injections.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to do anything with URLs here; moreover, modifying the URL here can add some edge cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the best approach to protect backend just to validate the target URL with provider patterns:

/packages/dashboard-frontend/src/store/Workspaces/Preferences/helpers.ts

export const gitProviderPatterns = {
  github: {
    https: /^https:\/\/github\.com\/([^\\/]+\/[^\\/]+)(?:\/.*)?$/,
    ssh: /^(?:(?:git\+)?ssh:\/\/)?git@github\.com:([^\\/]+\/[^\\/]+?)(?:\.git)?$/,
  },
  gitlab: {
    https: /^https:\/\/gitlab\.com\/([^\\/]+\/[^\\/]+)(?:\/.*)?$/,
    ssh: /^(?:(?:git\+)?ssh:\/\/)?git@gitlab\.com:([^\\/]+\/[^\\/]+?)(?:\.git)?$/,
  },
  bitbucket: {
    https: /^https:\/\/bitbucket\.org\/([^\\/]+\/[^\\/]+)(?:\/.*)?$/,
    ssh: /^(?:(?:git\+)?ssh:\/\/)?git@bitbucket\.org:([^\\/]+\/[^\\/]+?)(?:\.git)?$/,
  },
  azureDevOps: {
    https: /^https:\/\/(?:\w+@)?dev\.azure\.com\/([^\\/]+\/[^\\/]+\/_git\/[^\\/]+?)(?:\?.*)?$/,
    ssh: /^(?:ssh:\/\/)?git@ssh\.dev\.azure\.com:v3\/([^\\/]+\/[^\\/]+\/[^\\/]+)(?:\/[^\\/]+)?$/,
  },
};

If it is valid - execute code. If not - return the proper error.

@akurinnoy WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could also add a time-limited cashing option here. Since multiple users may be requesting the same repository(It is optional)

Copy link
Contributor Author

@vinokurig vinokurig Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this we will limit the functionality to just 4 supported git providers, server version of supported git providers will not be included. On the other hand, this will protect the dashboard container from executing git ls-remote with suspicious urls.
My proposal is to add a simple http https ssh validation and probably a character quantity limit.
@akurinnoy @svor We need to make a decision here: limited functionality or security protection.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have to support GitLab and Bitbucket servers as well. This providers may have custom host URLs, I'm not sure if the pattern provided above will work

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@olexii4 @vinokurig @svor I would also prefer more general URL validation to exclude possible injections.

@olexii4 Regarding the server-side caching, I would rather disagree, because this will introduce security issues. But, the client-side cashing sounds reasonable to me.

Check failure

Code scanning / CodeQL

Second order command injection High

Command line argument that depends on
a user-provided value
can execute an arbitrary command if --upload-pack is used with git.

Copilot Autofix

AI 4 days ago

The safest and most effective fix is to validate the url argument in the getBranches function before invoking the run command. This ensures that it cannot begin with a dash (i.e., -) or contain a string that would cause it to be interpreted as a git option rather than a repository address. The validation should accept only valid Git remote URLs (e.g., those beginning with git@, http://, https://, ssh://, or that conform to accepted address formats), and reject any value that could be used for command injection.
The change should be made directly inside the getBranches function, as that is the main sink for the tainted value.
The fix requires either a simple check (rejecting any value that starts with a dash), or a more rigorous check (accepting only URLs that match expected patterns).


Suggested changeset 1
packages/dashboard-backend/src/services/gitClient/index.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/dashboard-backend/src/services/gitClient/index.ts b/packages/dashboard-backend/src/services/gitClient/index.ts
--- a/packages/dashboard-backend/src/services/gitClient/index.ts
+++ b/packages/dashboard-backend/src/services/gitClient/index.ts
@@ -15,6 +15,15 @@
 import { run } from '@/devworkspaceClient/services/helpers/exec';
 
 export async function getBranches(url: string): Promise<api.IGitBranches | undefined> {
+  // Disallow remote URLs that start with "-" (prevent git option injection)
+  // Optionally, only allow typical git URLs: git@, http(s)://, ssh://
+  if (
+    typeof url !== 'string' ||
+    url.startsWith('-') ||
+    !(url.startsWith('git@') || url.startsWith('https://') || url.startsWith('http://') || url.startsWith('ssh://'))
+  ) {
+    throw new Error('Invalid remote URL: ' + url);
+  }
   try {
     return new Promise((resolve, reject) => {
       run(`git`, ['ls-remote', '--refs', url], 1000)
EOF
@@ -15,6 +15,15 @@
import { run } from '@/devworkspaceClient/services/helpers/exec';

export async function getBranches(url: string): Promise<api.IGitBranches | undefined> {
// Disallow remote URLs that start with "-" (prevent git option injection)
// Optionally, only allow typical git URLs: git@, http(s)://, ssh://
if (
typeof url !== 'string' ||
url.startsWith('-') ||
!(url.startsWith('git@') || url.startsWith('https://') || url.startsWith('http://') || url.startsWith('ssh://'))
) {
throw new Error('Invalid remote URL: ' + url);
}
try {
return new Promise((resolve, reject) => {
run(`git`, ['ls-remote', '--refs', url], 1000)
Copilot is powered by AI and may make mistakes. Always verify output.
.then(result => {
resolve({
branches: result
.split(' ')
.filter(b => b.indexOf('refs/heads/') > 0 || b.indexOf('refs/tags/') > 0)
.map(b => b.replace(new RegExp('.*\\trefs/((heads)|(tags))/'), '')),
});
})
.catch(err => reject(err));
});
} catch (error) {
return undefined;
}
}
Comment on lines +20 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, use async/await for better readability and maintainability.

Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@

import React from 'react';

import { Props } from '@/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/GitBranchField';
import { Props } from '@/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/GitBranchDropdown';

export class GitBranchField extends React.PureComponent<Props> {
export class GitBranchDropdown extends React.PureComponent<Props> {
public render() {
const { gitBranch, onChange } = this.props;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`GitBranchDropdown snapshot 1`] = `
<div
className="pf-c-form__group"
>
<div
className="pf-c-form__group-label"
>
<label
className="pf-c-form__label"
>
<span
className="pf-c-form__label-text"
>
Git Branch
</span>
</label>

</div>
<div
className="pf-c-form__group-control"
>
<div
className="pf-c-dropdown selector"
data-ouia-component-id="OUIA-Generated-Dropdown-1"
data-ouia-component-type="PF4/Dropdown"
data-ouia-safe={true}
onChange={[Function]}
>
<button
aria-expanded={false}
aria-haspopup={false}
aria-label="Git Branch"
className="pf-c-dropdown__toggle"
data-ouia-component-id="OUIA-Generated-DropdownToggle-1"
data-ouia-component-type="PF4/DropdownToggle"
data-ouia-safe={true}
disabled={false}
id="toggle-initial-selection"
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
<span
className="pf-c-dropdown__toggle-icon"
>
<svg
aria-hidden={true}
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
{
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 320 512"
width="1em"
>
<path
d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"
/>
</svg>
</span>
</button>
</div>
<div
aria-live="polite"
className="pf-c-form__helper-text pf-m-error"
id="undefined-helper"
>
No branch found. Please check the Git repository URL.
</div>
</div>
</div>
`;
Loading