Skip to content

Commit c83ebf4

Browse files
authored
Merge pull request #2401 from opengovsg/adriangohjw/isom-2008-vapt-insufficient-referrer-validation-bypasses-transition
isom-2008 feat(redirect): enhance checks for bypassing transition page
2 parents 2d94333 + 1684f26 commit c83ebf4

File tree

2 files changed

+210
-1
lines changed

2 files changed

+210
-1
lines changed

src/server/modules/redirect/services/RedirectService.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export class RedirectService {
6868
}
6969
}
7070

71-
const isFromTrustedPage = referrer.startsWith(ogUrl)
71+
const isFromTrustedPage = RedirectService.isFromTrustedPage(referrer)
7272

7373
const renderTransitionPage =
7474
!this.cookieArrayReducerService.userHasVisitedShortlink(
@@ -90,6 +90,23 @@ export class RedirectService {
9090
}
9191
}
9292

93+
/**
94+
* Checks whether the referrer is from a trusted page (same origin as ogUrl).
95+
* This prevents malicious sites from bypassing the transition page.
96+
* @param {string} referrer - The referrer URL to check.
97+
* @returns {boolean} - True if referrer is from trusted origin, false otherwise.
98+
*/
99+
private static isFromTrustedPage(referrer: string): boolean {
100+
try {
101+
const referrerUrl = new URL(referrer)
102+
const trustedUrl = new URL(ogUrl)
103+
return referrerUrl.origin === trustedUrl.origin
104+
} catch {
105+
// If referrer is not a valid URL, treat as untrusted
106+
return false
107+
}
108+
}
109+
93110
/**
94111
* Checks whether the input short url is valid.
95112
* @param {string} shortUrl
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { RedirectService } from '../RedirectService'
2+
import { UrlRepositoryInterface } from '../../../../repositories/interfaces/UrlRepositoryInterface'
3+
import { CrawlerCheckService } from '../CrawlerCheckService'
4+
import { CookieArrayReducerService } from '../CookieArrayReducerService'
5+
import { LinkStatisticsService } from '../../../analytics/interfaces'
6+
import { RedirectType } from '../..'
7+
8+
const ogUrl = 'https://go.gov.sg'
9+
10+
// Mock the config module
11+
jest.mock('../../../../config', () => {
12+
return {
13+
logger: {
14+
warn: jest.fn(),
15+
},
16+
ogUrl: 'https://go.gov.sg',
17+
}
18+
})
19+
20+
// Mock dependencies
21+
const mockUrlRepository = {
22+
getLongUrl: jest.fn(),
23+
}
24+
25+
const mockCrawlerCheckService = {
26+
isCrawler: jest.fn(),
27+
}
28+
29+
const mockCookieArrayReducerService = {
30+
userHasVisitedShortlink: jest.fn(),
31+
writeShortlinkToCookie: jest.fn(),
32+
}
33+
34+
const mockLinkStatisticsService = {
35+
updateLinkStatistics: jest.fn(),
36+
}
37+
38+
describe('RedirectService', () => {
39+
// Create service instance with mocked dependencies
40+
const redirectService = new RedirectService(
41+
mockUrlRepository as unknown as UrlRepositoryInterface,
42+
mockCrawlerCheckService as unknown as CrawlerCheckService,
43+
mockCookieArrayReducerService as unknown as CookieArrayReducerService,
44+
mockLinkStatisticsService as unknown as LinkStatisticsService,
45+
)
46+
47+
beforeEach(() => {
48+
jest.clearAllMocks()
49+
50+
// Setup default mock returns after clearing
51+
mockUrlRepository.getLongUrl.mockResolvedValue('https://example.com')
52+
mockCrawlerCheckService.isCrawler.mockReturnValue(false)
53+
mockCookieArrayReducerService.userHasVisitedShortlink.mockReturnValue(false)
54+
mockCookieArrayReducerService.writeShortlinkToCookie.mockReturnValue([
55+
'test',
56+
])
57+
})
58+
59+
describe('redirectFor', () => {
60+
it('should allow direct redirect for exact ogUrl match when user has not visited before', async () => {
61+
const result = await redirectService.redirectFor(
62+
'test',
63+
undefined,
64+
'Mozilla/5.0',
65+
ogUrl,
66+
)
67+
68+
expect(result.redirectType).toBe(RedirectType.Direct)
69+
})
70+
71+
it('should allow direct redirect for ogUrl with path when user has not visited before', async () => {
72+
const result = await redirectService.redirectFor(
73+
'test',
74+
undefined,
75+
'Mozilla/5.0',
76+
`${ogUrl}/some-path`,
77+
)
78+
79+
expect(result.redirectType).toBe(RedirectType.Direct)
80+
})
81+
82+
it('should allow direct redirect for ogUrl with query params when user has not visited before', async () => {
83+
const result = await redirectService.redirectFor(
84+
'test',
85+
undefined,
86+
'Mozilla/5.0',
87+
`${ogUrl}?param=value`,
88+
)
89+
90+
expect(result.redirectType).toBe(RedirectType.Direct)
91+
})
92+
93+
it('should show transition page for ogUrl with different protocol when user has not visited before', async () => {
94+
const result = await redirectService.redirectFor(
95+
'test',
96+
undefined,
97+
'Mozilla/5.0',
98+
'http://go.gov.sg', // different protocol
99+
)
100+
101+
expect(result.redirectType).toBe(RedirectType.TransitionPage)
102+
})
103+
104+
it('should NOT allow transition page bypass for malicious domain that starts with ogUrl', async () => {
105+
const result = await redirectService.redirectFor(
106+
'test',
107+
undefined,
108+
'Mozilla/5.0',
109+
`${ogUrl}.malicious.com`,
110+
)
111+
112+
expect(result.redirectType).toBe(RedirectType.TransitionPage)
113+
})
114+
115+
it('should NOT allow transition page bypass for subdomain of ogUrl', async () => {
116+
const result = await redirectService.redirectFor(
117+
'test',
118+
undefined,
119+
'Mozilla/5.0',
120+
'https://staging.go.gov.sg',
121+
)
122+
123+
expect(result.redirectType).toBe(RedirectType.TransitionPage)
124+
})
125+
126+
it('should NOT allow transition page bypass for domain containing ogUrl', async () => {
127+
const result = await redirectService.redirectFor(
128+
'test',
129+
undefined,
130+
'Mozilla/5.0',
131+
'https://staging.go.gov.sg.malicious.com',
132+
)
133+
134+
expect(result.redirectType).toBe(RedirectType.TransitionPage)
135+
})
136+
137+
it('should NOT allow transition page bypass for completely different domain', async () => {
138+
const result = await redirectService.redirectFor(
139+
'test',
140+
undefined,
141+
'Mozilla/5.0',
142+
'https://malicious.com',
143+
)
144+
145+
expect(result.redirectType).toBe(RedirectType.TransitionPage)
146+
})
147+
148+
it('should NOT allow transition page bypass for invalid referrer URL', async () => {
149+
const result = await redirectService.redirectFor(
150+
'test',
151+
undefined,
152+
'Mozilla/5.0',
153+
'not-a-valid-url',
154+
)
155+
156+
expect(result.redirectType).toBe(RedirectType.TransitionPage)
157+
})
158+
159+
// TODO: not a regression, but to consider if we want to fix this
160+
it('should allow direct redirect when user has visited the shortlink before, even from malicious site', async () => {
161+
// Mock that user has visited this shortlink before
162+
mockCookieArrayReducerService.userHasVisitedShortlink.mockReturnValue(
163+
true,
164+
)
165+
166+
const result = await redirectService.redirectFor(
167+
'test',
168+
['test'], // past visits include this shortlink
169+
'Mozilla/5.0',
170+
'https://malicious.com', // even from malicious site
171+
)
172+
173+
expect(result.redirectType).toBe(RedirectType.Direct)
174+
})
175+
176+
it('should allow direct redirect when user has visited the shortlink before, even from trusted page', async () => {
177+
// Mock that user has visited this shortlink before
178+
mockCookieArrayReducerService.userHasVisitedShortlink.mockReturnValue(
179+
true,
180+
)
181+
182+
const result = await redirectService.redirectFor(
183+
'test',
184+
['test'], // past visits include this shortlink
185+
'Mozilla/5.0',
186+
ogUrl, // from trusted page
187+
)
188+
189+
expect(result.redirectType).toBe(RedirectType.Direct)
190+
})
191+
})
192+
})

0 commit comments

Comments
 (0)