✨ finished Rate Limit
This commit is contained in:
		
							parent
							
								
									efc522a0c6
								
							
						
					
					
						commit
						79e1adc5ce
					
				
							
								
								
									
										50
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										50
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -9,7 +9,7 @@ | ||||
| 			"version": "1.0.0", | ||||
| 			"license": "ISC", | ||||
| 			"dependencies": { | ||||
| 				"@fosscord/server-util": "^1.3.21", | ||||
| 				"@fosscord/server-util": "^1.3.23", | ||||
| 				"@types/jest": "^26.0.22", | ||||
| 				"@types/json-schema": "^7.0.7", | ||||
| 				"ajv": "^8.4.0", | ||||
| @ -36,7 +36,8 @@ | ||||
| 				"mongoose-autopopulate": "^0.12.3", | ||||
| 				"mongoose-long": "^0.3.2", | ||||
| 				"multer": "^1.4.2", | ||||
| 				"node-fetch": "^2.6.1" | ||||
| 				"node-fetch": "^2.6.1", | ||||
| 				"require_optional": "^1.0.1" | ||||
| 			}, | ||||
| 			"devDependencies": { | ||||
| 				"@types/bcrypt": "^3.0.0", | ||||
| @ -549,9 +550,9 @@ | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/@fosscord/server-util": { | ||||
| 			"version": "1.3.21", | ||||
| 			"resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.21.tgz", | ||||
| 			"integrity": "sha512-Mb4FqgkMeI2V+et5mpQkJqeijWNTjv79LZ6rRvgFdz+DwOw75S3A2vLMRqPX9lUB6uT9ZOMd5StQjR6YZNH9Sw==", | ||||
| 			"version": "1.3.23", | ||||
| 			"resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.23.tgz", | ||||
| 			"integrity": "sha512-YxkuMwsJmMpCN4zGCq0LHvUuV9zlR8yTriquPqWfp5Sbj1DdFz7Qqo6wz6cRYb3WRIINouHhV60cbljmUqLIJQ==", | ||||
| 			"dependencies": { | ||||
| 				"@types/jsonwebtoken": "^8.5.0", | ||||
| 				"@types/mongoose-autopopulate": "^0.10.1", | ||||
| @ -7846,6 +7847,23 @@ | ||||
| 				"node": ">=0.10.0" | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/require_optional": { | ||||
| 			"version": "1.0.1", | ||||
| 			"resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", | ||||
| 			"integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", | ||||
| 			"dependencies": { | ||||
| 				"resolve-from": "^2.0.0", | ||||
| 				"semver": "^5.1.0" | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/require_optional/node_modules/resolve-from": { | ||||
| 			"version": "2.0.0", | ||||
| 			"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", | ||||
| 			"integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=", | ||||
| 			"engines": { | ||||
| 				"node": ">=0.10.0" | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/require-directory": { | ||||
| 			"version": "2.1.1", | ||||
| 			"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", | ||||
| @ -10635,9 +10653,9 @@ | ||||
| 			} | ||||
| 		}, | ||||
| 		"@fosscord/server-util": { | ||||
| 			"version": "1.3.21", | ||||
| 			"resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.21.tgz", | ||||
| 			"integrity": "sha512-Mb4FqgkMeI2V+et5mpQkJqeijWNTjv79LZ6rRvgFdz+DwOw75S3A2vLMRqPX9lUB6uT9ZOMd5StQjR6YZNH9Sw==", | ||||
| 			"version": "1.3.23", | ||||
| 			"resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.23.tgz", | ||||
| 			"integrity": "sha512-YxkuMwsJmMpCN4zGCq0LHvUuV9zlR8yTriquPqWfp5Sbj1DdFz7Qqo6wz6cRYb3WRIINouHhV60cbljmUqLIJQ==", | ||||
| 			"requires": { | ||||
| 				"@types/jsonwebtoken": "^8.5.0", | ||||
| 				"@types/mongoose-autopopulate": "^0.10.1", | ||||
| @ -16816,6 +16834,22 @@ | ||||
| 				"is-finite": "^1.0.0" | ||||
| 			} | ||||
| 		}, | ||||
| 		"require_optional": { | ||||
| 			"version": "1.0.1", | ||||
| 			"resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", | ||||
| 			"integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", | ||||
| 			"requires": { | ||||
| 				"resolve-from": "^2.0.0", | ||||
| 				"semver": "^5.1.0" | ||||
| 			}, | ||||
| 			"dependencies": { | ||||
| 				"resolve-from": { | ||||
| 					"version": "2.0.0", | ||||
| 					"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", | ||||
| 					"integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| 		"require-directory": { | ||||
| 			"version": "2.1.1", | ||||
| 			"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", | ||||
|  | ||||
| @ -29,7 +29,7 @@ | ||||
| 	}, | ||||
| 	"homepage": "https://github.com/fosscord/fosscord-api#readme", | ||||
| 	"dependencies": { | ||||
| 		"@fosscord/server-util": "^1.3.21", | ||||
| 		"@fosscord/server-util": "^1.3.23", | ||||
| 		"@types/jest": "^26.0.22", | ||||
| 		"@types/json-schema": "^7.0.7", | ||||
| 		"ajv": "^8.4.0", | ||||
| @ -56,7 +56,8 @@ | ||||
| 		"mongoose-autopopulate": "^0.12.3", | ||||
| 		"mongoose-long": "^0.3.2", | ||||
| 		"multer": "^1.4.2", | ||||
| 		"node-fetch": "^2.6.1" | ||||
| 		"node-fetch": "^2.6.1", | ||||
| 		"require_optional": "^1.0.1" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@types/bcrypt": "^3.0.0", | ||||
|  | ||||
| @ -56,7 +56,7 @@ export class FosscordServer extends Server { | ||||
| 			db.collection("emojis").createIndex({ id: 1 }, { unique: true }), | ||||
| 			db.collection("invites").createIndex({ code: 1 }, { unique: true }), | ||||
| 			db.collection("invites").createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 }), // after 0 seconds of expires_at the invite will get delete
 | ||||
| 			db.collection("ratelimits").createIndex({ created_at: 1 }, { expireAfterSeconds: 1000 }) | ||||
| 			db.collection("ratelimits").createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 }) | ||||
| 		]); | ||||
| 	} | ||||
| 
 | ||||
