Merge commit from fork

* Reject path traversal in SCM URL filepath parsing

* Harden parseGitUrlSafe against encoding bypass variants
This commit is contained in:
Ben Lambert
2026-03-04 07:28:36 +01:00
committed by GitHub
parent 30ff9810f5
commit 1513a0b132
7 changed files with 192 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Fixed a security vulnerability where path traversal sequences in SCM URLs could be used to access unintended API endpoints using server-side integration credentials.
@@ -16,6 +16,7 @@
import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { parseGitUrlSafe } from '../helpers';
import { BitbucketCloudIntegrationConfig } from './config';
import { DateTime } from 'luxon';
@@ -165,7 +166,7 @@ export async function getBitbucketCloudDownloadUrl(
ref,
protocol,
resource,
} = parseGitUrl(url);
} = parseGitUrlSafe(url);
let branch = ref;
if (!branch) {
@@ -193,7 +194,7 @@ export function getBitbucketCloudFileFetchUrl(
config: BitbucketCloudIntegrationConfig,
): string {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url);
const { owner, name, ref, filepathtype, filepath } = parseGitUrlSafe(url);
if (!owner || !name || (filepathtype !== 'src' && filepathtype !== 'raw')) {
throw new Error('Invalid Bitbucket Cloud URL or file path');
}
@@ -16,6 +16,7 @@
import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { parseGitUrlSafe } from '../helpers';
import { BitbucketServerIntegrationConfig } from './config';
/**
@@ -74,7 +75,12 @@ export async function getBitbucketServerDownloadUrl(
url: string,
config: BitbucketServerIntegrationConfig,
): Promise<string> {
const { name: repoName, owner: project, ref, filepath } = parseGitUrl(url);
const {
name: repoName,
owner: project,
ref,
filepath,
} = parseGitUrlSafe(url);
let branch = ref;
if (!branch) {
@@ -108,7 +114,7 @@ export function getBitbucketServerFileFetchUrl(
config: BitbucketServerIntegrationConfig,
): string {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url);
const { owner, name, ref, filepathtype, filepath } = parseGitUrlSafe(url);
if (
!owner ||
!name ||
@@ -154,5 +154,49 @@ describe('github core', () => {
),
).toEqual('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml');
});
it('rejects URLs with encoded path traversal sequences', () => {
const config: GithubIntegrationConfig = {
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
};
expect(() =>
getGithubFileFetchUrl(
'https://github.com/octocat/Hello-World/blob/main/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fuser/repos',
config,
tokenCredentials,
),
).toThrow(/path traversal/);
});
it('rejects URLs with literal path traversal in filepath', () => {
const config: GithubIntegrationConfig = {
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
};
// Literal ../ is normalized by the URL constructor before git-url-parse
// sees it, so it fails with the existing validation instead
expect(() =>
getGithubFileFetchUrl(
'https://github.com/octocat/Hello-World/blob/main/../../user/repos',
config,
tokenCredentials,
),
).toThrow(/Incorrect URL/);
});
it('rejects raw endpoint URLs with path traversal', () => {
const config: GithubIntegrationConfig = {
host: 'github.com',
rawBaseUrl: 'https://raw.githubusercontent.com',
};
expect(() =>
getGithubFileFetchUrl(
'https://github.com/octocat/Hello-World/blob/main/%2e%2e%2f%2e%2e%2fuser/repos',
config,
tokenCredentials,
),
).toThrow(/path traversal/);
});
});
});
+2 -2
View File
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { GithubIntegrationConfig } from './config';
import { parseGitUrlSafe } from '../helpers';
import { GithubCredentials } from './types';
/**
@@ -39,7 +39,7 @@ export function getGithubFileFetchUrl(
credentials: GithubCredentials,
): string {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url);
const { owner, name, ref, filepathtype, filepath } = parseGitUrlSafe(url);
if (
!owner ||
!name ||
+97
View File
@@ -19,6 +19,7 @@ import {
basicIntegrations,
defaultScmResolveUrl,
isValidHost,
parseGitUrlSafe,
} from './helpers';
describe('basicIntegrations', () => {
@@ -317,3 +318,99 @@ describe('defaultScmResolveUrl', () => {
).toBe('https://b.com/b.yaml');
});
});
describe('parseGitUrlSafe', () => {
it('parses a valid GitHub blob URL', () => {
const result = parseGitUrlSafe(
'https://github.com/owner/repo/blob/main/path/to/file.yaml',
);
expect(result.owner).toBe('owner');
expect(result.name).toBe('repo');
expect(result.ref).toBe('main');
expect(result.filepath).toBe('path/to/file.yaml');
});
it('rejects URLs with encoded path traversal in filepath', () => {
expect(() =>
parseGitUrlSafe(
'https://github.com/octocat/Hello-World/blob/main/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fuser/repos',
),
).toThrow('path traversal');
});
it('rejects URLs with double-encoded path traversal in filepath', () => {
expect(() =>
parseGitUrlSafe(
'https://github.com/octocat/Hello-World/blob/main/foo%2f%2e%2e%2fbar%2f%2e%2e%2f%2e%2e%2fsecret',
),
).toThrow('path traversal');
});
it('rejects URLs with uppercase %2E encoding in filepath', () => {
expect(() =>
parseGitUrlSafe(
'https://github.com/octocat/Hello-World/blob/main/%2E%2E%2F%2E%2E%2Fuser/repos',
),
).toThrow('path traversal');
});
it('rejects URLs with mixed-case percent encoding', () => {
expect(() =>
parseGitUrlSafe(
'https://github.com/octocat/Hello-World/blob/main/%2E%2e%2Fuser/repos',
),
).toThrow('path traversal');
});
it('rejects URLs where git-url-parse leaves percent-encoded traversal segments', () => {
expect(() =>
parseGitUrlSafe(
'https://github.com/octocat/Hello-World/blob/main/%252e%252e%252fuser/repos',
),
).toThrow('path traversal');
});
it('rejects URLs with triple-encoded path traversal', () => {
expect(() =>
parseGitUrlSafe(
'https://github.com/octocat/Hello-World/blob/main/%25252e%25252e%25252fuser/repos',
),
).toThrow('path traversal');
});
it('rejects URLs with literal .. in the middle of the filepath', () => {
// URL normalization resolves foo/../bar to just bar, so the filepath
// won't contain traversal. But we verify parseGitUrlSafe still handles
// the resolved path safely.
const result = parseGitUrlSafe(
'https://github.com/owner/repo/blob/main/foo/../bar',
);
expect(result.filepath).toBe('bar');
});
it('handles literal ../ that gets normalized away by URL parsing', () => {
// Literal ../../../ gets resolved by the URL constructor before
// git-url-parse sees it. This mangles owner/name but the filepath
// is empty, so parseGitUrlSafe doesn't reject it. Downstream
// functions reject these via their own validation.
const result = parseGitUrlSafe(
'https://github.com/owner/repo/blob/main/../../../user/repos',
);
expect(result.filepath).toBe('');
expect(result.owner).not.toBe('owner');
});
it('allows filenames that contain dots but are not traversal', () => {
const result = parseGitUrlSafe(
'https://github.com/owner/repo/blob/main/path/to/some..file.yaml',
);
expect(result.filepath).toBe('path/to/some..file.yaml');
});
it('allows URLs without a filepath', () => {
const result = parseGitUrlSafe('https://github.com/owner/repo');
expect(result.owner).toBe('owner');
expect(result.name).toBe('repo');
expect(result.filepath).toBe('');
});
});
+33 -1
View File
@@ -18,6 +18,38 @@ import parseGitUrl from 'git-url-parse';
import { trimEnd } from 'lodash';
import { ScmIntegration, ScmIntegrationsGroup } from './types';
/**
* Wraps git-url-parse and rejects URLs whose filepath contains path traversal
* segments. Without this check, a URL like
* `https://github.com/o/r/blob/main/%2e%2e%2f%2e%2e%2fuser/repos` would be
* decoded to `../../user/repos` and could escape the expected API path when
* interpolated into provider API URLs.
*/
export function parseGitUrlSafe(url: string) {
const parsed = parseGitUrl(url);
if (parsed.filepath) {
let decoded = parsed.filepath;
let previous;
do {
previous = decoded;
try {
decoded = decodeURIComponent(decoded);
} catch {
break;
}
} while (decoded !== previous);
if (
decoded.split('/').some(segment => segment === '..' || segment === '.')
) {
throw new Error(
'Invalid SCM URL: path traversal is not allowed in the URL',
);
}
}
return parsed;
}
/** Checks whether the given argument is a valid URL hostname */
export function isValidHost(host: string): boolean {
const check = new URL('http://example.com');
@@ -84,7 +116,7 @@ export function defaultScmResolveUrl(options: {
if (url.startsWith('/')) {
// If it is an absolute path, move relative to the repo root
const { href, filepath } = parseGitUrl(base);
const { href, filepath } = parseGitUrlSafe(base);
updated = new URL(href);