feat: rate-limiting

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-12-11 17:39:45 +01:00 committed by Philip Molares
parent e8f4cbabec
commit 876ebad1f3
10 changed files with 70 additions and 6 deletions

View file

@ -78,7 +78,7 @@ these are rarely used for various reasons.
## Web security aspects ## Web security aspects
| config file | environment | **default** and example value | description | | config file | environment | **default** and example value | description |
| ----------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------- | ------------------------------ |-------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `hsts` | | `{"enable": true, "maxAgeSeconds": 31536000, "includeSubdomains": true, "preload": true}` | [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) options to use with HTTPS (default is the example value, max age is a year) | | `hsts` | | `{"enable": true, "maxAgeSeconds": 31536000, "includeSubdomains": true, "preload": true}` | [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) options to use with HTTPS (default is the example value, max age is a year) |
| | `CMD_HSTS_ENABLE` | **`true`** or `false` | set to enable [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) if HTTPS is also enabled (default is ` true`) | | | `CMD_HSTS_ENABLE` | **`true`** or `false` | set to enable [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) if HTTPS is also enabled (default is ` true`) |
| | `CMD_HSTS_INCLUDE_SUBDOMAINS` | **`true`** or `false` | set to include subdomains in HSTS (default is `true`) | | | `CMD_HSTS_INCLUDE_SUBDOMAINS` | **`true`** or `false` | set to include subdomains in HSTS (default is `true`) |
@ -95,6 +95,7 @@ these are rarely used for various reasons.
| `csp.allowFraming` | `CMD_CSP_ALLOW_FRAMING` | **`true`** or `false` | Disable to disallow embedding of the instance via iframe. We **strongly recommend disabling** this option, as it increases the attack surface of XSS attacks. | | `csp.allowFraming` | `CMD_CSP_ALLOW_FRAMING` | **`true`** or `false` | Disable to disallow embedding of the instance via iframe. We **strongly recommend disabling** this option, as it increases the attack surface of XSS attacks. |
| `csp.allowPDFEmbed` | `CMD_CSP_ALLOW_PDF_EMBED` | **`true`** or `false` | Disable to disallow embedding PDFs. We recommend disabling this option, as it increases the attack surface of XSS attacks. | | `csp.allowPDFEmbed` | `CMD_CSP_ALLOW_PDF_EMBED` | **`true`** or `false` | Disable to disallow embedding PDFs. We recommend disabling this option, as it increases the attack surface of XSS attacks. |
| `cookiePolicy` | `CMD_COOKIE_POLICY` | **`lax`**, `strict` or `none` | Set a SameSite policy whether cookies are send from cross-origin. Be careful: setting a SameSite value of none without https breaks the editor. | | `cookiePolicy` | `CMD_COOKIE_POLICY` | **`lax`**, `strict` or `none` | Set a SameSite policy whether cookies are send from cross-origin. Be careful: setting a SameSite value of none without https breaks the editor. |
| `rateLimitNewNotes` | `CMD_RATE_LIMIT_NEW_NOTES` | **`20`**, `0` or any positive number | Sets the maximum amount of new note creations per 5-minute window per user. Can be disabled by setting to `0`. |
## Privacy and External Requests ## Privacy and External Requests

View file

@ -29,6 +29,7 @@ module.exports = {
allowFraming: true, allowFraming: true,
allowPDFEmbed: true allowPDFEmbed: true
}, },
rateLimitNewNotes: 20,
cookiePolicy: 'lax', cookiePolicy: 'lax',
protocolUseSSL: false, protocolUseSSL: false,
allowAnonymous: true, allowAnonymous: true,

View file

