Skip to content

Commit 705b25f

Browse files
committed
feat: check for malicious link just before redirect
1 parent 948c2c8 commit 705b25f

File tree

24 files changed

+657
-75
lines changed

24 files changed

+657
-75
lines changed

src/server/modules/api/admin-v1/__tests__/AdminApiV1Controller.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const urlManagementService = {
1212
changeOwnership: jest.fn(),
1313
getUrlsWithConditions: jest.fn(),
1414
bulkCreate: jest.fn(),
15+
deactivateMaliciousShortUrl: jest.fn(),
1516
}
1617

1718
const userRepository = {

src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const urlManagementService = {
1616
changeOwnership: jest.fn(),
1717
getUrlsWithConditions: jest.fn(),
1818
bulkCreate: jest.fn(),
19+
deactivateMaliciousShortUrl: jest.fn(),
1920
}
2021

2122
const urlV1Mapper = new UrlV1Mapper()

src/server/modules/auth/__tests__/LoginController.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ describe('LoginController', () => {
115115
const initMailer = jest.fn()
116116
const mailJobFailure = jest.fn()
117117
const mailJobSuccess = jest.fn()
118+
const mailDeactivatedMaliciousShortUrl = jest.fn()
118119

119120
const deleteOtpByEmail = jest.fn()
120121
const setOtpForEmail = jest.fn()
@@ -123,7 +124,13 @@ describe('LoginController', () => {
123124
const urlMapper = new UrlMapper()
124125
const authService = new AuthService(
125126
{ hash, compare },
126-
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
127+
{
128+
mailOTP,
129+
initMailer,
130+
mailJobFailure,
131+
mailJobSuccess,
132+
mailDeactivatedMaliciousShortUrl,
133+
},
127134
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
128135
new UserRepository(new UserMapper(urlMapper), urlMapper),
129136
)
@@ -198,6 +205,7 @@ describe('LoginController', () => {
198205
const initMailer = jest.fn()
199206
const mailJobFailure = jest.fn()
200207
const mailJobSuccess = jest.fn()
208+
const mailDeactivatedMaliciousShortUrl = jest.fn()
201209

202210
const deleteOtpByEmail = jest.fn()
203211
const setOtpForEmail = jest.fn()
@@ -216,7 +224,13 @@ describe('LoginController', () => {
216224

217225
const authService = new AuthService(
218226
{ hash, compare },
219-
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
227+
{
228+
mailOTP,
229+
initMailer,
230+
mailJobFailure,
231+
mailJobSuccess,
232+
mailDeactivatedMaliciousShortUrl,
233+
},
220234
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
221235
userRepository,
222236
)

src/server/modules/bulk/__tests__/BulkController.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const mockUrlManagementService = {
1515
changeOwnership: jest.fn(),
1616
getUrlsWithConditions: jest.fn(),
1717
bulkCreate: jest.fn(),
18+
deactivateMaliciousShortUrl: jest.fn(),
1819
}
1920

2021
const controller = new BulkController(mockBulkService, mockUrlManagementService)

src/server/modules/job/services/__tests__/JobManagementService.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const mockMailer = {
4040
mailOTP: jest.fn(),
4141
mailJobFailure: jest.fn(),
4242
mailJobSuccess: jest.fn(),
43+
mailDeactivatedMaliciousShortUrl: jest.fn(),
4344
}
4445

4546
const service = new JobManagementService(

src/server/modules/redirect/__tests__/RedirectController.test.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,25 @@ import httpMocks from 'node-mocks-http'
33
import redisMock from 'redis-mock'
44
import SequelizeMock from 'sequelize-mock'
55

6+
import { DependencyIds } from '../../../constants'
67
import { ACTIVE } from '../../../models/types'
78
import { UrlRepositoryInterface } from '../../../repositories/interfaces/UrlRepositoryInterface'
89
import { container } from '../../../util/inversify'
9-
import { DependencyIds } from '../../../constants'
1010
import { generateCookie } from '../ga'
1111

12+
import { RedirectController } from '..'
13+
import { logger } from '../../../config'
14+
import { UrlMapper } from '../../../mappers/UrlMapper'
15+
import { LinkStatisticsService } from '../../analytics/interfaces'
16+
import { UrlThreatScanService } from '../../threat/interfaces'
17+
import { UrlManagementService as UrlManagementServiceInterface } from '../../user/interfaces'
1218
import {
1319
AnalyticsLoggerService,
1420
CookieArrayReducerService,
1521
CrawlerCheckService,
1622
RedirectService,
1723
} from '../services'
18-
import { RedirectController } from '..'
19-
import { logger } from '../../../config'
20-
import { UrlMapper } from '../../../mappers/UrlMapper'
21-
import { LinkStatisticsService } from '../../analytics/interfaces'
24+
import { RedirectDestination } from '../../../repositories/types'
2225

2326
const redisMockClient = redisMock.createClient()
2427
const sequelizeMock = new SequelizeMock()
@@ -30,6 +33,9 @@ const urlModelMock = sequelizeMock.define(
3033
longUrl: 'aa',
3134
state: ACTIVE,
3235
clicks: 8,
36+
UrlClicks: {
37+
clicks: 8,
38+
},
3339
},
3440
{
3541
instanceMethods: {
@@ -107,6 +113,16 @@ const devicesModelMock = sequelizeMock.define(
107113
},
108114
)
109115

116+
const userModelMock = sequelizeMock.define('user', {
117+
id: 1,
118+
119+
})
120+
121+
const urlClicksModelMock = sequelizeMock.define('url_clicks', {
122+
shortUrl: 'a',
123+
clicks: 0,
124+
})
125+
110126
const mockTransaction = sequelizeMock.transaction
111127

112128
jest.resetModules()
@@ -127,6 +143,14 @@ jest.mock('../../../models/statistics/devices', () => ({
127143
Devices: devicesModelMock,
128144
}))
129145

146+
jest.mock('../../../models/statistics/clicks', () => ({
147+
UrlClicks: urlClicksModelMock,
148+
}))
149+
150+
jest.mock('../../../models/user', () => ({
151+
User: userModelMock,
152+
}))
153+
130154
jest.mock('../../../redis', () => ({
131155
redirectClient: redisMockClient,
132156
}))
@@ -258,6 +282,22 @@ describe('redirect API tests', () => {
258282
container
259283
.bind<LinkStatisticsService>(DependencyIds.linkStatisticsService)
260284
.toConstantValue(linkStatisticsServiceMock)
285+
container
286+
.bind<UrlThreatScanService>(DependencyIds.urlThreatScanService)
287+
.toConstantValue({
288+
isThreat: jest.fn().mockResolvedValue(false),
289+
isThreatBulk: jest.fn().mockResolvedValue([]),
290+
})
291+
container
292+
.bind<UrlManagementServiceInterface>(DependencyIds.urlManagementService)
293+
.toConstantValue({
294+
deactivateMaliciousShortUrl: jest.fn(),
295+
bulkCreate: jest.fn(),
296+
createUrl: jest.fn(),
297+
updateUrl: jest.fn(),
298+
changeOwnership: jest.fn(),
299+
getUrlsWithConditions: jest.fn(),
300+
})
261301
redisMockClient.flushall()
262302
})
263303
afterEach(() => {
@@ -480,8 +520,13 @@ describe('redirect API tests', () => {
480520
test('url does exists in cache but not db', async () => {
481521
const req = createRequestWithShortUrl('Aaa')
482522
const res = httpMocks.createResponse()
523+
const redirectDestination: RedirectDestination = {
524+
longUrl: 'aa',
525+
isFile: false,
526+
safeBrowsingExpiry: new Date(Date.now() + 1000).toISOString(),
527+
}
483528

484-
redisMockClient.set('aaa', 'aa')
529+
redisMockClient.set('aaa', JSON.stringify(redirectDestination))
485530
mockDbEmpty()
486531

487532
await container
@@ -495,9 +540,14 @@ describe('redirect API tests', () => {
495540
test('url in cache and db is down', async () => {
496541
const req = createRequestWithShortUrl('Aaa')
497542
const res = httpMocks.createResponse()
543+
const redirectDestination: RedirectDestination = {
544+
longUrl: 'aa',
545+
isFile: false,
546+
safeBrowsingExpiry: new Date(Date.now() + 1000).toISOString(),
547+
}
498548

499549
mockDbDown()
500-
redisMockClient.set('aaa', 'aa')
550+
redisMockClient.set('aaa', JSON.stringify(redirectDestination))
501551

502552
await container
503553
.get<RedirectController>(DependencyIds.redirectController)

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { RedirectResult, RedirectType } from '..'
66
import { LinkStatisticsService } from '../../analytics/interfaces'
77
import { logger, ogUrl } from '../../../config'
88
import { CookieArrayReducerService, CrawlerCheckService } from '.'
9+
import { UrlThreatScanService } from '../../threat/interfaces'
10+
import { getSafeBrowsingExpiryDate } from '../../../util/safeBrowsing'
11+
import { UrlManagementService } from '../../user/interfaces'
912

1013
@injectable()
1114
export class RedirectService {
@@ -17,6 +20,10 @@ export class RedirectService {
1720

1821
private linkStatisticsService: LinkStatisticsService
1922

23+
private urlThreatScanService: UrlThreatScanService
24+
25+
private urlManagementService: UrlManagementService
26+
2027
public constructor(
2128
@inject(DependencyIds.urlRepository) urlRepository: UrlRepositoryInterface,
2229
@inject(DependencyIds.crawlerCheckService)
@@ -25,11 +32,17 @@ export class RedirectService {
2532
cookieArrayReducerService: CookieArrayReducerService,
2633
@inject(DependencyIds.linkStatisticsService)
2734
linkStatisticsService: LinkStatisticsService,
35+
@inject(DependencyIds.urlThreatScanService)
36+
urlThreatScanService: UrlThreatScanService,
37+
@inject(DependencyIds.urlManagementService)
38+
urlManagementService: UrlManagementService,
2839
) {
2940
this.urlRepository = urlRepository
3041
this.crawlerCheckService = crawlerCheckService
3142
this.cookieArrayReducerService = cookieArrayReducerService
3243
this.linkStatisticsService = linkStatisticsService
44+
this.urlThreatScanService = urlThreatScanService
45+
this.urlManagementService = urlManagementService
3346
}
3447

3548
public redirectFor: (
@@ -51,7 +64,34 @@ export class RedirectService {
5164
const shortUrl = rawShortUrl.toLowerCase()
5265

5366
// Find longUrl to redirect to
54-
const longUrl = await this.urlRepository.getLongUrl(shortUrl)
67+
const { longUrl, isFile, safeBrowsingExpiry } =
68+
await this.urlRepository.getLongUrl(shortUrl)
69+
70+
// Validate that the longUrl is not a malicious link
71+
const isSafeBrowsingResultExpired =
72+
!isFile &&
73+
(!safeBrowsingExpiry ||
74+
new Date(safeBrowsingExpiry).getTime() < Date.now())
75+
76+
if (isSafeBrowsingResultExpired) {
77+
const isThreat = await this.urlThreatScanService.isThreat(longUrl)
78+
if (isThreat) {
79+
logger.warn(
80+
`Malicious link attempt: ${longUrl} was detected as malicious for shortUrl ${shortUrl}`,
81+
)
82+
83+
// Deactivate the short link and warn the short link owner
84+
await this.urlManagementService.deactivateMaliciousShortUrl(shortUrl)
85+
86+
// NOTE: We return a 404 error here to make the user experience the same
87+
// as if the short link was deactivated/not found for simplicity and to
88+
// avoid inducing user panic.
89+
throw new NotFoundError('Malicious link detected')
90+
}
91+
// Store the result of the threat scan in the database
92+
const expiry = getSafeBrowsingExpiryDate({ longUrl })
93+
await this.urlRepository.updateSafeBrowsingExpiry(shortUrl, expiry)
94+
}
5595

5696
// Update clicks and click statistics in database.
5797
try {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { RedirectService } from '..'
2+
import { NotFoundError } from '../../../../util/error'
3+
4+
const mockUrlRepository = {
5+
getLongUrl: jest.fn(),
6+
updateSafeBrowsingExpiry: jest.fn(),
7+
deactivateShortUrl: jest.fn(),
8+
findByShortUrlWithTotalClicks: jest.fn(),
9+
update: jest.fn(),
10+
create: jest.fn(),
11+
isShortUrlAvailable: jest.fn(),
12+
rawDirectorySearch: jest.fn(),
13+
bulkCreate: jest.fn(),
14+
}
15+
const mockCrawlerCheckService = { isCrawler: jest.fn() }
16+
const mockCookieArrayReducerService = {
17+
userHasVisitedShortlink: jest.fn(),
18+
writeShortlinkToCookie: jest.fn(),
19+
}
20+
const mockLinkStatisticsService = {
21+
updateLinkStatistics: jest.fn(),
22+
getLinkStatistics: jest.fn(),
23+
}
24+
const mockUrlThreatScanService = {
25+
isThreat: jest.fn(),
26+
isThreatBulk: jest.fn(),
27+
}
28+
const mockUrlManagementService = {
29+
bulkCreate: jest.fn(),
30+
createUrl: jest.fn(),
31+
updateUrl: jest.fn(),
32+
changeOwnership: jest.fn(),
33+
getUrlsWithConditions: jest.fn(),
34+
deactivateMaliciousShortUrl: jest.fn(),
35+
}
36+
37+
const service = new RedirectService(
38+
mockUrlRepository,
39+
mockCrawlerCheckService,
40+
mockCookieArrayReducerService,
41+
mockLinkStatisticsService,
42+
mockUrlThreatScanService,
43+
mockUrlManagementService,
44+
)
45+
46+
describe('RedirectService', () => {
47+
afterAll(jest.resetModules)
48+
49+
describe('redirectFor', () => {
50+
it.skip('should throw NotFoundError for invalid shortUrl', () => {})
51+
it.skip('should throw NotFoundError for non-existent shortUrl', () => {})
52+
53+
it('should throw NotFoundError if the longUrl is malicious and the safe browsing result is expired', async () => {
54+
// Arrange
55+
mockUrlRepository.getLongUrl.mockResolvedValue({
56+
longUrl: 'https://malicious.com',
57+
isFile: false,
58+
safeBrowsingExpiry: new Date(Date.now() - 1000).toISOString(),
59+
})
60+
mockUrlThreatScanService.isThreat.mockResolvedValue(true)
61+
62+
// Act & Assert
63+
await expect(
64+
service.redirectFor('shortUrl', undefined, '', ''),
65+
).rejects.toThrowError(NotFoundError)
66+
})
67+
68+
it('should not throw NotFoundError if the longUrl is malicious but the safe browsing result is not expired', async () => {
69+
// Arrange
70+
mockUrlRepository.getLongUrl.mockResolvedValue({
71+
longUrl: 'https://malicious.com',
72+
isFile: false,
73+
safeBrowsingExpiry: new Date(Date.now() + 1000).toISOString(),
74+
})
75+
mockUrlThreatScanService.isThreat.mockResolvedValue(true)
76+
77+
// Act & Assert
78+
await expect(
79+
service.redirectFor('shortUrl', undefined, '', ''),
80+
).resolves.not.toThrowError(NotFoundError)
81+
})
82+
83+
it('should update the safe browsing expiry if the longUrl is not malicious', async () => {
84+
// Arrange
85+
const mockShortUrl = 'short'
86+
const mockLongUrl = 'https://safe.com'
87+
mockUrlRepository.getLongUrl.mockResolvedValue({
88+
longUrl: mockLongUrl,
89+
isFile: false,
90+
safeBrowsingExpiry: new Date(Date.now() - 1000).toISOString(),
91+
})
92+
mockUrlThreatScanService.isThreat.mockResolvedValue(false)
93+
94+
// Act
95+
await service.redirectFor(mockShortUrl, undefined, '', '')
96+
97+
// Assert
98+
expect(mockUrlRepository.updateSafeBrowsingExpiry).toHaveBeenCalledWith(
99+
mockShortUrl,
100+
expect.any(Date),
101+
)
102+
})
103+
})
104+
})

src/server/modules/user/__tests__/UserController.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const urlManagementService = {
2323
changeOwnership: jest.fn(),
2424
getUrlsWithConditions: jest.fn(),
2525
bulkCreate: jest.fn(),
26+
deactivateMaliciousShortUrl: jest.fn(),
2627
}
2728

2829
const tagManagementService = {

0 commit comments

Comments
 (0)