Skip to content

Commit 01e0127

Browse files
authored
feat: add domain name validation for outgoing requests (#223)
1 parent 2043bed commit 01e0127

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
@@ -13,6 +13,9 @@ import {ParticleboardClient, IDelinquencyInfo, IDelinquencyConfig} from './parti
1313

1414
const debug = require('debug')
1515

16+
export const ALLOWED_HEROKU_DOMAINS = Object.freeze(['heroku.com', 'herokai.com', 'herokuspace.com', 'herokudev.com'])
17+
export const LOCALHOST_DOMAINS = Object.freeze(['localhost', '127.0.0.1'])
18+
1619
export namespace APIClient {
1720
export interface Options extends HTTPRequestOptions {
1821
retryAuth?: boolean
@@ -174,6 +177,7 @@ export class APIClient {
174177
delinquencyConfig.warning_shown = true
175178
}
176179

180+
// eslint-disable-next-line complexity
177181
static async request<T>(url: string, opts: APIClient.Options = {}, retries = 3): Promise<APIHTTPClient<T>> {
178182
opts.headers = opts.headers || {}
179183
const currentRequestId = RequestId.create() && RequestId.headerValue
@@ -192,7 +196,22 @@ export class APIClient {
192196
}
193197

194198
if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
195-
opts.headers.authorization = `Bearer ${self.auth}`
199+
// Handle both relative and absolute URLs for validation
200+
let targetUrl: URL
201+
try {
202+
// Try absolute URL first
203+
targetUrl = new URL(url)
204+
} catch {
205+
// If that fails, assume it's relative and prepend the API base URL
206+
targetUrl = new URL(url, vars.apiUrl)
207+
}
208+
209+
const isHerokuApi = ALLOWED_HEROKU_DOMAINS.some(domain => targetUrl.hostname.endsWith(`.${domain}`))
210+
const isLocalhost = LOCALHOST_DOMAINS.includes(targetUrl.hostname as (typeof LOCALHOST_DOMAINS)[number])
211+
212+
if (isHerokuApi || isLocalhost) {
213+
opts.headers.authorization = `Bearer ${self.auth}`
214+
}
196215
}
197216

198217
this.configDelinquency(url, opts)

src/vars.ts

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

4+
import {ALLOWED_HEROKU_DOMAINS, LOCALHOST_DOMAINS} from './api-client'
5+
36
export class Vars {
47
get host(): string {
5-
return this.envHost || 'heroku.com'
8+
const {envHost} = this
9+
10+
if (envHost && !this.isValidHerokuHost(envHost)) {
11+
ux.warn(`Invalid HEROKU_HOST '${envHost}' - using default`)
12+
return 'heroku.com'
13+
}
14+
15+
return envHost || 'heroku.com'
616
}
717

818
get apiUrl(): 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
@@ -97,6 +97,16 @@ describe('api_client', () => {
9797
})
9898

9999
describe('with HEROKU_HOST', () => {
100+
test
101+
.it('rejects invalid HEROKU_HOST and uses default API', async ctx => {
102+
process.env.HEROKU_HOST = 'http://bogus-server.com'
103+
api = nock('https://api.heroku.com') // Should fallback to default
104+
api.get('/apps').reply(200, [{name: 'myapp'}])
105+
106+
const cmd = new Command([], ctx.config)
107+
await cmd.heroku.get('/apps')
108+
})
109+
100110
test
101111
.it('makes an HTTP request with HEROKU_HOST', async ctx => {
102112
const localHostURI = 'http://localhost:5000'

test/vars.test.ts

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

31-
it('respects HEROKU_HOST', () => {
32-
process.env.HEROKU_HOST = 'customhost'
33-
expect(vars.apiHost).to.equal('api.customhost')
34-
expect(vars.apiUrl).to.equal('https://api.customhost')
35-
expect(vars.gitHost).to.equal('customhost')
36-
expect(vars.host).to.equal('customhost')
37-
expect(vars.httpGitHost).to.equal('git.customhost')
38-
expect(vars.gitPrefixes).to.deep.equal(['git@customhost:', 'ssh://git@customhost/', 'https://git.customhost/'])
31+
it('respects valid HEROKU_HOST values', () => {
32+
// Test with a valid heroku.com subdomain
33+
process.env.HEROKU_HOST = 'staging.heroku.com'
34+
expect(vars.apiHost).to.equal('api.staging.heroku.com')
35+
expect(vars.apiUrl).to.equal('https://api.staging.heroku.com')
36+
expect(vars.gitHost).to.equal('staging.heroku.com')
37+
expect(vars.host).to.equal('staging.heroku.com')
38+
expect(vars.httpGitHost).to.equal('git.staging.heroku.com')
39+
expect(vars.gitPrefixes).to.deep.equal(['[email protected]:', 'ssh://[email protected]/', 'https://git.staging.heroku.com/'])
3940
expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com')
4041
})
4142

42-
it('respects HEROKU_HOST as url', () => {
43-
process.env.HEROKU_HOST = 'https://customhost'
44-
expect(vars.host).to.equal('https://customhost')
45-
expect(vars.apiHost).to.equal('customhost')
46-
expect(vars.apiUrl).to.equal('https://customhost')
47-
expect(vars.gitHost).to.equal('customhost')
48-
expect(vars.httpGitHost).to.equal('customhost')
49-
expect(vars.gitPrefixes).to.deep.equal(['git@customhost:', 'ssh://git@customhost/', 'https://customhost/'])
43+
it('rejects invalid HEROKU_HOST values for security', () => {
44+
// Test that invalid hosts are rejected and fallback to default
45+
process.env.HEROKU_HOST = 'bogus-server.com'
46+
expect(vars.host).to.equal('heroku.com') // Should fallback to default
47+
expect(vars.apiHost).to.equal('api.heroku.com')
48+
expect(vars.apiUrl).to.equal('https://api.heroku.com')
49+
})
50+
51+
it('respects legitimate HEROKU_HOST as url', () => {
52+
// Test with a valid heroku.com subdomain URL
53+
process.env.HEROKU_HOST = 'https://staging.heroku.com'
54+
expect(vars.host).to.equal('https://staging.heroku.com')
55+
expect(vars.apiHost).to.equal('staging.heroku.com')
56+
expect(vars.apiUrl).to.equal('https://staging.heroku.com')
57+
expect(vars.gitHost).to.equal('staging.heroku.com')
58+
expect(vars.httpGitHost).to.equal('staging.heroku.com')
59+
expect(vars.gitPrefixes).to.deep.equal(['[email protected]:', 'ssh://[email protected]/', 'https://staging.heroku.com/'])
5060
expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com')
5161
})
5262

63+
it('rejects invalid HEROKU_HOST URLs', () => {
64+
// Test that invalid URL hosts are rejected and fallback to default
65+
process.env.HEROKU_HOST = 'https://bogus-server.com'
66+
expect(vars.host).to.equal('heroku.com') // Should fallback to default for security
67+
expect(vars.apiHost).to.equal('api.heroku.com')
68+
expect(vars.apiUrl).to.equal('https://api.heroku.com')
69+
})
70+
5371
it('respects HEROKU_PARTICLEBOARD_URL', () => {
5472
process.env.HEROKU_PARTICLEBOARD_URL = 'https://customhost'
5573
expect(vars.particleboardUrl).to.equal('https://customhost')

0 commit comments

Comments
 (0)