Skip to content

Commit c710d42

Browse files
W-19264912 feat: add domain name validation for outgoing requests (#218)
* add domain name validation to outgoing requests * reword * fix domain check to include localhost * use ux.warn for warning message * Apply suggestions from code review Co-authored-by: Justin Wilaby <jwilaby@gmail.com> Signed-off-by: Eric <eablack@gmail.com> * remove unnecessary as const --------- Signed-off-by: Eric <eablack@gmail.com> Co-authored-by: Justin Wilaby <jwilaby@gmail.com>
1 parent 924bd6a commit c710d42

File tree

4 files changed

+82
-18
lines changed

4 files changed

+82
-18
lines changed

src/api-client.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import {yubikey} from './yubikey.js'
1414

1515
const netrc = new Netrc()
1616

17+
export const ALLOWED_HEROKU_DOMAINS = Object.freeze(['heroku.com', 'herokai.com', 'herokuspace.com', 'herokudev.com'])
18+
export const LOCALHOST_DOMAINS = Object.freeze(['localhost', '127.0.0.1'])
19+
1720
// eslint-disable-next-line @typescript-eslint/no-namespace
1821
export namespace APIClient {
1922
export interface Options extends HTTPRequestOptions {
@@ -137,6 +140,7 @@ export class APIClient {
137140
delinquencyConfig.warning_shown = true
138141
}
139142

143+
// eslint-disable-next-line complexity
140144
static async request<T>(url: string, opts: APIClient.Options = {}, retries = 3): Promise<APIHTTPClient<T>> {
141145
opts.headers = opts.headers || {}
142146
const currentRequestId = RequestId.create() && RequestId.headerValue
@@ -155,7 +159,22 @@ export class APIClient {
155159
}
156160

157161
if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
158-
opts.headers.authorization = `Bearer ${self.auth}`
162+
// Handle both relative and absolute URLs for validation
163+
let targetUrl: URL
164+
try {
165+
// Try absolute URL first
166+
targetUrl = new URL(url)
167+
} catch {
168+
// If that fails, assume it's relative and prepend the API base URL
169+
targetUrl = new URL(url, vars.apiUrl)
170+
}
171+
172+
const isHerokuApi = ALLOWED_HEROKU_DOMAINS.some(domain => targetUrl.hostname.endsWith(`.${domain}`))
173+
const isLocalhost = LOCALHOST_DOMAINS.includes(targetUrl.hostname as (typeof LOCALHOST_DOMAINS)[number])
174+
175+
if (isHerokuApi || isLocalhost) {
176+
opts.headers.authorization = `Bearer ${self.auth}`
177+
}
159178
}
160179

161180
this.configDelinquency(url, opts)

src/vars.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import {ux} from '@oclif/core'
12
import * as url from 'node:url'
23

4+
import {ALLOWED_HEROKU_DOMAINS, LOCALHOST_DOMAINS} from './api-client.js'
5+
36
export class Vars {
47
get apiHost(): string {
58
if (this.host.startsWith('http')) {
@@ -41,7 +44,14 @@ export class Vars {
4144
}
4245

4346
get host(): string {
44-
return this.envHost || 'heroku.com'
47+
const {envHost} = this
48+
49+
if (envHost && !this.isValidHerokuHost(envHost)) {
50+
ux.warn(`Invalid HEROKU_HOST '${envHost}' - using default`)
51+
return 'heroku.com'
52+
}
53+
54+
return envHost || 'heroku.com'
4555
}
4656

4757
get httpGitHost(): string {
@@ -62,6 +72,13 @@ export class Vars {
6272
? 'https://particleboard-staging-cloud.herokuapp.com'
6373
: 'https://particleboard.heroku.com'
6474
}
75+
76+
private isValidHerokuHost(host: string): boolean {
77+
// Remove protocol if present
78+
const cleanHost = host.replace(/^https?:\/\//, '')
79+
80+
return ALLOWED_HEROKU_DOMAINS.some(domain => cleanHost.endsWith(`.${domain}`)) || LOCALHOST_DOMAINS.some(domain => cleanHost.includes(domain))
81+
}
6582
}
6683

6784
export const vars = new Vars()

test/api-client.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ describe('api_client', () => {
112112
})
113113

114114
describe('with HEROKU_HOST', () => {
115+
test
116+
.it('rejects invalid HEROKU_HOST and uses default API', async ctx => {
117+
process.env.HEROKU_HOST = 'http://bogus-server.com'
118+
api = nock('https://api.heroku.com') // Should fallback to default
119+
api.get('/apps').reply(200, [{name: 'myapp'}])
120+
121+
const cmd = new Command([], ctx.config)
122+
await cmd.heroku.get('/apps')
123+
})
124+
115125
test
116126
.it('makes an HTTP request with HEROKU_HOST', async ctx => {
117127
const localHostURI = 'http://localhost:5000'

test/vars.test.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,28 +22,46 @@ describe('vars', () => {
2222
expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com')
2323
})
2424

25-
it('respects HEROKU_HOST', () => {
26-
process.env.HEROKU_HOST = 'customhost'
27-
expect(vars.apiHost).to.equal('api.customhost')
28-
expect(vars.apiUrl).to.equal('https://api.customhost')
29-
expect(vars.gitHost).to.equal('customhost')
30-
expect(vars.host).to.equal('customhost')
31-
expect(vars.httpGitHost).to.equal('git.customhost')
32-
expect(vars.gitPrefixes).to.deep.equal(['git@customhost:', 'ssh://git@customhost/', 'https://git.customhost/'])
25+
it('respects valid HEROKU_HOST values', () => {
26+
// Test with a valid heroku.com subdomain
27+
process.env.HEROKU_HOST = 'staging.heroku.com'
28+
expect(vars.apiHost).to.equal('api.staging.heroku.com')
29+
expect(vars.apiUrl).to.equal('https://api.staging.heroku.com')
30+
expect(vars.gitHost).to.equal('staging.heroku.com')
31+
expect(vars.host).to.equal('staging.heroku.com')
32+
expect(vars.httpGitHost).to.equal('git.staging.heroku.com')
33+
expect(vars.gitPrefixes).to.deep.equal(['git@staging.heroku.com:', 'ssh://git@staging.heroku.com/', 'https://git.staging.heroku.com/'])
3334
expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com')
3435
})
3536

36-
it('respects HEROKU_HOST as url', () => {
37-
process.env.HEROKU_HOST = 'https://customhost'
38-
expect(vars.host).to.equal('https://customhost')
39-
expect(vars.apiHost).to.equal('customhost')
40-
expect(vars.apiUrl).to.equal('https://customhost')
41-
expect(vars.gitHost).to.equal('customhost')
42-
expect(vars.httpGitHost).to.equal('customhost')
43-
expect(vars.gitPrefixes).to.deep.equal(['git@customhost:', 'ssh://git@customhost/', 'https://customhost/'])
37+
it('rejects invalid HEROKU_HOST values for security', () => {
38+
// Test that invalid hosts are rejected and fallback to default
39+
process.env.HEROKU_HOST = 'bogus-server.com'
40+
expect(vars.host).to.equal('heroku.com') // Should fallback to default
41+
expect(vars.apiHost).to.equal('api.heroku.com')
42+
expect(vars.apiUrl).to.equal('https://api.heroku.com')
43+
})
44+
45+
it('respects legitimate HEROKU_HOST as url', () => {
46+
// Test with a valid heroku.com subdomain URL
47+
process.env.HEROKU_HOST = 'https://staging.heroku.com'
48+
expect(vars.host).to.equal('https://staging.heroku.com')
49+
expect(vars.apiHost).to.equal('staging.heroku.com')
50+
expect(vars.apiUrl).to.equal('https://staging.heroku.com')
51+
expect(vars.gitHost).to.equal('staging.heroku.com')
52+
expect(vars.httpGitHost).to.equal('staging.heroku.com')
53+
expect(vars.gitPrefixes).to.deep.equal(['git@staging.heroku.com:', 'ssh://git@staging.heroku.com/', 'https://staging.heroku.com/'])
4454
expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com')
4555
})
4656

57+
it('rejects invalid HEROKU_HOST URLs', () => {
58+
// Test that invalid URL hosts are rejected and fallback to default
59+
process.env.HEROKU_HOST = 'https://bogus-server.com'
60+
expect(vars.host).to.equal('heroku.com') // Should fallback to default for security
61+
expect(vars.apiHost).to.equal('api.heroku.com')
62+
expect(vars.apiUrl).to.equal('https://api.heroku.com')
63+
})
64+
4765
it('respects HEROKU_PARTICLEBOARD_URL', () => {
4866
process.env.HEROKU_PARTICLEBOARD_URL = 'https://customhost'
4967
expect(vars.particleboardUrl).to.equal('https://customhost')

0 commit comments

Comments
 (0)