1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-10 19:34:34 +00:00

feat: prevent brute force attack by rate limiting login endpoint

This commit is contained in:
2023-03-28 21:43:10 +05:00
parent c4066d32a0
commit a82cabb001
13 changed files with 286 additions and 16 deletions

View File

@@ -175,6 +175,18 @@ HELMET_COEP=
# }
HELMET_CSP_CONFIG_PATH=./csp.config.json
# To prevent brute force attack on login route we have implemented rate limiter
# Only valid for MODE: server
# Following are configurable env variable rate limiter
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
# After this, access is blocked for 1 day
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
# After this, access is blocked for an hour
# Store number for 90 days since first fail
# Once a successful login is attempted, it resets
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
LOG_FORMAT_MORGAN=

View File

@@ -24,6 +24,9 @@ LDAP_BIND_PASSWORD = <password>
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=[100] default value is 100
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=[10] default value is 10
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node

11
api/package-lock.json generated
View File

@@ -24,6 +24,7 @@
"mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"rate-limiter-flexible": "2.4.1",
"rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11",
@@ -9594,6 +9595,11 @@
"node": ">= 0.6"
}
},
"node_modules/rate-limiter-flexible": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.4.1.tgz",
"integrity": "sha512-dgH4T44TzKVO9CLArNto62hJOwlWJMLUjVVr/ii0uUzZXEXthDNr7/yefW5z/1vvHAfycc1tnuiYyNJ8CTRB3g=="
},
"node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
@@ -18811,6 +18817,11 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
},
"rate-limiter-flexible": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.4.1.tgz",
"integrity": "sha512-dgH4T44TzKVO9CLArNto62hJOwlWJMLUjVVr/ii0uUzZXEXthDNr7/yefW5z/1vvHAfycc1tnuiYyNJ8CTRB3g=="
},
"raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",

View File

@@ -64,6 +64,7 @@
"mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"rate-limiter-flexible": "2.4.1",
"rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11",

View File

