diff --git a/.changeset/validate-clone-destinations.md b/.changeset/validate-clone-destinations.md new file mode 100644 index 0000000..989e04a --- /dev/null +++ b/.changeset/validate-clone-destinations.md @@ -0,0 +1,5 @@ +--- +"layne": patch +--- + +Validate HTTPS github.com clone destinations before attaching installation credentials or invoking Git. Reject existing credentials, non-default ports, query strings, and fragments. diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 561fdf8..053242e 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: '22' - - run: rm -f package-lock.json && npm install + - run: npm ci - run: npm run build - run: npm run lint - run: npm run validate-config diff --git a/src/__tests__/fetcher.test.ts b/src/__tests__/fetcher.test.ts index 82f690c..456946f 100644 --- a/src/__tests__/fetcher.test.ts +++ b/src/__tests__/fetcher.test.ts @@ -69,6 +69,39 @@ describe('setupRepo()', () => { expect(args).toContain('https://x-access-token:tok999@github.com/org/repo.git'); }); + it.each([ + 'https://attacker.example/org/repo.git', + 'https://github.com.attacker.example/org/repo.git', + 'https://github.com@attacker.example/org/repo.git', + 'http://github.com/org/repo.git', + 'https://github.com:8443/org/repo.git', + 'https://user@github.com/org/repo.git', + 'https://:password@github.com/org/repo.git', + 'https://github.com/org/repo.git?redirect=attacker.example', + 'https://github.com/org/repo.git#attacker.example', + 'file:///tmp/repo.git', + 'ssh://git@github.com/org/repo.git', + ])('rejects unsafe clone destinations before spawning Git: %s', async cloneUrl => { + await expect(setupRepo(defaultSetupArgs({ cloneUrl }))).rejects.toThrow( + 'Refusing to authenticate non-GitHub clone URL', + ); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('rejects invalid URLs without reflecting untrusted input', async () => { + await expect(setupRepo(defaultSetupArgs({ cloneUrl: 'not a URL' }))).rejects.toThrow( + 'Refusing to authenticate invalid GitHub clone URL', + ); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('encodes token delimiters without changing the destination', async () => { + await setupRepo(defaultSetupArgs({ token: 'synthetic@token:with/slash' })); + const url = new URL(mockExecFile.mock.calls[1][1].at(-1)!); + expect(url.hostname).toBe('github.com'); + expect(decodeURIComponent(url.password)).toBe('synthetic@token:with/slash'); + }); + it('fetches the head SHA with --filter=blob:none', async () => { await setupRepo(defaultSetupArgs({ headSha: 'deadbeef' })); const [cmd, args] = mockExecFile.mock.calls[2]; // third call: fetch head diff --git a/src/fetcher.ts b/src/fetcher.ts index 03aafbf..a1e94bd 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -34,12 +34,28 @@ export async function setupRepo({ token, cloneUrl, headSha, baseSha, workspacePa baseSha: string; workspacePath: string; }): Promise { - const authenticatedUrl = cloneUrl.replace( - 'https://', - `https://x-access-token:${token}@` - ); + let parsedCloneUrl: URL; + try { + parsedCloneUrl = new URL(cloneUrl); + } catch { + throw new Error('Refusing to authenticate invalid GitHub clone URL'); + } + if ( + parsedCloneUrl.protocol !== 'https:' + || parsedCloneUrl.hostname !== 'github.com' + || parsedCloneUrl.port !== '' + || parsedCloneUrl.username !== '' + || parsedCloneUrl.password !== '' + || parsedCloneUrl.search !== '' + || parsedCloneUrl.hash !== '' + ) { + throw new Error('Refusing to authenticate non-GitHub clone URL'); + } - debug('fetcher', `setting up partial clone of ${cloneUrl} at ${headSha}`); + debug('fetcher', `setting up partial clone of ${parsedCloneUrl.href} at ${headSha}`); + parsedCloneUrl.username = 'x-access-token'; + parsedCloneUrl.password = token; + const authenticatedUrl = parsedCloneUrl.href; await git(['init', workspacePath]); await git(['-C', workspacePath, 'remote', 'add', 'origin', authenticatedUrl]);