Skip to content
Merged
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
33 changes: 28 additions & 5 deletions src/proxy/routes/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,12 @@ export const processGitUrl = (url: string): GitUrlBreakdown | null => {
};

/** Regex used to analyze url paths for requests to the proxy and split them
* into the embedded git end point and path for the git operation. */
const PROXIED_URL_PATH_REGEX = /(.+\.git)(\/.*)?/;
* into the embedded git end point and path for the git operation.
* Anchored to the full path to avoid silent partial matches on ".git" substrings. */
const PROXIED_URL_PATH_REGEX = /^(.+\.git)(\/.*)?$/;

/** Match smart-HTTP operation suffixes (/info/refs, pack endpoints), with or without a .git repo suffix. */
const SMART_HTTP_PATH_REGEX = /^(.+?)(\/(?:info\/refs|git-upload-pack|git-receive-pack)(?:\?.*)?)$/;

/** Type representing a breakdown of paths requested from the proxy server */
export type UrlPathBreakdown = { repoPath: string; gitPath: string };
Expand All @@ -85,6 +89,14 @@ export type UrlPathBreakdown = { repoPath: string; gitPath: string };
* - repoPath: /github.com/finos/git-proxy.git
* - gitPath: /info/refs?service=git-upload-pack
*
* Processing /github.com/finos/git-proxy/info/refs?service=git-upload-pack (no .git suffix)
* would produce:
* - repoPath: /github.com/finos/git-proxy.git
* - gitPath: /info/refs?service=git-upload-pack
*
* Git-operation suffixes are matched first so repo names that contain ".git" as a substring
* (e.g. .github, user.github.io) parse correctly when the client omits an explicit .git suffix.
*
* @param {string} requestPath The URL path to process.
* @return {GitUrlBreakdown | null} A breakdown of the components of the URL path.
*/
Expand All @@ -94,16 +106,27 @@ export const processUrlPath = (requestPath: string): UrlPathBreakdown | null =>
console.error(`The requestPath is too long: ${requestPath}`);
return null;
}

// Prefer protocol operation suffix over unanchored ".git" matching.
const smartHttp = requestPath.match(SMART_HTTP_PATH_REGEX);
Comment thread
jescalada marked this conversation as resolved.
Resolved
if (smartHttp) {
const repoPrefix = smartHttp[1];
return {
repoPath: repoPrefix.endsWith('.git') ? repoPrefix : `${repoPrefix}.git`,
gitPath: smartHttp[2],
};
}

const components = requestPath.match(PROXIED_URL_PATH_REGEX);
if (components && components.length >= 3) {
return {
repoPath: components[1],
gitPath: components[2] ?? '/',
};
} else {
console.error(`Failed to parse proxy url path: ${requestPath}`);
return null;
}

console.error(`Failed to parse proxy url path: ${requestPath}`);
return null;
};