@@ -8,8 +8,10 @@ import Client from '../model/Client'
import {
getWebBuildFolder,
generateAuthCode,
getRateLimiters,
AuthProviderType,
LDAPClient
LDAPClient,
secondsToHms
} from '../utils'
import { InfoJWT } from '../types'
import { AuthController } from './auth'
@@ -81,19 +83,98 @@ const login = async (
req: express.Request,
{ username, password }: LoginPayload
) => {
// code for preventing brute force attack
const {
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
} = process.env
const { limiterSlowBruteByIP, limiterConsecutiveFailsByUsernameAndIP } =
getRateLimiters()
const ipAddr = req.ip
const usernameIPkey = getUsernameIPkey(username, ipAddr)
const [resSlowByIP, resUsernameAndIP] = await Promise.all([
limiterSlowBruteByIP.get(ipAddr),
limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
])
let retrySecs = 0
// Check if IP or Username + IP is already blocked
if (
resSlowByIP !== null &&
resSlowByIP.consumedPoints >= Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY)
) {
retrySecs = Math.round(resSlowByIP.msBeforeNext / 1000) || 1
} else if (
resUsernameAndIP !== null &&
resUsernameAndIP.consumedPoints >=
Number(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP)
) {
retrySecs = Math.round(resUsernameAndIP.msBeforeNext / 1000) || 1
}
if (retrySecs > 0) {
throw {
code: 429,
message: `Too Many Requests! Retry after ${secondsToHms(retrySecs)}`
}
}
// Authenticate User
const user = await User.findOne({ username })
if (!user) throw new Error('Username is not found.')
if (
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
user.authProvider === AuthProviderType.LDAP
) {
const ldapClient = await LDAPClient.init()
await ldapClient.verifyUser(username, password)
} else {
const validPass = user.comparePassword(password)
if (!validPass) throw new Error('Invalid password.')
let validPass = false
if (user) {
if (
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
user.authProvider === AuthProviderType.LDAP
) {
const ldapClient = await LDAPClient.init()
validPass = await ldapClient
.verifyUser(username, password)
.catch(() => false)
} else {
validPass = user.comparePassword(password)
}
}
// Consume 1 point from limiters on wrong attempt and block if limits reached
if (!validPass) {
try {
const promises = [limiterSlowBruteByIP.consume(ipAddr)]
if (user) {
// Count failed attempts by Username + IP only for registered users
promises.push(
limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey)
)
}
await Promise.all(promises)
} catch (rlRejected: any) {
if (rlRejected instanceof Error) {
throw rlRejected
} else {
retrySecs = Math.round(rlRejected.msBeforeNext / 1000) || 1
throw {
code: 429,
message: `Too Many Requests! Retry after ${secondsToHms(retrySecs)}`
}
}
}
}
if (!user) throw { code: 401, message: 'Username is not found.' }
if (!validPass) throw { code: 401, message: 'Invalid Password.' }
if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
// Reset on successful authorization
await limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey)
}
req.session.loggedIn = true
@@ -144,6 +225,8 @@ const authorize = async (
return { code }
}
const getUsernameIPkey = (username: string, ip: string) => `${username}_${ip}`
interface LoginPayload {
/**
* Username for user

View File

@@ -47,7 +47,7 @@ describe('web', () => {
})
})
describe('SASLogon/login', () => {
describe.only('SASLogon/login', () => {
let csrfToken: string
beforeAll(async () => {
@@ -63,6 +63,7 @@ describe('web', () => {
it('should respond with successful login', async () => {
await userController.createUser(user)
process.dbInstance = con
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
@@ -82,6 +83,72 @@ describe('web', () => {
})
})
it('should respond with too many requests when attempting with invalid password for a same user 10 times', async () => {
await userController.createUser(user)
process.dbInstance = con
const promises: request.Test[] = []
Array(10)
.fill(0)
.map((_, i) => {
promises.push(
request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: 'invalid-password'
})
)
})
await Promise.all(promises)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(429)
})
it.only('should respond with too many requests when attempting with invalid credentials for different users but with same ip 100 times', async () => {
await userController.createUser(user)
process.dbInstance = con
const promises: request.Test[] = []
Array(100)
.fill(0)
.map((_, i) => {
promises.push(
request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: `user${i}`,
password: 'invalid-password'
})
)
})
await Promise.all(promises)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(429)
})
it('should respond with Bad Request if CSRF Token is not present', async () => {
await userController.createUser(user)

View File

@@ -35,7 +35,11 @@ webRouter.post('/SASLogon/login', desktopRestrict, async (req, res) => {
const response = await controller.login(req, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
if (err instanceof Error) {
res.status(500).send(err.toString())
} else {
res.status(err.code).send(err.message)
}
}
})

View File

@@ -14,5 +14,6 @@ declare namespace NodeJS {
logger: import('@sasjs/utils/logger').Logger
runTimes: import('../../utils').RunTimeType[]
secrets: import('../../model/Configuration').ConfigurationType
dbInstance: import('mongoose').Mongoose
}
}

View File

@@ -3,7 +3,9 @@ import { seedDB } from './seedDB'
export const connectDB = async () => {
try {
await mongoose.connect(process.env.DB_CONNECT as string)
process.dbInstance = await mongoose.connect(
process.env.DB_CONNECT as string
)
} catch (err) {
throw new Error('Unable to connect to DB!')
}

View File

@@ -0,0 +1,26 @@
import { RateLimiterMongo } from 'rate-limiter-flexible'
export const getRateLimiters = () => {
const {
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
} = process.env
const limiterSlowBruteByIP = new RateLimiterMongo({
storeClient: process.dbInstance.connection,
keyPrefix: 'login_fail_ip_per_day',
points: Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY),
duration: 60 * 60 * 24,
blockDuration: 60 * 60 * 24 // Block for 1 day
})
const limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMongo({
storeClient: process.dbInstance.connection,
keyPrefix: 'login_fail_consecutive_username_and_ip',
points: Number(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP),
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
blockDuration: 60 * 60 // Block for 1 hour
})
return { limiterSlowBruteByIP, limiterConsecutiveFailsByUsernameAndIP }
}

View File

@@ -13,6 +13,7 @@ export * from './getAuthorizedRoutes'
export * from './getCertificates'
export * from './getDesktopFields'
export * from './getPreProgramVariables'
export * from './getRateLimiters'
export * from './getRunTimeAndFilePath'
export * from './getServerUrl'
export * from './getTokensFromDB'
@@ -20,10 +21,10 @@ export * from './instantiateLogger'
export * from './isDebugOn'
export * from './isPublicRoute'
export * from './ldapClient'
export * from './zipped'
export * from './parseLogToArray'
export * from './removeTokensInDB'
export * from './saveTokensInDB'
export * from './secondsToHms'
export * from './seedDB'
export * from './setProcessVariables'
export * from './setupFolders'
@@ -32,3 +33,4 @@ export * from './upload'
export * from './validation'
export * from './verifyEnvVariables'
export * from './verifyTokenInDB'
export * from './zipped'

View File

@@ -0,0 +1,10 @@
export const secondsToHms = (seconds: number) => {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor((seconds % 3600) % 60)
const hDisplay = h > 0 ? h + (h == 1 ? ' hour, ' : ' hours, ') : ''
const mDisplay = m > 0 ? m + (m == 1 ? ' minute, ' : ' minutes, ') : ''
const sDisplay = s > 0 ? s + (s == 1 ? ' second' : ' seconds') : ''
return hDisplay + mDisplay + sDisplay
}

View File

@@ -77,6 +77,8 @@ export const verifyEnvVariables = (): ReturnCode => {
errors.push(...verifyDbType())
errors.push(...verifyRateLimiter())
if (errors.length) {
process.logger?.error(
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
@@ -367,6 +369,50 @@ const verifyDbType = () => {
return errors
}
const verifyRateLimiter = () => {
const errors: string[] = []
const {
MODE,
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
} = process.env
if (MODE === ModeType.Server) {
if (MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) {
if (
!isNumeric(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) ||
Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) < 1
) {
errors.push(
`- Invalid value for 'MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY' - Only positive number is acceptable`
)
}
} else {
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY =
DEFAULTS.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
}
if (MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) {
if (
!isNumeric(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) ||
Number(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) < 1
) {
errors.push(
`- Invalid value for 'MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP' - Only positive number is acceptable`
)
}
} else {
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP =
DEFAULTS.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
}
}
return errors
}
const isNumeric = (val: string): boolean => {
return !isNaN(Number(val))
}
const DEFAULTS = {
MODE: ModeType.Desktop,
PROTOCOL: ProtocolType.HTTP,
@@ -374,5 +420,7 @@ const DEFAULTS = {
HELMET_COEP: HelmetCoepType.TRUE,
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
RUN_TIMES: RunTimeType.SAS,
DB_TYPE: DatabaseType.MONGO
DB_TYPE: DatabaseType.MONGO,
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10'
}