Skip to content

Bypass few common security attacks to Donut Server #146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ NODE_ENV="development"
JWT_SECRET="thisismysupersecrettokenjustkidding"
DATABASE_URL="mongodb://mongo:27017/donut-development"
SENDGRID_API_KEY='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'
SOCKET_PORT=8810
SOCKET_PORT=8810
REDIS_HOST="redis"
REDIS_PORT=6379
REDIS_PASSWORD="auth"
REDIS_DB=0
Comment on lines +6 to +10
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added Redis connection credentials here


24 changes: 24 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const cookieParser = require('cookie-parser')
const createError = require('http-errors')
const path = require('path')
const socket = require('socket.io')
const helmet = require('helmet')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using helmet to add few security response headers as it has got standard implementations and widely used in express apps


const indexRouter = require('./app/routes/index')
const authRouter = require('./app/routes/auth')
Expand Down Expand Up @@ -40,10 +41,33 @@ app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())
app.use(express.static(path.join(__dirname, 'public')))
app.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Expires', 0)
Comment on lines +44 to +45
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does not allow browsers to cache Donut server responses, so using will help to bypass attacks such as CSRF.
Anyways we will be having a global cache server such as Redis which we can implement in subsequent PRs.

Copy link
Member

Choose a reason for hiding this comment

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

For csrf attack you can use this, https://www.npmjs.com/package/csrf

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Rupeshiya I will work on csrf there are a couple of things to do 😅 will add them in my next or

Copy link
Member

Choose a reason for hiding this comment

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

Yeah sure!

req.io = io
next()
})

// setting headers for security
app.disable('x-powered-by')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hide from attackers on what tech stack does the Donut server runs on.
This will. avoid framework-specific attacks.

Copy link
Member

Choose a reason for hiding this comment

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

@kmehant I think it will only disable express.js to come into view of attackers

app.use(helmet.xssFilter())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Bypasses reflected XSS

app.use(helmet.referrerPolicy({ policy: 'same-origin' }))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[Privacy] referrer information is shared only within our server.

app.use(helmet.noSniff())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

avoids Browsers from running unrecognized malicious code.

app.use(helmet.frameguard({ action: 'sameorigin' }))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does not allow Donut Server being used in a frame thereby bypasses clickjacking in browsers.

app.use(helmet.hsts({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To make browsers to keep Donut server users on HTTPS protocol strictly

Copy link
Member

Choose a reason for hiding this comment

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

Also will this work without SSL ??

maxAge: 5184000,
includeSubDomains: true
}))
app.use(helmet.dnsPrefetchControl())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[Privacy] Does not allow browsers from preloading the Donut server even before user routes to it.

app.use(helmet.contentSecurityPolicy({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Response header to restrict Browsers in allowing contents only from trusted sources.

directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"]
// other sources have to mentioned here
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Rupeshiya We might need to add more external sources which are being used in Donut Server if any

},
browserSniff: false
}))
app.use(helmet.ieNoOpen())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Specifically for IE,
to restrain from executing downloads in our site’s context.


app.use('/notification', notificationRouter)
app.use('/', indexRouter)
app.use('/auth', authRouter)
Expand Down
27 changes: 23 additions & 4 deletions app/controllers/auth.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
const User = require('../models/User')
const HttpStatus = require('http-status-codes')
const rateLimiter = require('../utils/rateLimit')
module.exports = {
authenticateUser: async (req, res, next) => {
const email = req.body.email
const password = req.body.password
try {
const user = await User.findByCredentials(email, password)
const token = await user.generateAuthToken()
res.send({ user: user, token: token })
const _state = await rateLimiter.rateLimit(email, false)
if (_state === 'admit') {
const user = await User.findByCredentials(email, password)
const token = await user.generateAuthToken()
await rateLimiter.rateLimit(email, true)
res.send({ user: user, token: token })
} else {
res.status(HttpStatus.TOO_MANY_REQUESTS).json({ error: _state })
if (process.env.NODE_ENV !== 'production') {
console.log('Error', '-', _state)
}
}
await rateLimiter.rateLimit(email, true)
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.log(error.name, '-', error.message)
}
res.status(HttpStatus.BAD_REQUEST).json({ error: error.message })
const _state = await rateLimiter.rateLimit(email, false)
if (_state === 'admit') {
res.status(HttpStatus.BAD_REQUEST).json({ error: error.message })
} else {
res.status(HttpStatus.TOO_MANY_REQUESTS).json({ error: _state })
if (process.env.NODE_ENV !== 'production') {
console.log('Error', '-', _state)
}
}
}
},
logout: (req, res, next) => {
Expand Down
61 changes: 61 additions & 0 deletions app/utils/rateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Util for limiting the number of login retries on using email and password

const Redis = require('../../config/redis')
const { RateLimiterRedis } = require('rate-limiter-flexible')

const redisClient = Redis.redisClient

const loginLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login',
points: 5,
duration: 60 * 30 // 30 minutes wait time
})
Comment on lines +7 to +12
Copy link
Contributor Author

