Skip to content

Commit 2e3dfde

Browse files
christopherholland-workdaychristopherholland-workday
andauthored
Enforce https in URL's used by customers (#5728)
* Enforce HTTPS in links used by customers * Enforce HTTPS in links used by customers * Enforce https in URL's used by customers * Enforce https in URL's used by customers --------- Co-authored-by: christopherholland-workday <christopher.holland+evisort@workday.com>
1 parent 75ce181 commit 2e3dfde

4 files changed

Lines changed: 198 additions & 9 deletions

File tree

packages/server/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ module.exports = {
1313
},
1414

1515
// Regular expression to find test files
16-
testRegex: '((\\.|/)index\\.test)\\.tsx?$',
16+
testRegex: '.*\\.test\\.tsx?$',
1717

1818
// File extensions to recognize in module resolution
1919
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],

packages/server/src/enterprise/services/account.service.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { destroyAllSessionsForUser } from '../middleware/passport/SessionPersist
2020
import { compareHash, getHash, getPasswordSaltRounds, hashNeedsUpgrade } from '../utils/encryption.util'
2121
import { sendPasswordResetEmail, sendVerificationEmailForCloud, sendWorkspaceAdd, sendWorkspaceInvite } from '../utils/sendEmail'
2222
import { generateTempToken } from '../utils/tempTokenUtils'
23+
import { getSecureAppUrl, getSecureTokenLink } from '../utils/url.util'
2324
import { validatePasswordOrThrow } from '../utils/validation.util'
2425
import auditService from './audit'
2526
import { OrganizationUserErrorMessage, OrganizationUserService } from './organization-user.service'
@@ -98,7 +99,7 @@ export class AccountService {
9899
await queryRunner.manager.save(User, updatedUser)
99100

100101
// resend invite
101-
const verificationLink = `${process.env.APP_URL}/verify?token=${updateUserData.tempToken}`
102+
const verificationLink = getSecureTokenLink('/verify', updateUserData.tempToken!)
102103
await sendVerificationEmailForCloud(email, verificationLink)
103104

104105
await queryRunner.commitTransaction()
@@ -173,7 +174,7 @@ export class AccountService {
173174
}
174175
// send verification email only if user signed up with email/password
175176
if (data.user.credential) {
176-
const verificationLink = `${process.env.APP_URL}/verify?token=${data.user.tempToken}`
177+
const verificationLink = getSecureTokenLink('/verify', data.user.tempToken!)
177178
await sendVerificationEmailForCloud(data.user.email!, verificationLink)
178179
}
179180
break
@@ -314,8 +315,8 @@ export class AccountService {
314315
// send invite
315316
const registerLink =
316317
this.identityManager.getPlatformType() === Platform.ENTERPRISE
317-
? `${process.env.APP_URL}/register?token=${data.user.tempToken}`
318-
: `${process.env.APP_URL}/register`
318+
? getSecureTokenLink('/register', data.user.tempToken!)
319+
: getSecureAppUrl('/register')
319320
await sendWorkspaceInvite(data.user.email!, data.workspace.name!, registerLink, this.identityManager.getPlatformType())
320321
data.user = await this.userService.createNewUser(data.user, queryRunner)
321322

@@ -383,9 +384,9 @@ export class AccountService {
383384
tokenExpiry.setHours(tokenExpiry.getHours() + expiryInHours)
384385
data.user.tokenExpiry = tokenExpiry
385386
await this.userService.saveUser(data.user, queryRunner)
386-
registerLink = `${process.env.APP_URL}/register?token=${data.user.tempToken}`
387+
registerLink = getSecureTokenLink('/register', data.user.tempToken!)
387388
} else {
388-
registerLink = `${process.env.APP_URL}/register`
389+
registerLink = getSecureAppUrl('/register')
389390
}
390391
if (workspaceUser.length === 1) {
391392
oldWorkspaceUser = workspaceUser[0]
@@ -411,7 +412,7 @@ export class AccountService {
411412
} else {
412413
data.organizationUser.updatedBy = data.user.createdBy
413414

414-
const dashboardLink = `${process.env.APP_URL}`
415+
const dashboardLink = getSecureAppUrl()
415416
await sendWorkspaceAdd(data.user.email!, data.workspace.name!, dashboardLink)
416417
}
417418

@@ -549,7 +550,7 @@ export class AccountService {
549550
tokenExpiry.setMinutes(tokenExpiry.getMinutes() + expiryInMins)
550551
data.user.tokenExpiry = tokenExpiry
551552
data.user = await this.userService.saveUser(data.user, queryRunner)
552-
const resetLink = `${process.env.APP_URL}/reset-password?token=${data.user.tempToken}`
553+
const resetLink = getSecureTokenLink('/reset-password', data.user.tempToken!)
553554
await sendPasswordResetEmail(data.user.email!, resetLink)
554555
await queryRunner.commitTransaction()
555556
} catch (error) {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import logger from '../../utils/logger'
2+
3+
/**
4+
* Ensures the APP_URL uses HTTPS protocol for security-sensitive operations.
5+
* Allows HTTP only for localhost/127.0.0.1 (development environments).
6+
*
7+
* @param path - Optional path to append to the base URL
8+
* @returns Secure URL (HTTPS) or development URL (HTTP localhost)
9+
*/
10+
export function getSecureAppUrl(path?: string): string {
11+
const appUrl = process.env.APP_URL || ''
12+
13+
if (!appUrl) {
14+
throw new Error('APP_URL environment variable is not configured')
15+
}
16+
17+
// Validate that APP_URL is a well-formed URL (e.g. catches bare "example.com" without a protocol)
18+
let urlObj: URL
19+
try {
20+
urlObj = new URL(appUrl)
21+
} catch {
22+
throw new Error(`APP_URL environment variable is not a valid URL: "${appUrl}"`)
23+
}
24+
25+
const isLocalhost =
26+
urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1' || urlObj.hostname === '[::1]' || urlObj.hostname === '0.0.0.0'
27+
28+
// If URL is HTTP and NOT localhost, convert to HTTPS for security.
29+
// Keep HTTP for localhost/development URLs to avoid issues with self-signed certs.
30+
if (urlObj.protocol === 'http:' && !isLocalhost) {
31+
urlObj.protocol = 'https:'
32+
const newUrlString = urlObj.toString().replace(/\/$/, '')
33+
logger.warn(
34+
`APP_URL uses insecure HTTP protocol for non-localhost URL. ` +
35+
`Automatically converting to HTTPS for security. ` +
36+
`Please update APP_URL to use HTTPS: ${newUrlString}`
37+
)
38+
}
39+
40+
// Always strip trailing slash for consistency, whether or not a path is appended
41+
const secureUrl = urlObj.toString().replace(/\/$/, '')
42+
43+
// Append path if provided
44+
if (path) {
45+
const cleanPath = path.startsWith('/') ? path : `/${path}`
46+
return `${secureUrl}${cleanPath}`
47+
}
48+
49+
return secureUrl
50+
}
51+
52+
/**
53+
* Constructs a secure link with a token parameter.
54+
* Always uses HTTPS for non-localhost URLs.
55+
*
56+
* @param path - URL path (e.g., '/reset-password', '/verify')
57+
* @param token - Security token to include in URL
58+
* @returns Secure URL with token parameter
59+
*/
60+
export function getSecureTokenLink(path: string, token: string): string {
61+
const baseUrl = getSecureAppUrl(path)
62+
return `${baseUrl}?token=${token}`
63+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, expect, it, afterEach } from '@jest/globals'
2+
import { getSecureAppUrl, getSecureTokenLink } from '../../../src/enterprise/utils/url.util'
3+
4+
describe('URL Security Utilities', () => {
5+
const originalEnv = process.env.APP_URL
6+
7+
afterEach(() => {
8+
if (originalEnv) {
9+
process.env.APP_URL = originalEnv
10+
} else {
11+
delete process.env.APP_URL
12+
}
13+
})
14+
15+
describe('getSecureAppUrl', () => {
16+
it('should throw error if APP_URL is not configured', () => {
17+
delete process.env.APP_URL
18+
expect(() => getSecureAppUrl()).toThrow('APP_URL environment variable is not configured')
19+
})
20+
21+
it('should throw error if APP_URL is not a valid URL', () => {
22+
process.env.APP_URL = 'example.com'
23+
expect(() => getSecureAppUrl()).toThrow('APP_URL environment variable is not a valid URL: "example.com"')
24+
})
25+
26+
it('should return HTTPS URL unchanged', () => {
27+
process.env.APP_URL = 'https://example.com'
28+
expect(getSecureAppUrl()).toBe('https://example.com')
29+
})
30+
31+
it('should convert HTTP to HTTPS for production URLs', () => {
32+
process.env.APP_URL = 'http://example.com'
33+
const result = getSecureAppUrl()
34+
expect(result).toBe('https://example.com')
35+
})
36+
37+
it('should allow HTTP for localhost', () => {
38+
process.env.APP_URL = 'http://localhost:3000'
39+
expect(getSecureAppUrl()).toBe('http://localhost:3000')
40+
})
41+
42+
it('should allow HTTP for 127.0.0.1', () => {
43+
process.env.APP_URL = 'http://127.0.0.1:3000'
44+
expect(getSecureAppUrl()).toBe('http://127.0.0.1:3000')
45+
})
46+
47+
it('should allow HTTP for ::1 (IPv6 localhost)', () => {
48+
process.env.APP_URL = 'http://[::1]:3000'
49+
expect(getSecureAppUrl()).toBe('http://[::1]:3000')
50+
})
51+
52+
it('should allow HTTP for 0.0.0.0', () => {
53+
process.env.APP_URL = 'http://0.0.0.0:3000'
54+
expect(getSecureAppUrl()).toBe('http://0.0.0.0:3000')
55+
})
56+
57+
it('should append path correctly', () => {
58+
process.env.APP_URL = 'https://example.com'
59+
expect(getSecureAppUrl('/reset-password')).toBe('https://example.com/reset-password')
60+
})
61+
62+
it('should handle trailing slash in base URL', () => {
63+
process.env.APP_URL = 'https://example.com/'
64+
expect(getSecureAppUrl('/reset-password')).toBe('https://example.com/reset-password')
65+
})
66+
67+
it('should handle path without leading slash', () => {
68+
process.env.APP_URL = 'https://example.com'
69+
expect(getSecureAppUrl('reset-password')).toBe('https://example.com/reset-password')
70+
})
71+
72+
it('should convert HTTP to HTTPS and append path', () => {
73+
process.env.APP_URL = 'http://example.com'
74+
expect(getSecureAppUrl('/verify')).toBe('https://example.com/verify')
75+
})
76+
})
77+
78+
describe('getSecureTokenLink', () => {
79+
it('should create secure link with token', () => {
80+
process.env.APP_URL = 'https://example.com'
81+
const result = getSecureTokenLink('/reset-password', 'abc123')
82+
expect(result).toBe('https://example.com/reset-password?token=abc123')
83+
})
84+
85+
it('should convert HTTP to HTTPS in token link', () => {
86+
process.env.APP_URL = 'http://example.com'
87+
const result = getSecureTokenLink('/reset-password', 'abc123')
88+
expect(result).toBe('https://example.com/reset-password?token=abc123')
89+
})
90+
91+
it('should allow HTTP localhost in token link', () => {
92+
process.env.APP_URL = 'http://localhost:3000'
93+
const result = getSecureTokenLink('/verify', 'xyz789')
94+
expect(result).toBe('http://localhost:3000/verify?token=xyz789')
95+
})
96+
97+
it('should handle complex tokens', () => {
98+
process.env.APP_URL = 'https://example.com'
99+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0'
100+
const result = getSecureTokenLink('/register', token)
101+
expect(result).toBe(`https://example.com/register?token=${token}`)
102+
})
103+
})
104+
105+
describe('Security scenarios', () => {
106+
it('should prevent HTTP password reset links in production', () => {
107+
process.env.APP_URL = 'http://myapp.com'
108+
const resetLink = getSecureTokenLink('/reset-password', 'secret-token')
109+
expect(resetLink).toMatch(/^https:\/\//)
110+
expect(resetLink).not.toMatch(/^http:\/\//)
111+
})
112+
113+
it('should prevent HTTP verification links in production', () => {
114+
process.env.APP_URL = 'http://myapp.com'
115+
const verifyLink = getSecureTokenLink('/verify', 'verify-token')
116+
expect(verifyLink).toMatch(/^https:\/\//)
117+
})
118+
119+
it('should prevent HTTP registration links in production', () => {
120+
process.env.APP_URL = 'http://myapp.com'
121+
const registerLink = getSecureTokenLink('/register', 'invite-token')
122+
expect(registerLink).toMatch(/^https:\/\//)
123+
})
124+
})
125+
})

0 commit comments

Comments
 (0)