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
5 changes: 5 additions & 0 deletions .changeset/validate-clone-destinations.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,28 @@ export async function setupRepo({ token, cloneUrl, headSha, baseSha, workspacePa
baseSha: string;
workspacePath: string;
}): Promise<void> {
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]);
Expand Down
Loading