/** Regex used to analyze repo URLs (with protocol and origin) to extract the repository name and
Expand Down
2 changes: 1 addition & 1 deletion src/proxy/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ const proxyErrorHandler: ProxyOptions['proxyErrorHandler'] = (err, _res, next) =

const isPackPost = (req: Request) =>
req.method === 'POST' &&
/^(?:\/[^/]+)*\/[^/]+\.git\/(?:git-upload-pack|git-receive-pack)$/.test(req.url);
/^(?:\/[^/]+)*\/[^/]+(?:\.git)?\/(?:git-upload-pack|git-receive-pack)$/.test(req.url);

const extractRawBody = async (req: Request, res: Response, next: NextFunction) => {
if (!isPackPost(req)) {
Expand Down
12 changes: 12 additions & 0 deletions test/extractRawBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,19 @@ describe('isPackPost()', () => {
expect(isPackPost({ method: 'POST', url: '/a.git/git-upload-pack' } as Request)).toBe(true);
});

it('returns true for git-upload-pack POST without a .git repo suffix', () => {
Comment thread
jescalada marked this conversation as resolved.
Resolved
expect(isPackPost({ method: 'POST', url: '/a/b/git-upload-pack' } as Request)).toBe(true);
expect(
isPackPost({ method: 'POST', url: '/github.com/org/repo/git-receive-pack' } as Request),
).toBe(true);
});

it('returns false for other URLs', () => {
expect(isPackPost({ method: 'POST', url: '/info/refs' } as Request)).toBe(false);
});

// Single path segment is not a repo + pack operation (boundary for optional .git).
it('returns false for git-upload-pack POST with no repository path segment', () => {
expect(isPackPost({ method: 'POST', url: '/git-upload-pack' } as Request)).toBe(false);
});
});
15 changes: 15 additions & 0 deletions test/testProxyRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,21 @@ describe('proxyFilter', () => {
expect(mockReq.body).toEqual(Buffer.from('test data'));
expect((mockReq as any).bodyRaw).toBeUndefined();
});

it('should allow GET info/refs for a .git-less smart-HTTP path without mocking processUrlPath', async () => {
mockReq.url = '/github.com/finos/git-proxy/info/refs?service=git-upload-pack';
mockReq.method = 'GET';

vi.spyOn(chain, 'executeChain').mockResolvedValue({
error: false,
blocked: false,
} as Action);

const result = await proxyFilter?.(mockReq as Request, mockRes as Response);

expect(result).toBe(true);
expect(sendMock).not.toHaveBeenCalled();
});
});

describe('Invalid requests', () => {
Expand Down
58 changes: 58 additions & 0 deletions test/testRouteFilter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,64 @@ describe('url helpers and filter functions used in the proxy', () => {
expect(processUrlPath(VERY_LONG_PATH)).toBeNull();
});

it('processUrlPath should parse smart-HTTP paths without a .git repo suffix', () => {
Comment thread
jescalada marked this conversation as resolved.
Resolved
expect(processUrlPath('/octocat/hello-world/info/refs?service=git-upload-pack')).toEqual({
repoPath: '/octocat/hello-world.git',
gitPath: '/info/refs?service=git-upload-pack',
});

expect(
processUrlPath('/github.com/octocat/hello-world/info/refs?service=git-upload-pack'),
).toEqual({
repoPath: '/github.com/octocat/hello-world.git',
gitPath: '/info/refs?service=git-upload-pack',
});

expect(processUrlPath('/org/owner/repo/git-upload-pack')).toEqual({
repoPath: '/org/owner/repo.git',
gitPath: '/git-upload-pack',
});
});

// Pins match order: git-operation suffix before unanchored ".git" substring match.
// Names like .github / *.github.io contain ".git" inside the repo segment.
it('processUrlPath should prefer smart-HTTP op suffix over .git substring in repo names', () => {
expect(processUrlPath('/github.com/finos/.github/info/refs?service=git-upload-pack')).toEqual({
repoPath: '/github.com/finos/.github.git',
gitPath: '/info/refs?service=git-upload-pack',
});

expect(processUrlPath('/github.com/user/user.github.io/git-receive-pack')).toEqual({
repoPath: '/github.com/user/user.github.io.git',
gitPath: '/git-receive-pack',
});

// Query values may contain ".git"; must not swallow the whole path into repoPath.
expect(
processUrlPath('/octocat/hello-world/info/refs?service=git-upload-pack&foo=.git'),
).toEqual({
repoPath: '/octocat/hello-world.git',
gitPath: '/info/refs?service=git-upload-pack&foo=.git',
});

// Explicit .git on .github remotes must keep working (not a regression).
expect(
processUrlPath('/github.com/finos/.github.git/info/refs?service=git-upload-pack'),
).toEqual({
repoPath: '/github.com/finos/.github.git',
gitPath: '/info/refs?service=git-upload-pack',
});

// Already ends with .git — must not become …/b.git.git when op-suffix matching runs first.
expect(processUrlPath('/a/b.git/git-upload-pack')).toEqual({
repoPath: '/a/b.git',
gitPath: '/git-upload-pack',
});

// No git operation to split on — must not invent a repo path.
expect(processUrlPath('/github.com/finos/git-proxy')).toBeNull();
});

it('processGitUrl should return breakdown of a git URL separating out the protocol, host and repository path', () => {
expect(processGitUrl('https://somegithost.com/octocat/hello-world.git')).toEqual({
protocol: 'https://',
Expand Down
Loading