| @ -69,7 +69,6 @@ export class FosscordServer extends Server { | ||||
| 
 | ||||
| 		this.app.use(CORS); | ||||
| 		this.app.use(Authentication); | ||||
| 		this.app.use(RateLimit({ count: 10, error: 10, window: 5 })); | ||||
| 		this.app.use(BodyParser({ inflate: true, limit: 1024 * 1024 * 2 })); | ||||
| 		const languages = await fs.readdir(path.join(__dirname, "..", "locales")); | ||||
| 		const namespaces = await fs.readdir(path.join(__dirname, "..", "locales", "en")); | ||||
| @ -94,6 +93,7 @@ export class FosscordServer extends Server { | ||||
| 		const prefix = Router(); | ||||
| 		// @ts-ignore
 | ||||
| 		this.app = prefix; | ||||
| 		prefix.use(RateLimit({ bucket: "global", count: 10, error: 10, window: 5, bot: 250 })); | ||||
| 		prefix.use("/guilds/:id", RateLimit({ count: 10, window: 5 })); | ||||
| 		prefix.use("/webhooks/:id", RateLimit({ count: 10, window: 5 })); | ||||
| 		prefix.use("/channels/:id", RateLimit({ count: 10, window: 5 })); | ||||
|  | ||||
| @ -1,8 +1,16 @@ | ||||
| import { db, MongooseCache, Bucket } from "@fosscord/server-util"; | ||||
| import { NextFunction, Request, Response } from "express"; | ||||
| import { API_PREFIX, API_PREFIX_TRAILING_SLASH } from "./Authentication"; | ||||
| import { getIpAdress } from "../util/ipAddress"; | ||||
| import { API_PREFIX_TRAILING_SLASH } from "./Authentication"; | ||||
| 
 | ||||
| const Cache = new MongooseCache(db.collection("ratelimits"), [{ $match: { blocked: true } }], { onlyEvents: false, array: true }); | ||||
| const Cache = new MongooseCache( | ||||
| 	db.collection("ratelimits"), | ||||
| 	[ | ||||
| 		// TODO: uncomment $match and fix error: not receiving change events
 | ||||
| 		// { $match: { blocked: true } }
 | ||||
| 	], | ||||
| 	{ onlyEvents: false, array: true } | ||||
| ); | ||||
| 
 | ||||
| // Docs: https://discord.com/developers/docs/topics/rate-limits
 | ||||
| 
 | ||||
| @ -37,52 +45,86 @@ export default function RateLimit(opts: { | ||||
| 	Cache.init(); // will only initalize it once
 | ||||
| 
 | ||||
| 	return async (req: Request, res: Response, next: NextFunction) => { | ||||
| 		const bucket_id = req.path.replace(API_PREFIX_TRAILING_SLASH, ""); | ||||
| 		const user_id = req.user_id; | ||||
| 		const max_hits = req.user_bot ? opts.bot : opts.count; | ||||
| 		const offender = Cache.data.find((x: Bucket) => x.user && x.id === bucket_id) as Bucket | null; | ||||
| 		const bucket_id = opts.bucket || req.path.replace(API_PREFIX_TRAILING_SLASH, ""); | ||||
| 		const user_id = req.user_id || getIpAdress(req); | ||||
| 		var max_hits = opts.count; | ||||
| 		if (opts.bot && req.user_bot) max_hits = opts.bot; | ||||
| 		if (opts.GET && ["GET", "OPTIONS", "HEAD"].includes(req.method)) max_hits = opts.GET; | ||||
| 		else if (opts.MODIFY && ["POST", "DELETE", "PATCH", "PUT"].includes(req.method)) max_hits = opts.MODIFY; | ||||
| 
 | ||||
| 		const offender = Cache.data?.find((x: Bucket) => x.user_id == user_id && x.id === bucket_id) as Bucket | null; | ||||
| 
 | ||||
| 		if (offender && offender.blocked) { | ||||
| 			const reset = offender.created_at.getTime() + opts.window; | ||||
| 			const reset = offender.expires_at.getTime(); | ||||
| 			const resetAfterMs = reset - Date.now(); | ||||
| 			const resetAfterSec = resetAfterMs / 1000; | ||||
| 			const global = bucket_id === "global"; | ||||
| 			console.log("blocked", { resetAfterMs }); | ||||
| 
 | ||||
| 			return ( | ||||
| 				res | ||||
| 					.status(429) | ||||
| 					.set("X-RateLimit-Limit", `${max_hits}`) | ||||
| 					.set("X-RateLimit-Remaining", "0") | ||||
| 					.set("X-RateLimit-Reset", `${reset}`) | ||||
| 					.set("X-RateLimit-Reset-After", `${resetAfterSec}`) | ||||
| 					.set("X-RateLimit-Global", `${global}`) | ||||
| 					.set("Retry-After", `${Math.ceil(resetAfterSec)}`) | ||||
| 					.set("X-RateLimit-Bucket", `${bucket_id}`) | ||||
| 					// TODO: error rate limit message translation
 | ||||
| 					.send({ message: "You are being rate limited.", retry_after: resetAfterSec, global }) | ||||
| 			); | ||||
| 			if (resetAfterMs > 0) { | ||||
| 				return ( | ||||
| 					res | ||||
| 						.status(429) | ||||
| 						.set("X-RateLimit-Limit", `${max_hits}`) | ||||
| 						.set("X-RateLimit-Remaining", "0") | ||||
| 						.set("X-RateLimit-Reset", `${reset}`) | ||||
| 						.set("X-RateLimit-Reset-After", `${resetAfterSec}`) | ||||
| 						.set("X-RateLimit-Global", `${global}`) | ||||
| 						.set("Retry-After", `${Math.ceil(resetAfterSec)}`) | ||||
| 						.set("X-RateLimit-Bucket", `${bucket_id}`) | ||||
| 						// TODO: error rate limit message translation
 | ||||
| 						.send({ message: "You are being rate limited.", retry_after: resetAfterSec, global }) | ||||
| 				); | ||||
| 			} else { | ||||
| 				// mongodb ttl didn't update yet -> manually update/delete
 | ||||
| 				db.collection("ratelimits").updateOne( | ||||
| 					{ id: bucket_id, user_id }, | ||||
| 					{ $set: { hits: 0, expires_at: new Date(Date.now() + opts.window * 1000), blocked: false } } | ||||
| 				); | ||||
| 			} | ||||
| 		} | ||||
| 		next(); | ||||
| 		console.log(req.route); | ||||
| 
 | ||||
| 		if (opts.error) { | ||||
| 			res.once("finish", () => { | ||||
| 				// check if error and increment error rate limit
 | ||||
| 				if (res.statusCode >= 400) { | ||||
| 					// TODO: use config rate limit values
 | ||||
| 					return hitRoute({ bucket_id: "error", user_id, max_hits: opts.error as number, window: opts.window }); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		db.collection("ratelimits").updateOne( | ||||
| 			{ bucket: bucket_id }, | ||||
| 			{ | ||||
| 				$set: { | ||||
| 					id: bucket_id, | ||||
| 					user_id, | ||||
| 					created_at: new Date(), | ||||
| 					$cond: { if: { $gt: ["$hits", max_hits] }, then: true, else: false } | ||||
| 				}, | ||||
| 				$inc: { hits: 1 } | ||||
| 			}, | ||||
| 			{ upsert: true } | ||||
| 		); | ||||
| 		return hitRoute({ user_id, bucket_id, max_hits, window: opts.window }); | ||||
| 	}; | ||||
| } | ||||
| 
 | ||||
| function hitRoute(opts: { user_id: string; bucket_id: string; max_hits: number; window: number }) { | ||||
| 	return db.collection("ratelimits").updateOne( | ||||
| 		{ id: opts.bucket_id, user_id: opts.user_id }, | ||||
| 		[ | ||||
| 			{ | ||||
| 				$replaceRoot: { | ||||
| 					newRoot: { | ||||
| 						// similar to $setOnInsert
 | ||||
| 						$mergeObjects: [ | ||||
| 							{ | ||||
| 								id: opts.bucket_id, | ||||
| 								user_id: opts.user_id, | ||||
| 								expires_at: new Date(Date.now() + opts.window * 1000) | ||||
| 							}, | ||||
| 							"$$ROOT" | ||||
| 						] | ||||
| 					} | ||||
| 				} | ||||
| 			}, | ||||
| 			{ | ||||
| 				$set: { | ||||
| 					hits: { $sum: [{ $ifNull: ["$hits", 0] }, 1] }, | ||||
| 					blocked: { $gt: ["$hits", opts.max_hits] } | ||||
| 				} | ||||
| 			} | ||||
| 		], | ||||
| 		{ upsert: true } | ||||
| 	); | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Flam3rboy
						Flam3rboy