Skip to content

Commit dfbbd15

Browse files
committed
🔒 security(registries): route token-fetch requests through operator TLS settings
GAR, GitLab, Mau, DHI and public-ECR built their own token-fetch request and called axios() directly, bypassing withTlsRequestOptions() — so the credential exchange ignored the operator's cafile / insecure / client-cert config and validated against the system trust store instead. A MITM with a system-trusted cert could intercept the registry secret in the Authorization header even when a private CA was configured. - Promote BaseRegistry.withTlsRequestOptions from private to protected. - Wrap each provider's token-fetch request with it (public-ECR branch only; the private-ECR path uses the AWS SDK and is out of scope). - Align Ecr to extend BaseRegistry (it was the only provider extending the bare Registry, so it lacked the shared auth/TLS helpers).
1 parent fd0b02a commit dfbbd15

11 files changed

Lines changed: 186 additions & 21 deletions

File tree

‎app/registries/BaseRegistry.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ class BaseRegistry<
244244
return this.httpsAgent;
245245
}
246246

247-
private withTlsRequestOptions(requestOptions: RegistryRequestOptions): RegistryRequestOptions {
247+
protected withTlsRequestOptions(requestOptions: RegistryRequestOptions): RegistryRequestOptions {
248248
const httpsAgent = requestOptions.httpsAgent || this.getHttpsAgent();
249249
if (!httpsAgent) {
250250
return requestOptions;

‎app/registries/providers/dhi/Dhi.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,35 @@ describe('DHI Registry', () => {
161161
auth: '[REDACTED]',
162162
});
163163
});
164+
165+
test('should pass httpsAgent to axios when insecure=true', async () => {
166+
const { default: axios } = await import('axios');
167+
axios.mockResolvedValue({ data: { token: 'auth-token' } });
168+
169+
dhi.configuration = {
170+
url: 'https://dhi.io',
171+
insecure: true,
172+
};
173+
dhi.getAuthCredentials = vi.fn().mockReturnValue(null);
174+
175+
await dhi.authenticate({ name: 'python' }, { headers: {} });
176+
177+
expect(axios).toHaveBeenCalledWith(expect.objectContaining({ httpsAgent: expect.anything() }));
178+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
179+
expect(calledConfig.httpsAgent.options.rejectUnauthorized).toBe(false);
180+
});
181+
182+
test('should NOT attach httpsAgent when no TLS config is set', async () => {
183+
const { default: axios } = await import('axios');
184+
axios.mockResolvedValue({ data: { token: 'auth-token' } });
185+
186+
dhi.getAuthCredentials = vi.fn().mockReturnValue(null);
187+
188+
await dhi.authenticate({ name: 'python' }, { headers: {} });
189+
190+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
191+
expect(calledConfig.httpsAgent).toBeUndefined();
192+
expect(calledConfig.method).toBe('GET');
193+
expect(calledConfig.url).toContain('https://dhi.io/token');
194+
});
164195
});

‎app/registries/providers/dhi/Dhi.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class Dhi extends Custom<DhiRegistryConfiguration> {
6464
axiosConfig.headers.Authorization = `Basic ${credentials}`;
6565
}
6666

67-
const response = await axios(axiosConfig);
67+
const response = await axios(this.withTlsRequestOptions(axiosConfig));
6868
return withAuthorizationHeader(
6969
requestOptions,
7070
'Bearer',

‎app/registries/providers/ecr/Ecr.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,3 +635,35 @@ test('match should return true for public ECR gallery', async () => {
635635
}),
636636
).toBeTruthy();
637637
});
638+
639+
test('authenticate public ECR gallery should pass httpsAgent to axios when insecure=true', async () => {
640+
mockAxios.mockResolvedValueOnce({ data: { token: 'public-token-insecure' } });
641+
642+
const ecrPublicInsecure = new Ecr();
643+
ecrPublicInsecure.configuration = { insecure: true };
644+
645+
await ecrPublicInsecure.authenticate(
646+
{ registry: { url: 'https://public.ecr.aws/v2' } },
647+
{ headers: {} },
648+
);
649+
650+
expect(mockAxios).toHaveBeenCalledWith(
651+
expect.objectContaining({ httpsAgent: expect.anything() }),
652+
);
653+
const calledConfig = mockAxios.mock.calls[0][0];
654+
expect(calledConfig.httpsAgent.options.rejectUnauthorized).toBe(false);
655+
});
656+
657+
test('authenticate public ECR gallery should NOT attach httpsAgent when no TLS config is set', async () => {
658+
mockAxios.mockResolvedValueOnce({ data: { token: 'public-token-123' } });
659+
660+
const ecrPublic = new Ecr();
661+
ecrPublic.configuration = {};
662+
663+
await ecrPublic.authenticate({ registry: { url: 'https://public.ecr.aws/v2' } }, { headers: {} });
664+
665+
const calledConfig = mockAxios.mock.calls[0][0];
666+
expect(calledConfig.httpsAgent).toBeUndefined();
667+
expect(calledConfig.method).toBe('GET');
668+
expect(calledConfig.url).toBe('https://public.ecr.aws/token/');
669+
});

