Skip to content

Commit 8a41533

Browse files
frostebiteclaude
andcommitted
fix(orchestrator): use http.extraHeader for secure git authentication
Replace token-in-URL pattern with http.extraHeader for git clone and LFS operations. The token no longer appears in clone URLs, git remote config, or process command lines. Add gitAuthMode input (default: 'header', legacy: 'url') so users can fall back to the old behavior if needed. Closes #785 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9d47543 commit 8a41533

File tree

8 files changed

+235
-12
lines changed

8 files changed

+235
-12
lines changed

action.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ inputs:
105105
required: false
106106
default: ''
107107
description: '[Orchestrator] Github private token to pull from github'
108+
gitAuthMode:
109+
required: false
110+
default: 'header'
111+
description:
112+
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
113+
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
108114
githubOwner:
109115
required: false
110116
default: ''

src/model/build-parameters.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class BuildParameters {
5454
public sshAgent!: string;
5555
public sshPublicKeysDirectoryPath!: string;
5656
public providerStrategy!: string;
57+
public gitAuthMode!: string;
5758
public gitPrivateToken!: string;
5859
public awsStackName!: string;
5960
public awsEndpoint?: string;
@@ -194,6 +195,7 @@ class BuildParameters {
194195
containerRegistryRepository: Input.containerRegistryRepository,
195196
containerRegistryImageVersion: Input.containerRegistryImageVersion,
196197
providerStrategy: OrchestratorOptions.providerStrategy,
198+
gitAuthMode: OrchestratorOptions.gitAuthMode,
197199
buildPlatform: OrchestratorOptions.buildPlatform,
198200
kubeConfig: OrchestratorOptions.kubeConfig,
199201
containerMemory: OrchestratorOptions.containerMemory,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { OrchestratorFolders } from './orchestrator-folders';
2+
3+
jest.mock('../orchestrator', () => ({
4+
__esModule: true,
5+
default: {
6+
buildParameters: {
7+
orchestratorRepoName: 'game-ci/unity-builder',
8+
githubRepo: 'myorg/myrepo',
9+
gitPrivateToken: 'ghp_test123',
10+
gitAuthMode: 'header',
11+
buildGuid: 'test-guid',
12+
projectPath: '',
13+
buildPath: 'Builds',
14+
cacheKey: 'test-cache',
15+
},
16+
lockedWorkspace: '',
17+
},
18+
}));
19+
20+
jest.mock('./orchestrator-options', () => ({
21+
__esModule: true,
22+
default: {
23+
useSharedBuilder: false,
24+
},
25+
}));
26+
27+
jest.mock('../services/core/orchestrator-system', () => ({
28+
OrchestratorSystem: {
29+
Run: jest.fn().mockResolvedValue(''),
30+
},
31+
}));
32+
33+
const mockOrchestrator = require('../orchestrator').default;
34+
35+
describe('OrchestratorFolders git auth', () => {
36+
beforeEach(() => {
37+
jest.clearAllMocks();
38+
});
39+
40+
describe('useHeaderAuth', () => {
41+
it('should return true when gitAuthMode is header', () => {
42+
mockOrchestrator.buildParameters.gitAuthMode = 'header';
43+
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
44+
});
45+
46+
it('should return true when gitAuthMode is undefined (default)', () => {
47+
mockOrchestrator.buildParameters.gitAuthMode = undefined;
48+
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
49+
});
50+
51+
it('should return false when gitAuthMode is url', () => {
52+
mockOrchestrator.buildParameters.gitAuthMode = 'url';
53+
expect(OrchestratorFolders.useHeaderAuth).toBe(false);
54+
});
55+
});
56+
57+
describe('unityBuilderRepoUrl', () => {
58+
it('should not include token in URL when using header auth', () => {
59+
mockOrchestrator.buildParameters.gitAuthMode = 'header';
60+
const url = OrchestratorFolders.unityBuilderRepoUrl;
61+
expect(url).toBe('https://github.com/game-ci/unity-builder.git');
62+
expect(url).not.toContain('ghp_test123');
63+
});
64+
65+
it('should include token in URL when using url auth (legacy)', () => {
66+
mockOrchestrator.buildParameters.gitAuthMode = 'url';
67+
const url = OrchestratorFolders.unityBuilderRepoUrl;
68+
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
69+
});
70+
});
71+
72+
describe('targetBuildRepoUrl', () => {
73+
it('should not include token in URL when using header auth', () => {
74+
mockOrchestrator.buildParameters.gitAuthMode = 'header';
75+
const url = OrchestratorFolders.targetBuildRepoUrl;
76+
expect(url).toBe('https://github.com/myorg/myrepo.git');
77+
expect(url).not.toContain('ghp_test123');
78+
});
79+
80+
it('should include token in URL when using url auth (legacy)', () => {
81+
mockOrchestrator.buildParameters.gitAuthMode = 'url';
82+
const url = OrchestratorFolders.targetBuildRepoUrl;
83+
expect(url).toBe('https://ghp_test123@github.com/myorg/myrepo.git');
84+
});
85+
});
86+
87+
describe('gitAuthConfigScript', () => {
88+
it('should emit http.extraHeader commands in header mode', () => {
89+
mockOrchestrator.buildParameters.gitAuthMode = 'header';
90+
const script = OrchestratorFolders.gitAuthConfigScript;
91+
expect(script).toContain('http.extraHeader');
92+
expect(script).toContain('GIT_PRIVATE_TOKEN');
93+
expect(script).toContain('Authorization: Basic');
94+
});
95+
96+
it('should emit no-op comment in url mode', () => {
97+
mockOrchestrator.buildParameters.gitAuthMode = 'url';
98+
const script = OrchestratorFolders.gitAuthConfigScript;
99+
expect(script).toContain('legacy');
100+
expect(script).not.toContain('http.extraHeader');
101+
});
102+
});
103+
104+
describe('configureGitAuth', () => {
105+
it('should run git config with http.extraHeader in header mode', async () => {
106+
mockOrchestrator.buildParameters.gitAuthMode = 'header';
107+
mockOrchestrator.buildParameters.gitPrivateToken = 'ghp_test123';
108+
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
109+
110+
await OrchestratorFolders.configureGitAuth();
111+
112+
// Verify the base64 encoding and extraHeader config are correct
113+
const expectedEncoded = Buffer.from('x-access-token:ghp_test123').toString('base64');
114+
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
115+
expect.stringContaining(expectedEncoded),
116+
);
117+
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
118+
expect.stringContaining('.extraHeader'),
119+
);
120+
});
121+
122+
it('should not run git config in url mode', async () => {
123+
mockOrchestrator.buildParameters.gitAuthMode = 'url';
124+
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
125+
126+
await OrchestratorFolders.configureGitAuth();
127+
128+
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
129+
});
130+
131+
it('should not run git config when no token is available', async () => {
132+
mockOrchestrator.buildParameters.gitAuthMode = 'header';
133+
mockOrchestrator.buildParameters.gitPrivateToken = '';
134+
const originalEnv = process.env.GIT_PRIVATE_TOKEN;
135+
delete process.env.GIT_PRIVATE_TOKEN;
136+
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
137+
138+
await OrchestratorFolders.configureGitAuth();
139+
140+
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
141+
if (originalEnv !== undefined) process.env.GIT_PRIVATE_TOKEN = originalEnv;
142+
});
143+
});
144+
});