@ -26,6 +26,7 @@ module.exports = {
allowFraming: toBooleanConfig(process.env.CMD_CSP_ALLOW_FRAMING), allowFraming: toBooleanConfig(process.env.CMD_CSP_ALLOW_FRAMING),
allowPDFEmbed: toBooleanConfig(process.env.CMD_CSP_ALLOW_PDF_EMBED) allowPDFEmbed: toBooleanConfig(process.env.CMD_CSP_ALLOW_PDF_EMBED)
}, },
rateLimitNewNotes: toIntegerConfig(process.env.CMD_RATE_LIMIT_NEW_NOTES),
cookiePolicy: process.env.CMD_COOKIE_POLICY, cookiePolicy: process.env.CMD_COOKIE_POLICY,
protocolUseSSL: toBooleanConfig(process.env.CMD_PROTOCOL_USESSL), protocolUseSSL: toBooleanConfig(process.env.CMD_PROTOCOL_USESSL),
allowOrigin: toArrayConfig(process.env.CMD_ALLOW_ORIGIN), allowOrigin: toArrayConfig(process.env.CMD_ALLOW_ORIGIN),

View file

@ -26,6 +26,9 @@ module.exports = {
errorTooLong: function (res) { errorTooLong: function (res) {
responseError(res, 413, 'Payload Too Large', 'Shorten your note!') responseError(res, 413, 'Payload Too Large', 'Shorten your note!')
}, },
errorTooManyRequests: function (res) {
responseError(res, 429, 'Too Many Requests', 'Try again later.')
},
errorInternalError: function (res) { errorInternalError: function (res) {
responseError(res, 500, 'Internal Error', 'wtf.') responseError(res, 500, 'Internal Error', 'wtf.')
}, },

View file

@ -9,6 +9,7 @@ const models = require('../../../models')
const logger = require('../../../logger') const logger = require('../../../logger')
const { urlencodedParser } = require('../../utils') const { urlencodedParser } = require('../../utils')
const errors = require('../../../errors') const errors = require('../../../errors')
const rateLimit = require('../../middleware/rateLimit')
const emailAuth = module.exports = Router() const emailAuth = module.exports = Router()
@ -37,7 +38,7 @@ passport.use(new LocalStrategy({
})) }))
if (config.allowEmailRegister) { if (config.allowEmailRegister) {
emailAuth.post('/register', urlencodedParser, function (req, res, next) { emailAuth.post('/register', rateLimit.userEndpoints, urlencodedParser, function (req, res, next) {
if (!req.body.email || !req.body.password) return errors.errorBadRequest(res) if (!req.body.email || !req.body.password) return errors.errorBadRequest(res)
if (!validator.isEmail(req.body.email)) return errors.errorBadRequest(res) if (!validator.isEmail(req.body.email)) return errors.errorBadRequest(res)
models.User.findOrCreate({ models.User.findOrCreate({
@ -67,7 +68,7 @@ if (config.allowEmailRegister) {
}) })
} }
emailAuth.post('/login', urlencodedParser, function (req, res, next) { emailAuth.post('/login', rateLimit.userEndpoints, urlencodedParser, function (req, res, next) {
if (!req.body.email || !req.body.password) return errors.errorBadRequest(res) if (!req.body.email || !req.body.password) return errors.errorBadRequest(res)
if (!validator.isEmail(req.body.email)) return errors.errorBadRequest(res) if (!validator.isEmail(req.body.email)) return errors.errorBadRequest(res)
passport.authenticate('local', { passport.authenticate('local', {

View file

@ -0,0 +1,33 @@
'use strict'
const { rateLimit } = require('express-rate-limit')
const errors = require('../../errors')
const config = require('../../config')
const determineKey = (req) => {
if (req.user) {
return req.user.id
}
return req.header('cf-connecting-ip') || req.ip
}
// limits requests to user endpoints (login, signup) to 10 requests per 5 minutes
const userEndpoints = rateLimit({
windowMs: 5 * 60 * 1000,
limit: 10,
keyGenerator: determineKey,
handler: (req, res) => errors.errorTooManyRequests(res)
})
// limits the amount of requests to the new note endpoint per 5 minutes based on configuration
const newNotes = rateLimit({
windowMs: 5 * 60 * 1000,
limit: config.rateLimitNewNotes,
keyGenerator: determineKey,
handler: (req, res) => errors.errorTooManyRequests(res)
})
module.exports = {
userEndpoints,
newNotes
}

View file

@ -7,13 +7,22 @@ const router = module.exports = Router()
const noteController = require('./controller') const noteController = require('./controller')
const slide = require('./slide') const slide = require('./slide')
const rateLimit = require('../middleware/rateLimit')
const config = require('../../config')
const applyRateLimitIfConfigured = (req, res, next) => {
if (config.rateLimitNewNotes > 0) {
return rateLimit.newNotes(req, res, next)
}
next()
}
// get new note // get new note
router.get('/new', noteController.createFromPOST) router.get('/new', applyRateLimitIfConfigured, noteController.createFromPOST)
// post new note with content // post new note with content
router.post('/new', markdownParser, noteController.createFromPOST) router.post('/new', applyRateLimitIfConfigured, markdownParser, noteController.createFromPOST)
// post new note with content and alias // post new note with content and alias
router.post('/new/:noteId', markdownParser, noteController.createFromPOST) router.post('/new/:noteId', applyRateLimitIfConfigured, markdownParser, noteController.createFromPOST)
// get publish note // get publish note
router.get('/s/:shortid', noteController.showPublishNote) router.get('/s/:shortid', noteController.showPublishNote)
// publish note actions // publish note actions

View file

@ -38,6 +38,7 @@
"diff-match-patch": "git+https://github.com/hackmdio/diff-match-patch.git#commit=59a9395ad9fe143e601e7ae5765ed943bdd2b11e", "diff-match-patch": "git+https://github.com/hackmdio/diff-match-patch.git#commit=59a9395ad9fe143e601e7ae5765ed943bdd2b11e",
"ejs": "3.1.10", "ejs": "3.1.10",
"express": "4.21.2", "express": "4.21.2",
"express-rate-limit": "7.4.1",
"express-session": "1.18.1", "express-session": "1.18.1",
"file-type": "18.7.0", "file-type": "18.7.0",
"formidable": "2.1.2", "formidable": "2.1.2",

View file

@ -2,6 +2,10 @@
## <i class="fa fa-tag"></i> 1.x.x <i class="fa fa-calendar-o"></i> UNRELEASED ## <i class="fa fa-tag"></i> 1.x.x <i class="fa fa-calendar-o"></i> UNRELEASED
### Features
- Add fixed rate-limiting to the login and register endpoints
- Add configurable rate-limiting to the new notes endpoint
### Bugfixes ### Bugfixes
- Fix a crash when cannot read user profile in Oauth - Fix a crash when cannot read user profile in Oauth
- Fix CSP Header for mermaid embedded images ([#5887](https://github.com/hedgedoc/hedgedoc/pull/5887) by [@domrim](https://github.com/domrim)) - Fix CSP Header for mermaid embedded images ([#5887](https://github.com/hedgedoc/hedgedoc/pull/5887) by [@domrim](https://github.com/domrim))

View file

@ -1286,6 +1286,7 @@ __metadata:
exports-loader: "npm:1.1.1" exports-loader: "npm:1.1.1"
expose-loader: "npm:1.0.3" expose-loader: "npm:1.0.3"
express: "npm:4.21.2" express: "npm:4.21.2"
express-rate-limit: "npm:7.4.1"
express-session: "npm:1.18.1" express-session: "npm:1.18.1"
file-loader: "npm:6.2.0" file-loader: "npm:6.2.0"
file-saver: "npm:2.0.5" file-saver: "npm:2.0.5"
@ -6473,6 +6474,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"express-rate-limit@npm:7.4.1":
version: 7.4.1
resolution: "express-rate-limit@npm:7.4.1"
peerDependencies:
express: 4 || 5 || ^5.0.0-beta.1
checksum: 10/230cebc90d9a6baf0b471fa9039b5bf3d82f0a29dc7b304adee38eaa4803493266584108ca3d79d21993bdd45f9497c0b4eac9db8037cd3f10b19c529a9bdf66
languageName: node
linkType: hard
"express-session@npm:1.18.1": "express-session@npm:1.18.1":
version: 1.18.1 version: 1.18.1
resolution: "express-session@npm:1.18.1" resolution: "express-session@npm:1.18.1"