From e305fddb34dfafe4de2e324e08a0c6cebf6bb37e Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Tue, 12 May 2026 09:05:24 -0700 Subject: [PATCH 1/3] Switch container registry host resolution across container commands to use validated vars.host --- src/commands/container/login.ts | 5 ++-- src/commands/container/logout.ts | 5 ++-- src/commands/container/pull.ts | 5 ++-- src/commands/container/push.ts | 5 ++-- src/commands/container/release.ts | 5 ++-- src/commands/container/run.ts | 5 ++-- .../commands/container/login.unit.test.ts | 23 +++++++++++++++++++ .../commands/container/logout.unit.test.ts | 21 +++++++++++++++++ 8 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/commands/container/login.ts b/src/commands/container/login.ts index b4b976e62b..6af19e4319 100644 --- a/src/commands/container/login.ts +++ b/src/commands/container/login.ts @@ -1,4 +1,4 @@ -import {Command, flags} from '@heroku-cli/command' +import {Command, flags, vars} from '@heroku-cli/command' import {ux} from '@oclif/core/ux' import {debug} from '../../lib/container/debug.js' @@ -45,8 +45,7 @@ export default class Login extends Command { async run() { const {flags} = await this.parse(Login) const {verbose} = flags - const herokuHost = process.env.HEROKU_HOST || 'heroku.com' - const registry = `registry.${herokuHost}` + const registry = `registry.${vars.host}` const password = this.heroku.auth if (verbose) { diff --git a/src/commands/container/logout.ts b/src/commands/container/logout.ts index 30f6b11648..6f03892e7b 100644 --- a/src/commands/container/logout.ts +++ b/src/commands/container/logout.ts @@ -1,4 +1,4 @@ -import {Command, flags} from '@heroku-cli/command' +import {Command, flags, vars} from '@heroku-cli/command' import {ux} from '@oclif/core/ux' import {debug} from '../../lib/container/debug.js' @@ -24,8 +24,7 @@ export default class Logout extends Command { async run() { const {flags} = await this.parse(Logout) const {verbose} = flags - const herokuHost = process.env.HEROKU_HOST || 'heroku.com' - const registry = `registry.${herokuHost}` + const registry = `registry.${vars.host}` if (verbose) { debug.enabled = true diff --git a/src/commands/container/pull.ts b/src/commands/container/pull.ts index dc9f94415f..14ea4eff14 100644 --- a/src/commands/container/pull.ts +++ b/src/commands/container/pull.ts @@ -1,4 +1,4 @@ -import {Command, flags} from '@heroku-cli/command' +import {Command, flags, vars} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' @@ -34,8 +34,7 @@ export default class Pull extends Command { const {body: appBody} = await this.heroku.get(`/apps/${app}`) ensureContainerStack(appBody, 'pull') - const herokuHost = process.env.HEROKU_HOST || 'heroku.com' - const registry = `registry.${herokuHost}` + const registry = `registry.${vars.host}` if (verbose) { debug.enabled = true diff --git a/src/commands/container/push.ts b/src/commands/container/push.ts index 59d55a96ba..a12960124e 100644 --- a/src/commands/container/push.ts +++ b/src/commands/container/push.ts @@ -1,4 +1,4 @@ -import {Command, flags} from '@heroku-cli/command' +import {Command, flags, vars} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' @@ -48,8 +48,7 @@ export default class Push extends Command { const {body: appBody} = await this.heroku.get(`/apps/${app}`) ensureContainerStack(appBody, 'push') - const herokuHost = process.env.HEROKU_HOST || 'heroku.com' - const registry = `registry.${herokuHost}` + const registry = `registry.${vars.host}` const dockerfiles = this.dockerHelper.getDockerfiles(process.cwd(), recursive) const possibleJobs = this.dockerHelper.getJobs(`${registry}/${app}`, dockerfiles) const jobs = await this.selectJobs(possibleJobs, processTypes as string[], recursive) diff --git a/src/commands/container/release.ts b/src/commands/container/release.ts index 0788dbd1b8..ae1248b584 100644 --- a/src/commands/container/release.ts +++ b/src/commands/container/release.ts @@ -1,4 +1,4 @@ -import {Command, flags} from '@heroku-cli/command' +import {Command, flags, vars} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' import {ux} from '@oclif/core/ux' @@ -43,7 +43,6 @@ export default class ContainerRelease extends Command { const {body: appBody} = await this.heroku.get(`/apps/${app}`) ensureContainerStack(appBody, 'release') - const herokuHost: string = process.env.HEROKU_HOST || 'heroku.com' const updateData: any[] = [] for (const process of argv) { const image = `${app}/${process}` @@ -55,7 +54,7 @@ export default class ContainerRelease extends Command { Accept: 'application/vnd.docker.distribution.manifest.v2+json', Authorization: `Basic ${Buffer.from(`:${this.heroku.auth}`).toString('base64')}`, }, - hostname: `registry.${herokuHost}`, + hostname: `registry.${vars.host}`, }, ) let imageID diff --git a/src/commands/container/run.ts b/src/commands/container/run.ts index 761172509c..8c544eb736 100644 --- a/src/commands/container/run.ts +++ b/src/commands/container/run.ts @@ -1,4 +1,4 @@ -import {Command, flags} from '@heroku-cli/command' +import {Command, flags, vars} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' @@ -43,8 +43,7 @@ export default class Run extends Command { const processType = argv.shift() as string const command: string = argv.join(' ') - const herokuHost = process.env.HEROKU_HOST || 'heroku.com' - const registry = `registry.${herokuHost}` + const registry = `registry.${vars.host}` const dockerfiles = this.dockerHelper.getDockerfiles(process.cwd(), false) const possibleJobs = this.dockerHelper.getJobs(`${registry}/${app}`, dockerfiles) diff --git a/test/unit/commands/container/login.unit.test.ts b/test/unit/commands/container/login.unit.test.ts index ac3a90704e..2be3668143 100644 --- a/test/unit/commands/container/login.unit.test.ts +++ b/test/unit/commands/container/login.unit.test.ts @@ -30,6 +30,29 @@ describe('container:login', function () { sandbox.assert.calledOnce(login) }) + it('rejects invalid HEROKU_HOST and uses default registry', async function () { + const originalHost = process.env.HEROKU_HOST + process.env.HEROKU_HOST = 'attacker.com' + + try { + const version = sandbox.stub(DockerHelper.prototype, 'version').resolves([19, 12]) + const login = sandbox.stub(DockerHelper.prototype, 'cmd') + .withArgs('docker', ['login', '--username=_', '--password-stdin', 'registry.heroku.com'], {input: 'heroku_token'}) + + const {stderr} = await runCommand(Cmd) + + expect(stderr).to.contain("Invalid HEROKU_HOST 'attacker.com'") + sandbox.assert.calledOnce(version) + sandbox.assert.calledOnce(login) + } finally { + if (originalHost === undefined) { + delete process.env.HEROKU_HOST + } else { + process.env.HEROKU_HOST = originalHost + } + } + }) + it('logs to the docker registry with an old version', async function () { const version = sandbox.stub(DockerHelper.prototype, 'version').returns(new Promise(function (resolve) { resolve([17, 0]) diff --git a/test/unit/commands/container/logout.unit.test.ts b/test/unit/commands/container/logout.unit.test.ts index 07f33b1742..4c3567380c 100644 --- a/test/unit/commands/container/logout.unit.test.ts +++ b/test/unit/commands/container/logout.unit.test.ts @@ -16,6 +16,27 @@ describe('container logout', function () { return sandbox.restore() }) + it('rejects invalid HEROKU_HOST and uses default registry', async function () { + const originalHost = process.env.HEROKU_HOST + process.env.HEROKU_HOST = 'attacker.com' + + try { + const logout = sandbox.stub(DockerHelper.prototype, 'cmd') + .withArgs('docker', ['logout', 'registry.heroku.com']) + + const {stderr} = await runCommand(Cmd) + + expect(stderr).to.contain("Invalid HEROKU_HOST 'attacker.com'") + sandbox.assert.calledOnce(logout) + } finally { + if (originalHost === undefined) { + delete process.env.HEROKU_HOST + } else { + process.env.HEROKU_HOST = originalHost + } + } + }) + it('logs out of the docker registry', async function () { const logout = sandbox.stub(DockerHelper.prototype, 'cmd') .withArgs('docker', ['logout', 'registry.heroku.com']) From 75bb5d83e9801bff6ca14450186fb78f02c2d377 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Tue, 12 May 2026 11:01:59 -0700 Subject: [PATCH 2/3] Add coverage for container:release to ensure an invalid HEROKU_HOST is rejected and the command falls back to registry.heroku.com --- .../commands/container/release.unit.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/unit/commands/container/release.unit.test.ts b/test/unit/commands/container/release.unit.test.ts index 5fcdedf6dd..f7cf99854d 100644 --- a/test/unit/commands/container/release.unit.test.ts +++ b/test/unit/commands/container/release.unit.test.ts @@ -46,6 +46,55 @@ describe('container release', function () { expect(oclif.exit).to.equal(1) }) + context('when HEROKU_HOST is set to an invalid domain', function () { + let originalHost: string | undefined + let registry: nock.Scope + + beforeEach(function () { + originalHost = process.env.HEROKU_HOST + process.env.HEROKU_HOST = 'attacker.com' + api + .get('/apps/testapp') + .reply(200, {name: 'testapp', stack: {name: 'container'}}) + registry = nock('https://registry.heroku.com:443') + }) + + afterEach(function () { + if (originalHost === undefined) { + delete process.env.HEROKU_HOST + } else { + process.env.HEROKU_HOST = originalHost + } + + registry.done() + }) + + it('rejects invalid host and sends request to registry.heroku.com', async function () { + api + .patch('/apps/testapp/formation', { + updates: [ + {docker_image: 'image_id', type: 'web'}, + ], + }) + .reply(200, {}) + .get('/apps/testapp/releases') + .reply(200, []) + .get('/apps/testapp/releases') + .reply(200, [{id: 'release_id'}]) + registry + .get('/v2/testapp/web/manifests/latest') + .reply(200, {config: {digest: 'image_id'}, schemaVersion: 2}) + + const {stderr} = await runCommand(Cmd, [ + '--app', + 'testapp', + 'web', + ]) + + expect(stderr).to.contain("Invalid HEROKU_HOST 'attacker.com'") + }) + }) + context('when the app is a container app', function () { let registry: nock.Scope beforeEach(function () { From 6d9cab551bcd3fe8b3d0c34d28cac1dc36fb086c Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Tue, 12 May 2026 11:14:10 -0700 Subject: [PATCH 3/3] Refactor the new invalid-HEROKU_HOST login/logout test coverage to use scoped setup/teardown contexts --- .../commands/container/login.unit.test.ts | 28 +++++++++++-------- .../commands/container/logout.unit.test.ts | 28 +++++++++++-------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/test/unit/commands/container/login.unit.test.ts b/test/unit/commands/container/login.unit.test.ts index 2be3668143..73f0d901eb 100644 --- a/test/unit/commands/container/login.unit.test.ts +++ b/test/unit/commands/container/login.unit.test.ts @@ -30,11 +30,23 @@ describe('container:login', function () { sandbox.assert.calledOnce(login) }) - it('rejects invalid HEROKU_HOST and uses default registry', async function () { - const originalHost = process.env.HEROKU_HOST - process.env.HEROKU_HOST = 'attacker.com' + context('when HEROKU_HOST is set to an invalid domain', function () { + let originalHost: string | undefined - try { + beforeEach(function () { + originalHost = process.env.HEROKU_HOST + process.env.HEROKU_HOST = 'attacker.com' + }) + + afterEach(function () { + if (originalHost === undefined) { + delete process.env.HEROKU_HOST + } else { + process.env.HEROKU_HOST = originalHost + } + }) + + it('rejects invalid HEROKU_HOST and uses default registry', async function () { const version = sandbox.stub(DockerHelper.prototype, 'version').resolves([19, 12]) const login = sandbox.stub(DockerHelper.prototype, 'cmd') .withArgs('docker', ['login', '--username=_', '--password-stdin', 'registry.heroku.com'], {input: 'heroku_token'}) @@ -44,13 +56,7 @@ describe('container:login', function () { expect(stderr).to.contain("Invalid HEROKU_HOST 'attacker.com'") sandbox.assert.calledOnce(version) sandbox.assert.calledOnce(login) - } finally { - if (originalHost === undefined) { - delete process.env.HEROKU_HOST - } else { - process.env.HEROKU_HOST = originalHost - } - } + }) }) it('logs to the docker registry with an old version', async function () { diff --git a/test/unit/commands/container/logout.unit.test.ts b/test/unit/commands/container/logout.unit.test.ts index 4c3567380c..ca8d887a31 100644 --- a/test/unit/commands/container/logout.unit.test.ts +++ b/test/unit/commands/container/logout.unit.test.ts @@ -16,11 +16,23 @@ describe('container logout', function () { return sandbox.restore() }) - it('rejects invalid HEROKU_HOST and uses default registry', async function () { - const originalHost = process.env.HEROKU_HOST - process.env.HEROKU_HOST = 'attacker.com' + context('when HEROKU_HOST is set to an invalid domain', function () { + let originalHost: string | undefined - try { + beforeEach(function () { + originalHost = process.env.HEROKU_HOST + process.env.HEROKU_HOST = 'attacker.com' + }) + + afterEach(function () { + if (originalHost === undefined) { + delete process.env.HEROKU_HOST + } else { + process.env.HEROKU_HOST = originalHost + } + }) + + it('rejects invalid HEROKU_HOST and uses default registry', async function () { const logout = sandbox.stub(DockerHelper.prototype, 'cmd') .withArgs('docker', ['logout', 'registry.heroku.com']) @@ -28,13 +40,7 @@ describe('container logout', function () { expect(stderr).to.contain("Invalid HEROKU_HOST 'attacker.com'") sandbox.assert.calledOnce(logout) - } finally { - if (originalHost === undefined) { - delete process.env.HEROKU_HOST - } else { - process.env.HEROKU_HOST = originalHost - } - } + }) }) it('logs out of the docker registry', async function () {