src/model/orchestrator/options/orchestrator-folders.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,67 @@ export class OrchestratorFolders {
7272
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
7373
}
7474

75+
/**
76+
* Whether to use http.extraHeader for git authentication (secure, default)
77+
* instead of embedding the token in clone URLs (legacy).
78+
*/
79+
public static get useHeaderAuth(): boolean {
80+
return Orchestrator.buildParameters.gitAuthMode !== 'url';
81+
}
82+
7583
public static get unityBuilderRepoUrl(): string {
84+
if (OrchestratorFolders.useHeaderAuth) {
85+
return `https://github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
86+
}
87+
7688
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
7789
}
7890

7991
public static get targetBuildRepoUrl(): string {
92+
if (OrchestratorFolders.useHeaderAuth) {
93+
return `https://github.com/${Orchestrator.buildParameters.githubRepo}.git`;
94+
}
95+
8096
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
8197
}
8298

99+
/**
100+
* Shell commands to configure git authentication via http.extraHeader.
101+
* Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output.
102+
* This is the same mechanism used by actions/checkout.
103+
*
104+
* Only emits commands when gitAuthMode is 'header' (default). In 'url' mode,
105+
* returns a no-op comment since the token is already in the URL.
106+
*/
107+
public static get gitAuthConfigScript(): string {
108+
if (!OrchestratorFolders.useHeaderAuth) {
109+
return `# git auth: using token-in-URL mode (legacy)`;
110+
}
111+
112+
return `# git auth: configuring http.extraHeader (secure mode)
113+
if [ -n "$GIT_PRIVATE_TOKEN" ]; then
114+
git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)"
115+
fi`;
116+
}
117+
118+
/**
119+
* Configure git authentication via http.extraHeader in the current Node process.
120+
* For use in the remote-client where shell scripts aren't used.
121+
* Only configures when gitAuthMode is 'header' (default).
122+
*/
123+
public static async configureGitAuth(): Promise<void> {
124+
if (!OrchestratorFolders.useHeaderAuth) return;
125+
126+
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
127+
if (!token) return;
128+
129+
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
130+
const { OrchestratorSystem } = await import('../services/core/orchestrator-system');
131+
await OrchestratorSystem.Run(
132+
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
133+
);
134+
}
135+
83136
public static get buildVolumeFolder() {
84137
return 'data';
85138
}