Choose a reason for hiding this comment

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

config for rate limiting

duration is the amount of delay after the allowed number of retries
points is number of retries allowed before starting the delay

Copy link
Member

Choose a reason for hiding this comment

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

@kmehant You are rate-limiting only for the login endpoints so DOS attack is still possible by a logged-in user.
Let say I am a logged in user and now I will write a script something like this:

while(1) {
    setInterval(() => { // hit endpoint to send GET/POST request to the server }, 1000);
}

This will hit the server after every 1second and due to infinite hit server will go down.

Please take it into consideration.
cc @vaibhavdaren

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Rupeshiya Yes, this point is noted ✌🏻.
I will create another issue and add these points there and will work on another PR as this change is especially required for login endpoint 😅 to create time block delays.
But the present work is to create restrict unsuccessful login attempts that come under rate-limiting but has a special extension of using block time intervals. (was the reason for not writing this as a middleware rather an extension to login controller)

So in our next pr, I will simply write a middleware that will keep track of the number of requests (immediate) and IP address of the Client, and then we will use a quota system where we will block the user from accessing the service. (this truly comes under rate-limiting 😅 and not the present work, sorry if I have used the word rate-limiting anywhere in the context for the present work.)

Copy link
Member

Choose a reason for hiding this comment

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

Yes!!
Great go ahead @kmehant !!


const limiterConsecutiveOutOfLimits = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_consecutive_outoflimits',
points: 99999,
duration: 0
})
Comment on lines +14 to +19
Copy link
Contributor Author

Choose a reason for hiding this comment

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

config for repeating the process of being out of retries


function getFibonacciBlockDurationMinutes (countConsecutiveOutOfLimits) {
if (countConsecutiveOutOfLimits <= 1) {
return 1
}

return getFibonacciBlockDurationMinutes(countConsecutiveOutOfLimits - 1) + getFibonacciBlockDurationMinutes(countConsecutiveOutOfLimits - 2)
}

module.exports = {
// restrict brute for attacks on login
rateLimit: async (email, isLoggedIn) => {
const redisUserID = await loginLimiter.get(email)
let retrySecs = 0
if (isLoggedIn) {
await limiterConsecutiveOutOfLimits.delete(email)
return ('reset done')
}
if (redisUserID !== null && redisUserID.remainingPoints <= 0) {
retrySecs = Math.round(redisUserID.msBeforeNext / 1000) || 1
}
if (retrySecs > 0) {
return ('Retry after: '.concat(String(retrySecs)))
} else {
try {
const resConsume = await loginLimiter.consume(email)
if (resConsume.remainingPoints <= 0) {
const resPenalty = await limiterConsecutiveOutOfLimits.penalty(email)
await loginLimiter.block(email, 60 * getFibonacciBlockDurationMinutes(resPenalty.consumedPoints))
} else {
return ('admit')
}
} catch (rlRejected) {
if (rlRejected instanceof Error) {
throw rlRejected
} else {
return ('Too many tries, Retry after'.concat(String(Math.round(rlRejected.msBeforeNext / 1000)) || 1))
}
}
}
}
}
10 changes: 10 additions & 0 deletions config/redis.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const Redis = require('ioredis')
const redisClient = new Redis({
port: process.env.REDIS_PORT,
host: process.env.REDIS_HOST,
family: 4,
password: process.env.REDIS_PASSWORD,
db: process.env.REDIS_DB
})

exports.redisClient = redisClient
11 changes: 10 additions & 1 deletion docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ services:
server:
container_name: server
restart: always
expose:
expose:
- "5000"
build:
context: .
Expand All @@ -23,5 +23,14 @@ services:
- db-data:/data/db
ports:
- "27017:27017"
redis:
container_name: redis
image: redis:5-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --requirepass auth
Comment on lines +26 to +33
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added Redis to the container setup

volumes:
db-data:
redis-data:
15 changes: 14 additions & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ services:
server:
container_name: server-prod
restart: always
expose:
expose:
- "5000"
build:
context: .
Expand All @@ -19,12 +19,25 @@ services:
- DATABASE_URL="mongodb://mongo:27017/donut-development"
- SENDGRID_API_KEY='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'
- SOCKET_PORT=8810
- REDIS_HOST="redis"
- REDIS_PORT=6379
- REDIS_PASSWORD="auth"
- REDIS_DB=0
mongo:
container_name: mongo
image: mongo
volumes:
- db-data:/data/db
ports:
- "27017:27017"
redis:
container_name: redis
image: redis:5-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --requirepass auth
volumes:
db-data:
redis-data:
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
"dotenv": "^8.2.0",
"ejs": "~2.6.1",
"express": "^4.16.4",
"helmet": "^3.23.3",
"http-status-codes": "^1.4.0",
"ioredis": "^4.17.3",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.7.7",
"morgan": "^1.9.1",
"multer": "^1.4.2",
"rate-limiter-flexible": "^2.1.9",
"socket.io": "^2.3.0",
"validator": "^10.11.0"
},
Expand Down