Skip to content
19 changes: 18 additions & 1 deletion src/server/modules/redirect/services/RedirectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class RedirectService {
}
}

const isFromTrustedPage = referrer.startsWith(ogUrl)
const isFromTrustedPage = RedirectService.isFromTrustedPage(referrer)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: should use this instead of RedirectService

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually im not super familiar with the framework, but i referenced this from an existing code

if (RedirectService.isValidShortUrl(rawShortUrl)) {
throw new NotFoundError('Invalid Url')
}

private static isValidShortUrl(shortUrl: string): boolean {
return !shortUrl || !/^[a-zA-Z0-9-]+$/.test(shortUrl)
}

curious - what's the issue for this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah oops sorry was reviewing on mobile, so didn't see the static part. Doing RedirectService.isFromTrustedPage is ok since isFromTrustedPage is a static function. If otherwise then should use this.


const renderTransitionPage =
!this.cookieArrayReducerService.userHasVisitedShortlink(
Expand All @@ -90,6 +90,23 @@ export class RedirectService {
}
}

/**
* Checks whether the referrer is from a trusted page (same origin as ogUrl).
* This prevents malicious sites from bypassing the transition page.
* @param {string} referrer - The referrer URL to check.
* @returns {boolean} - True if referrer is from trusted origin, false otherwise.
*/
private static isFromTrustedPage(referrer: string): boolean {
try {
const referrerUrl = new URL(referrer)
const trustedUrl = new URL(ogUrl)
return referrerUrl.origin === trustedUrl.origin
} catch {
// If referrer is not a valid URL, treat as untrusted
return false
}
}

/**
* Checks whether the input short url is valid.
* @param {string} shortUrl
Expand Down
192 changes: 192 additions & 0 deletions src/server/modules/redirect/services/__tests__/RedirectService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { RedirectService } from '../RedirectService'
import { UrlRepositoryInterface } from '../../../../repositories/interfaces/UrlRepositoryInterface'
import { CrawlerCheckService } from '../CrawlerCheckService'
import { CookieArrayReducerService } from '../CookieArrayReducerService'
import { LinkStatisticsService } from '../../../analytics/interfaces'
import { RedirectType } from '../..'

const ogUrl = 'https://go.gov.sg'

// Mock the config module
jest.mock('../../../../config', () => {
return {
logger: {
warn: jest.fn(),
},
ogUrl: 'https://go.gov.sg',
}
})

// Mock dependencies
const mockUrlRepository = {
getLongUrl: jest.fn(),
}

const mockCrawlerCheckService = {
isCrawler: jest.fn(),
}

const mockCookieArrayReducerService = {
userHasVisitedShortlink: jest.fn(),
writeShortlinkToCookie: jest.fn(),
}

const mockLinkStatisticsService = {
updateLinkStatistics: jest.fn(),
}

describe('RedirectService', () => {
// Create service instance with mocked dependencies
const redirectService = new RedirectService(
mockUrlRepository as unknown as UrlRepositoryInterface,
mockCrawlerCheckService as unknown as CrawlerCheckService,
mockCookieArrayReducerService as unknown as CookieArrayReducerService,
mockLinkStatisticsService as unknown as LinkStatisticsService,
)

beforeEach(() => {
jest.clearAllMocks()

// Setup default mock returns after clearing
mockUrlRepository.getLongUrl.mockResolvedValue('https://example.com')
mockCrawlerCheckService.isCrawler.mockReturnValue(false)
mockCookieArrayReducerService.userHasVisitedShortlink.mockReturnValue(false)
mockCookieArrayReducerService.writeShortlinkToCookie.mockReturnValue([
'test',
])
})

describe('redirectFor', () => {
it('should allow direct redirect for exact ogUrl match when user has not visited before', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
ogUrl,
)

expect(result.redirectType).toBe(RedirectType.Direct)
})

it('should allow direct redirect for ogUrl with path when user has not visited before', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
`${ogUrl}/some-path`,
)

expect(result.redirectType).toBe(RedirectType.Direct)
})

it('should allow direct redirect for ogUrl with query params when user has not visited before', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
`${ogUrl}?param=value`,
)

expect(result.redirectType).toBe(RedirectType.Direct)
})

it('should show transition page for ogUrl with different protocol when user has not visited before', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
'http://go.gov.sg', // different protocol
)

expect(result.redirectType).toBe(RedirectType.TransitionPage)
})

it('should NOT allow transition page bypass for malicious domain that starts with ogUrl', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
`${ogUrl}.malicious.com`,
)

expect(result.redirectType).toBe(RedirectType.TransitionPage)
})

it('should NOT allow transition page bypass for subdomain of ogUrl', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
'https://staging.go.gov.sg',
)

expect(result.redirectType).toBe(RedirectType.TransitionPage)
})

it('should NOT allow transition page bypass for domain containing ogUrl', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
'https://staging.go.gov.sg.malicious.com',
)

expect(result.redirectType).toBe(RedirectType.TransitionPage)
})

it('should NOT allow transition page bypass for completely different domain', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
'https://malicious.com',
)

expect(result.redirectType).toBe(RedirectType.TransitionPage)
})

it('should NOT allow transition page bypass for invalid referrer URL', async () => {
const result = await redirectService.redirectFor(
'test',
undefined,
'Mozilla/5.0',
'not-a-valid-url',
)

expect(result.redirectType).toBe(RedirectType.TransitionPage)
})

// TODO: not a regression, but to consider if we want to fix this
it('should allow direct redirect when user has visited the shortlink before, even from malicious site', async () => {
// Mock that user has visited this shortlink before
mockCookieArrayReducerService.userHasVisitedShortlink.mockReturnValue(
true,
)

const result = await redirectService.redirectFor(
'test',
['test'], // past visits include this shortlink
'Mozilla/5.0',
'https://malicious.com', // even from malicious site
)

expect(result.redirectType).toBe(RedirectType.Direct)
})

it('should allow direct redirect when user has visited the shortlink before, even from trusted page', async () => {
// Mock that user has visited this shortlink before
mockCookieArrayReducerService.userHasVisitedShortlink.mockReturnValue(
true,
)

const result = await redirectService.redirectFor(
'test',
['test'], // past visits include this shortlink
'Mozilla/5.0',
ogUrl, // from trusted page
)

expect(result.redirectType).toBe(RedirectType.Direct)
})
})
})
Loading