‎app/registries/providers/ecr/Ecr.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr';
22
import axios from 'axios';
33
import { requireAuthString, withAuthorizationHeader } from '../../../security/auth.js';
4-
import Registry from '../../Registry.js';
4+
import BaseRegistry, { type BaseRegistryConfiguration } from '../../BaseRegistry.js';
55

66
const ECR_PUBLIC_GALLERY_HOSTNAME = 'public.ecr.aws';
77
const PRIVATE_ECR_AUTH_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
@@ -26,7 +26,7 @@ function getRegistryHost(registryUrl: string | undefined): string {
2626
/**
2727
* Elastic Container Registry integration.
2828
*/
29-
interface EcrRegistryConfiguration {
29+
interface EcrRegistryConfiguration extends BaseRegistryConfiguration {
3030
accesskeyid?: string;
3131
secretaccesskey?: string;
3232
region?: string;
@@ -43,7 +43,7 @@ interface EcrAuthTokenFetchEntry {
4343
promise: Promise<string | undefined>;
4444
}
4545

46-
class Ecr extends Registry<EcrRegistryConfiguration> {
46+
class Ecr extends BaseRegistry<EcrRegistryConfiguration> {
4747
private privateEcrAuthTokenCache?: EcrAuthTokenCacheEntry;
4848

4949
private privateEcrAuthTokenFetch?: EcrAuthTokenFetchEntry;
@@ -195,13 +195,15 @@ class Ecr extends Registry<EcrRegistryConfiguration> {
195195

196196
// Public ECR gallery
197197
} else if (getRegistryHost(image?.registry?.url) === ECR_PUBLIC_GALLERY_HOSTNAME) {
198-
const response = await axios({
199-
method: 'GET',
200-
url: 'https://public.ecr.aws/token/',
201-
headers: {
202-
Accept: 'application/json',
203-
},
204-
});
198+
const response = await axios(
199+
this.withTlsRequestOptions({
200+
method: 'GET',
201+
url: 'https://public.ecr.aws/token/',
202+
headers: {
203+
Accept: 'application/json',
204+
},
205+
}),
206+
);
205207
return withAuthorizationHeader(
206208
requestOptionsWithAuth,
207209
'Bearer',

‎app/registries/providers/gar/Gar.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,49 @@ test('authenticate should throw a URL error when registry URL is missing', async
259259
),
260260
).rejects.toThrow('Invalid URL');
261261
});
262+
263+
test('authenticate should pass httpsAgent to axios when insecure=true', async () => {
264+
const { default: axios } = await import('axios');
265+
266+
const garInsecure = new Gar();
267+
garInsecure.configuration = {
268+
clientemail: TEST_CLIENT_EMAIL,
269+
privatekey: TEST_PRIVATE_KEY,
270+
insecure: true,
271+
};
272+
273+
await garInsecure.authenticate(
274+
{
275+
name: 'project/repository/image',
276+
registry: { url: 'us-central1-docker.pkg.dev' },
277+
},
278+
{ headers: {} },
279+
);
280+
281+
expect(axios).toHaveBeenCalledWith(expect.objectContaining({ httpsAgent: expect.anything() }));
282+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
283+
expect(calledConfig.httpsAgent.options.rejectUnauthorized).toBe(false);
284+
});
285+
286+
test('authenticate should NOT attach httpsAgent when no TLS config is set', async () => {
287+
const { default: axios } = await import('axios');
288+
289+
const garDefault = new Gar();
290+
garDefault.configuration = {
291+
clientemail: TEST_CLIENT_EMAIL,
292+
privatekey: TEST_PRIVATE_KEY,
293+
};
294+
295+
await garDefault.authenticate(
296+
{
297+
name: 'project/repository/image',
298+
registry: { url: 'us-central1-docker.pkg.dev' },
299+
},
300+
{ headers: {} },
301+
);
302+
303+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
304+
expect(calledConfig.httpsAgent).toBeUndefined();
305+
expect(calledConfig.method).toBe('GET');
306+
expect(calledConfig.url).toContain('https://us-central1-docker.pkg.dev/v2/token');
307+
});

‎app/registries/providers/gar/Gar.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class Gar extends BaseRegistry<GarRegistryConfiguration> {
5959
},
6060
};
6161

62-
const response = await axios(request);
62+
const response = await axios(this.withTlsRequestOptions(request));
6363
return withAuthorizationHeader(
6464
requestOptions,
6565
'Bearer',

‎app/registries/providers/gitlab/Gitlab.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,32 @@ test('getAuthPull should return pam', async () => {
193193
password: gitlab.configuration.token,
194194
});
195195
});
196+
197+
test('authenticate should pass httpsAgent to axios when insecure=true', async () => {
198+
axios.mockImplementation(() => ({ data: { token: 'token' } }));
199+
200+
const gitlabInsecure = new Gitlab();
201+
gitlabInsecure.configuration = {
202+
url: 'https://registry.gitlab.com',
203+
authurl: 'https://gitlab.com',
204+
token: TEST_TOKEN,
205+
insecure: true,
206+
};
207+
208+
await gitlabInsecure.authenticate({ name: 'group/project' }, { headers: {} });
209+
210+
expect(axios).toHaveBeenCalledWith(expect.objectContaining({ httpsAgent: expect.anything() }));
211+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
212+
expect(calledConfig.httpsAgent.options.rejectUnauthorized).toBe(false);
213+
});
214+
215+
test('authenticate should NOT attach httpsAgent when no TLS config is set', async () => {
216+
axios.mockImplementation(() => ({ data: { token: 'token' } }));
217+
218+
await gitlab.authenticate({ name: 'group/project' }, { headers: {} });
219+
220+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
221+
expect(calledConfig.httpsAgent).toBeUndefined();
222+
expect(calledConfig.method).toBe('GET');
223+
expect(calledConfig.headers.Authorization).toContain('Basic ');
224+
});

‎app/registries/providers/gitlab/Gitlab.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class Gitlab<
6969
Authorization: `Basic ${Gitlab.base64Encode('', this.configuration.token)}`,
7070
},
7171
};
72-
const response = await axios(request);
72+
const response = await axios(this.withTlsRequestOptions(request));
7373
return withAuthorizationHeader(
7474
requestOptions,
7575
'Bearer',

‎app/registries/providers/mau/Mau.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,32 @@ test('init should convert string configuration to object defaults', async () =>
236236
authurl: 'https://dock.mau.dev',
237237
});
238238
});
239+
240+
test('authenticate should pass httpsAgent to axios when insecure=true', async () => {
241+
axios.mockImplementation(() => ({ data: { token: 'token' } }));
242+
243+
const mauInsecure = new Mau();
244+
mauInsecure.configuration = {
245+
url: 'https://dock.mau.dev',
246+
authurl: 'https://dock.mau.dev',
247+
token: TEST_TOKEN,
248+
insecure: true,
249+
};
250+
251+
await mauInsecure.authenticate({ name: 'team/image' }, { headers: {} });
252+
253+
expect(axios).toHaveBeenCalledWith(expect.objectContaining({ httpsAgent: expect.anything() }));
254+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
255+
expect(calledConfig.httpsAgent.options.rejectUnauthorized).toBe(false);
256+
});
257+
258+
test('authenticate should NOT attach httpsAgent when no TLS config is set', async () => {
259+
axios.mockImplementation(() => ({ data: { token: 'token' } }));
260+
261+
await mau.authenticate({ name: 'team/image' }, { headers: {} });
262+
263+
const calledConfig = (axios as ReturnType<typeof vi.fn>).mock.calls[0][0];
264+
expect(calledConfig.httpsAgent).toBeUndefined();
265+
expect(calledConfig.method).toBe('GET');
266+
expect(calledConfig.headers.Authorization).toContain('Basic ');
267+
});

0 commit comments

Comments
 (0)