From 9afdb73931ed301a48b1ae9f8733ffe4a623d87e Mon Sep 17 00:00:00 2001
From: Lagorce <vlagorce@gmail.com>
Date: Thu, 19 May 2022 08:03:38 +0200
Subject: [PATCH] fix(docker): use a GET request to the real resource auth.
 (#14744) (#15312)

---
 lib/modules/datasource/docker/index.spec.ts | 128 ++++++++++++++------
 lib/modules/datasource/docker/index.ts      |  18 ++-
 2 files changed, 103 insertions(+), 43 deletions(-)

diff --git a/lib/modules/datasource/docker/index.spec.ts b/lib/modules/datasource/docker/index.spec.ts
index c5234c1c79..5049f64089 100644
--- a/lib/modules/datasource/docker/index.spec.ts
+++ b/lib/modules/datasource/docker/index.spec.ts
@@ -105,15 +105,12 @@ describe('modules/datasource/docker/index', () => {
   });
 
   describe('getAuthHeaders', () => {
-    beforeEach(() => {
+    it('returns "authType token" if both provided', async () => {
       httpMock
         .scope('https://my.local.registry')
         .get('/v2/', undefined, { badheaders: ['authorization'] })
         .reply(401, '', { 'www-authenticate': 'Authenticate you must' });
       hostRules.hosts.mockReturnValue([]);
-    });
-
-    it('returns "authType token" if both provided', async () => {
       hostRules.find.mockReturnValue({
         authType: 'some-authType',
         token: 'some-token',
@@ -134,6 +131,11 @@ describe('modules/datasource/docker/index', () => {
     });
 
     it('returns "Bearer token" if only token provided', async () => {
+      httpMock
+        .scope('https://my.local.registry')
+        .get('/v2/', undefined, { badheaders: ['authorization'] })
+        .reply(401, '', { 'www-authenticate': 'Authenticate you must' });
+      hostRules.hosts.mockReturnValue([]);
       hostRules.find.mockReturnValue({
         token: 'some-token',
       });
@@ -153,6 +155,11 @@ describe('modules/datasource/docker/index', () => {
     });
 
     it('fails', async () => {
+      httpMock
+        .scope('https://my.local.registry')
+        .get('/v2/', undefined, { badheaders: ['authorization'] })
+        .reply(401, '', { 'www-authenticate': 'Authenticate you must' });
+      hostRules.hosts.mockReturnValue([]);
       httpMock.clear(false);
 
       httpMock
@@ -168,6 +175,34 @@ describe('modules/datasource/docker/index', () => {
 
       expect(headers).toBeNull();
     });
+
+    it('use resources URL and resolve scope in www-authenticate header', async () => {
+      httpMock
+        .scope('https://my.local.registry')
+        .get('/v2/my/node/resource')
+        .reply(401, '', {
+          'www-authenticate':
+            'Bearer realm="https://my.local.registry/oauth2/token",service="my.local.registry",scope="repository:my/node:whatever"',
+        })
+        .get(
+          '/oauth2/token?service=my.local.registry&scope=repository:my/node:whatever'
+        )
+        .reply(200, { token: 'some-token' });
+
+      const headers = await getAuthHeaders(
+        http,
+        'https://my.local.registry',
+        'my/node/prefix',
+        'https://my.local.registry/v2/my/node/resource'
+      );
+
+      // do not inline, otherwise we get false positive from codeql
+      expect(headers).toMatchInlineSnapshot(`
+        Object {
+          "authorization": "Bearer some-token",
+        }
+      `);
+    });
   });
 
   describe('getDigest', () => {
@@ -223,7 +258,7 @@ describe('modules/datasource/docker/index', () => {
         .get('/')
         .reply(401, '', {
           'www-authenticate':
-            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull  "',
+            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull  "',
         })
         .head('/library/some-dep/manifests/latest')
         .reply(200, {}, { 'docker-content-digest': 'some-digest' });
@@ -249,7 +284,7 @@ describe('modules/datasource/docker/index', () => {
         .twice()
         .reply(401, '', {
           'www-authenticate':
-            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull  "',
+            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull  "',
         })
         .head('/library/some-dep/manifests/some-new-value')
         .reply(200, undefined, {})
@@ -497,7 +532,7 @@ describe('modules/datasource/docker/index', () => {
         .get('/')
         .reply(401, '', {
           'www-authenticate':
-            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull  "',
+            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-other-dep:pull  "',
         })
         .head('/library/some-other-dep/manifests/8.0.0-alpine')
         .reply(200, {}, { 'docker-content-digest': 'some-digest' });
@@ -533,7 +568,7 @@ describe('modules/datasource/docker/index', () => {
     it('returns null if no token', async () => {
       httpMock
         .scope(baseUrl)
-        .get('/')
+        .get('/library/node/tags/list?n=10000')
         .reply(200, '', {})
         .get('/library/node/tags/list?n=10000')
         .reply(403);
@@ -549,7 +584,7 @@ describe('modules/datasource/docker/index', () => {
       const tags = ['1.0.0'];
       httpMock
         .scope('https://registry.company.com/v2')
-        .get('/')
+        .get('/node/tags/list?n=10000')
         .reply(200, '', {})
         .get('/node/tags/list?n=10000')
         .reply(
@@ -580,7 +615,7 @@ describe('modules/datasource/docker/index', () => {
       const tags = ['1.0.0'];
       httpMock
         .scope('https://registry.company.com/v2')
-        .get('/')
+        .get('/node/tags/list?n=10000')
         .reply(200, '', {})
         .get('/node/tags/list?n=10000')
         .reply(200, { tags }, {})
@@ -638,7 +673,7 @@ describe('modules/datasource/docker/index', () => {
     it('uses lower tag limit for ECR deps', async () => {
       httpMock
         .scope(amazonUrl)
-        .get('/')
+        .get('/node/tags/list?n=1000')
         .reply(200, '', {})
         // The  tag limit parameter `n` needs to be limited to 1000 for ECR
         // See https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults
@@ -663,7 +698,7 @@ describe('modules/datasource/docker/index', () => {
       it('resolves requests to ECR proxy', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(
@@ -727,7 +762,7 @@ describe('modules/datasource/docker/index', () => {
 
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(405, maxResultsErrorBody, {
@@ -748,7 +783,7 @@ describe('modules/datasource/docker/index', () => {
       it('returns null when the response code is not 405', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(
@@ -779,7 +814,7 @@ describe('modules/datasource/docker/index', () => {
       it('returns null when no response headers are present', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(405, {
@@ -802,7 +837,7 @@ describe('modules/datasource/docker/index', () => {
       it('returns null when the expected docker header is missing', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(
@@ -831,7 +866,7 @@ describe('modules/datasource/docker/index', () => {
       it('returns null when the response body does not contain an errors object', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(
@@ -852,7 +887,7 @@ describe('modules/datasource/docker/index', () => {
       it('returns null when the response body does not contain errors', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(
@@ -875,7 +910,7 @@ describe('modules/datasource/docker/index', () => {
       it('returns null when the the response errors does not have a message property', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(
@@ -902,7 +937,7 @@ describe('modules/datasource/docker/index', () => {
       it('returns null when the the error message does not have the expected max results error', async () => {
         httpMock
           .scope('https://ecr-proxy.company.com/v2')
-          .get('/')
+          .get('/node/tags/list?n=10000')
           .reply(200, '', {})
           .get('/node/tags/list?n=10000')
           .reply(
@@ -932,7 +967,7 @@ describe('modules/datasource/docker/index', () => {
       const tags = ['1.0.0'];
       httpMock
         .scope(baseUrl)
-        .get('/')
+        .get('/library/node/tags/list?n=10000')
         .reply(401, '', {
           'www-authenticate':
             'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/node:pull  "',
@@ -960,7 +995,7 @@ describe('modules/datasource/docker/index', () => {
       const tags = ['1.0.0'];
       httpMock
         .scope(baseUrl)
-        .get('/')
+        .get('/library/node/tags/list?n=10000')
         .reply(401, '', {
           'www-authenticate':
             'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/node:pull  "',
@@ -988,7 +1023,7 @@ describe('modules/datasource/docker/index', () => {
       const tags = ['1.0.0'];
       httpMock
         .scope('https://k8s.gcr.io/v2/')
-        .get('/')
+        .get('/kubernetes-dashboard-amd64/tags/list?n=10000')
         .reply(401, '', {
           'www-authenticate':
             'Bearer realm="https://k8s.gcr.io/v2/token",service="k8s.gcr.io"',
@@ -1013,7 +1048,7 @@ describe('modules/datasource/docker/index', () => {
     it('returns null on error', async () => {
       httpMock
         .scope(baseUrl)
-        .get('/')
+        .get('/my/node/tags/list?n=10000')
         .reply(200, null)
         .get('/my/node/tags/list?n=10000')
         .replyWithError('error');
@@ -1027,7 +1062,7 @@ describe('modules/datasource/docker/index', () => {
     it('strips trailing slash from registry', async () => {
       httpMock
         .scope(baseUrl)
-        .get('/')
+        .get('/my/node/tags/list?n=10000')
         .reply(401, '', {
           'www-authenticate':
             'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:my/node:pull  "',
@@ -1052,9 +1087,12 @@ describe('modules/datasource/docker/index', () => {
 
     it('returns null if no auth', async () => {
       hostRules.find.mockReturnValue({});
-      httpMock.scope(baseUrl).get('/').reply(401, undefined, {
-        'www-authenticate': 'Basic realm="My Private Docker Registry Server"',
-      });
+      httpMock
+        .scope(baseUrl)
+        .get('/library/node/tags/list?n=10000')
+        .reply(401, undefined, {
+          'www-authenticate': 'Basic realm="My Private Docker Registry Server"',
+        });
       const res = await getPkgReleases({
         datasource: DockerDatasource.id,
         depName: 'node',
@@ -1066,7 +1104,9 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(3)
+        .times(2)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, {
@@ -1129,7 +1169,9 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(4)
+        .times(3)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, { tags: ['abc'] })
@@ -1169,7 +1211,8 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(2)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, { tags: ['latest'] })
@@ -1193,7 +1236,8 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(2)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, { tags: ['latest'] })
@@ -1216,7 +1260,8 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(2)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, { tags: ['latest'] })
@@ -1236,7 +1281,9 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(4)
+        .times(3)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, { tags: ['1'] })
@@ -1280,7 +1327,9 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(4)
+        .times(3)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, { tags: ['1'] })
@@ -1323,7 +1372,8 @@ describe('modules/datasource/docker/index', () => {
       httpMock
         .scope('https://registry.company.com/v2')
         .get('/')
-        .times(2)
+        .reply(200)
+        .get('/node/tags/list?n=10000')
         .reply(200)
         .get('/node/tags/list?n=10000')
         .reply(200, { tags: ['latest'] })
@@ -1349,7 +1399,11 @@ describe('modules/datasource/docker/index', () => {
           badheaders: ['authorization'],
         })
         .get('/')
-        .times(3)
+        .times(2)
+        .reply(401, '', {
+          'www-authenticate': 'Basic realm="My Private Docker Registry Server"',
+        })
+        .get('/node/tags/list?n=10000')
         .reply(401, '', {
           'www-authenticate': 'Basic realm="My Private Docker Registry Server"',
         });
diff --git a/lib/modules/datasource/docker/index.ts b/lib/modules/datasource/docker/index.ts
index 797ddc635a..54e2e5fcae 100644
--- a/lib/modules/datasource/docker/index.ts
+++ b/lib/modules/datasource/docker/index.ts
@@ -53,17 +53,17 @@ function isDockerHost(host: string): boolean {
 export async function getAuthHeaders(
   http: Http,
   registryHost: string,
-  dockerRepository: string
+  dockerRepository: string,
+  apiCheckUrl = `${registryHost}/v2/`
 ): Promise<OutgoingHttpHeaders | null> {
   try {
-    const apiCheckUrl = `${registryHost}/v2/`;
     const apiCheckResponse = await http.get(apiCheckUrl, {
       throwHttpErrors: false,
       noAuth: true,
     });
 
     if (apiCheckResponse.statusCode === 200) {
-      logger.debug({ registryHost }, 'No registry auth required');
+      logger.debug({ apiCheckUrl }, 'No registry auth required');
       return {};
     }
     if (
@@ -71,7 +71,7 @@ export async function getAuthHeaders(
       !is.nonEmptyString(apiCheckResponse.headers['www-authenticate'])
     ) {
       logger.warn(
-        { registryHost, res: apiCheckResponse },
+        { apiCheckUrl, res: apiCheckResponse },
         'Invalid registry response'
       );
       return null;
@@ -135,7 +135,12 @@ export async function getAuthHeaders(
       return opts.headers ?? null;
     }
 
-    const authUrl = `${authenticateHeader.params.realm}?service=${authenticateHeader.params.service}&scope=repository:${dockerRepository}:pull`;
+    let scope = `repository:${dockerRepository}:pull`;
+    if (is.string(authenticateHeader.params.scope)) {
+      scope = authenticateHeader.params.scope;
+    }
+
+    const authUrl = `${authenticateHeader.params.realm}?service=${authenticateHeader.params.service}&scope=${scope}`;
     logger.trace(
       { registryHost, dockerRepository, authUrl },
       `Obtaining docker registry token`
@@ -691,7 +696,8 @@ export class DockerDatasource extends Datasource {
     const headers = await getAuthHeaders(
       this.http,
       registryHost,
-      dockerRepository
+      dockerRepository,
+      url
     );
     if (!headers) {
       logger.debug('Failed to get authHeaders for getTags lookup');
-- 
GitLab