diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7f6a172d1..14210f3fd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,16 +17,9 @@ "vscode": { "extensions": [ "anthropic.claude-code", - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", "eamodio.gitlens" ], "settings": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, "terminal.integrated.defaultProfile.linux": "zsh", "terminal.integrated.profiles.linux": { "bash": { diff --git a/src/core/git/gitRemoteParse.ts b/src/core/git/gitRemoteParse.ts index 920ff1204..323294949 100644 --- a/src/core/git/gitRemoteParse.ts +++ b/src/core/git/gitRemoteParse.ts @@ -19,6 +19,38 @@ export const isValidShorthand = (remoteValue: string): boolean => { return validShorthandRegex.test(remoteValue); }; +/** + * Check if a URL is an Azure DevOps repository URL by validating the hostname. + * This uses proper URL parsing to avoid security issues with substring matching. + */ +const isAzureDevOpsUrl = (remoteValue: string): boolean => { + // Handle SSH URLs (e.g., git@ssh.dev.azure.com:v3/org/project/repo) + if (remoteValue.startsWith('git@ssh.dev.azure.com:')) { + return true; + } + + // Handle HTTP(S) URLs + try { + const url = new URL(remoteValue); + const hostname = url.hostname.toLowerCase(); + + // Check for exact Azure DevOps hostnames + if (hostname === 'dev.azure.com' || hostname === 'ssh.dev.azure.com') { + return true; + } + + // Check for legacy Visual Studio Team Services (*.visualstudio.com) + if (hostname.endsWith('.visualstudio.com')) { + return true; + } + + return false; + } catch { + // Not a valid URL, let git-url-parse handle it + return false; + } +}; + export const parseRemoteValue = ( remoteValue: string, refs: string[] = [], @@ -31,6 +63,17 @@ export const parseRemoteValue = ( }; } + // Check for Azure DevOps URLs before parsing, as git-url-parse may not handle them correctly + // - SSH: git@ssh.dev.azure.com:v3/org/project/repo + // - HTTPS: https://dev.azure.com/organization/project/_git/repo + // - Legacy: https://org.visualstudio.com/project/_git/repo + if (isAzureDevOpsUrl(remoteValue)) { + return { + repoUrl: remoteValue, + remoteBranch: undefined, + }; + } + try { const parsedFields = gitUrlParse(remoteValue, refs) as IGitUrl; diff --git a/tests/core/git/gitRemoteParse.test.ts b/tests/core/git/gitRemoteParse.test.ts index 437a16627..d8959d54f 100644 --- a/tests/core/git/gitRemoteParse.test.ts +++ b/tests/core/git/gitRemoteParse.test.ts @@ -49,6 +49,48 @@ describe('remoteAction functions', () => { }); }); + test('should handle Azure DevOps SSH URLs', () => { + const azureDevOpsUrl = 'git@ssh.dev.azure.com:v3/organization/project/repo'; + const parsed = parseRemoteValue(azureDevOpsUrl); + expect(parsed).toEqual({ + repoUrl: azureDevOpsUrl, + remoteBranch: undefined, + }); + }); + + test('should handle Azure DevOps HTTPS URLs', () => { + const azureDevOpsUrl = 'https://dev.azure.com/organization/project/_git/repo'; + const parsed = parseRemoteValue(azureDevOpsUrl); + expect(parsed).toEqual({ + repoUrl: azureDevOpsUrl, + remoteBranch: undefined, + }); + }); + + test('should handle legacy Visual Studio Team Services URLs', () => { + const vstsUrl = 'https://myorg.visualstudio.com/myproject/_git/myrepo'; + const parsed = parseRemoteValue(vstsUrl); + expect(parsed).toEqual({ + repoUrl: vstsUrl, + remoteBranch: undefined, + }); + }); + + test('should not treat URLs with Azure DevOps hostnames in path as Azure DevOps URLs', () => { + // Security test: Ensure URLs with Azure DevOps keywords in the path are not treated as Azure DevOps + const maliciousUrl = 'https://evil.com/dev.azure.com/fake/repo'; + const parsed = parseRemoteValue(maliciousUrl); + // Should be parsed normally (not as Azure DevOps), with .git suffix added + expect(parsed.repoUrl).toBe('https://evil.com/dev.azure.com/fake/repo.git'); + }); + + test('should not treat URLs with visualstudio.com in path as Azure DevOps URLs', () => { + const maliciousUrl = 'https://evil.com/path/visualstudio.com/fake/repo'; + const parsed = parseRemoteValue(maliciousUrl); + // Should be parsed normally (not as Azure DevOps), with .git suffix added + expect(parsed.repoUrl).toBe('https://evil.com/path/visualstudio.com/fake/repo.git'); + }); + test('should get correct branch name from url', () => { expect(parseRemoteValue('https://github.com/username/repo/tree/branchname')).toEqual({ repoUrl: 'https://github.com/username/repo.git',