src/model/orchestrator/options/orchestrator-options.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ class OrchestratorOptions {
138138
return provider || 'local';
139139
}
140140

141+
static get gitAuthMode(): string {
142+
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
143+
}
144+
141145
static get containerCpu(): string {
142146
return OrchestratorOptions.getInput('containerCpu') || `1024`;
143147
}

src/model/orchestrator/remote-client/index.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export class RemoteClient {
302302

303303
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
304304
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
305+
await OrchestratorFolders.configureGitAuth();
305306
RemoteClientLogger.log(`Cloning the repository being built:`);
306307
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
307308
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
@@ -411,12 +412,7 @@ export class RemoteClient {
411412
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
412413
if (gitPrivateToken) {
413414
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
414-
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
415-
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
416-
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
417-
await OrchestratorSystem.Run(
418-
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
419-
);
415+
await RemoteClient.configureTokenAuth(gitPrivateToken);
420416
await OrchestratorSystem.Run(`git lfs pull`, true);
421417
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
422418
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
@@ -432,12 +428,7 @@ export class RemoteClient {
432428
const githubToken = process.env.GITHUB_TOKEN;
433429
if (githubToken) {
434430
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
435-
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
436-
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
437-
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
438-
await OrchestratorSystem.Run(
439-
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
440-
);
431+
await RemoteClient.configureTokenAuth(githubToken);
441432
await OrchestratorSystem.Run(`git lfs pull`, true);
442433
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
443434
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
@@ -501,4 +492,25 @@ export class RemoteClient {
501492

502493
return false;
503494
}
495+
496+
/**
497+
* Configure git authentication for a token. In header mode (default), uses
498+
* http.extraHeader so the token never appears in URLs or git config output.
499+
* In url mode (legacy), uses url.insteadOf to embed the token in URLs.
500+
*/
501+
private static async configureTokenAuth(token: string): Promise<void> {
502+
if (OrchestratorFolders.useHeaderAuth) {
503+
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
504+
await OrchestratorSystem.Run(
505+
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
506+
);
507+
} else {
508+
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
509+
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
510+
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
511+
await OrchestratorSystem.Run(
512+
`git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`,
513+
);
514+
}
515+
}
504516
}

src/model/orchestrator/workflows/async-workflow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ printenv
2727
git config --global advice.detachedHead false
2828
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
2929
git config --global filter.lfs.process "git-lfs filter-process --skip"
30+
${OrchestratorFolders.gitAuthConfigScript}
3031
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
3132
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
3233
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then

src/model/orchestrator/workflows/build-automation-workflow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
9292
const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder(
9393
OrchestratorFolders.builderPathAbsolute,
9494
)}
95+
${OrchestratorFolders.gitAuthConfigScript}
9596
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
9697
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
9798
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"

0 commit comments

Comments
 (0)