├── .editorconfig ├── .eslintignore ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── README.md ├── eslint.config.mjs ├── package.json ├── playground ├── app.vue ├── nuxt.config.ts ├── package.json ├── server │ ├── api │ │ └── example.get.ts │ ├── tasks │ │ └── shield │ │ │ └── clean.ts │ └── tsconfig.json └── tsconfig.json ├── renovate.json ├── src ├── module.ts └── runtime │ ├── plugin.ts │ └── server │ ├── middleware │ └── shield.ts │ ├── tsconfig.json │ ├── types │ ├── LogEntry.ts │ └── RateLimit.ts │ └── utils │ ├── isBanExpired.ts │ └── shieldLog.ts ├── test ├── ApiResponse.ts ├── basic.test.ts ├── fixtures │ ├── basic │ │ ├── app.vue │ │ ├── nuxt.config.ts │ │ ├── package.json │ │ └── server │ │ │ └── api │ │ │ └── basicexample.get.ts │ └── withroutes │ │ ├── app.vue │ │ ├── nuxt.config.ts │ │ ├── package.json │ │ └── server │ │ └── api │ │ ├── v2 │ │ └── example.get.ts │ │ └── v3 │ │ └── example.get.ts └── withroutes.test.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: "20" 20 | 21 | - name: Install dependencies 22 | run: yarn install 23 | 24 | - name: Generate type stubs 25 | run: yarn dev:prepare 26 | 27 | - name: Run tests 28 | run: yarn test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | 58 | .shield 59 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.7.1 4 | 5 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.7.0...v0.7.1) 6 | 7 | ### 🩹 Fixes 8 | 9 | - **deps:** Update nuxtjs monorepo to v3.13.2 ([95197ee](https://github.com/rrd108/nuxt-api-shield/commit/95197ee)) 10 | 11 | ## v0.6.10 12 | 13 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.8...v0.6.10) 14 | 15 | ### 🏡 Chore 16 | 17 | - **release:** V0.6.8 ([52326ce](https://github.com/rrd108/nuxt-api-shield/commit/52326ce)) 18 | - **release:** V0.6.9 ([17f53f3](https://github.com/rrd108/nuxt-api-shield/commit/17f53f3)) 19 | 20 | ### ❤️ Contributors 21 | 22 | - Rrd108 ([@rrd108](http://github.com/rrd108)) 23 | 24 | ## v0.6.9 25 | 26 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.8...v0.6.9) 27 | 28 | ### 🏡 Chore 29 | 30 | - **release:** V0.6.8 ([52326ce](https://github.com/rrd108/nuxt-api-shield/commit/52326ce)) 31 | 32 | ### ❤️ Contributors 33 | 34 | - Rrd108 ([@rrd108](http://github.com/rrd108)) 35 | 36 | ## v0.6.8 37 | 38 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.7...v0.6.8) 39 | 40 | ## v0.6.7 41 | 42 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.6...v0.6.7) 43 | 44 | ## v0.6.6 45 | 46 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.0...v0.6.6) 47 | 48 | ### 🚀 Enhancements 49 | 50 | - Expose RateLimit type ([536c73e](https://github.com/rrd108/nuxt-api-shield/commit/536c73e)) 51 | 52 | ### 🏡 Chore 53 | 54 | - Remove unused import ([17f40fe](https://github.com/rrd108/nuxt-api-shield/commit/17f40fe)) 55 | - **release:** V0.6.1 ([3641e5b](https://github.com/rrd108/nuxt-api-shield/commit/3641e5b)) 56 | - **release:** V0.6.3 ([0f05e4c](https://github.com/rrd108/nuxt-api-shield/commit/0f05e4c)) 57 | - **release:** V0.6.5 ([505e1fd](https://github.com/rrd108/nuxt-api-shield/commit/505e1fd)) 58 | 59 | ### ❤️ Contributors 60 | 61 | - Rrd108 62 | 63 | ## v0.6.5 64 | 65 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.4...v0.6.5) 66 | 67 | ## v0.6.3 68 | 69 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.2...v0.6.3) 70 | 71 | ## v0.6.1 72 | 73 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.6.0...v0.6.1) 74 | 75 | ### 🚀 Enhancements 76 | 77 | - Expose RateLimit type ([536c73e](https://github.com/rrd108/nuxt-api-shield/commit/536c73e)) 78 | 79 | ### 🏡 Chore 80 | 81 | - Remove unused import ([17f40fe](https://github.com/rrd108/nuxt-api-shield/commit/17f40fe)) 82 | 83 | ### ❤️ Contributors 84 | 85 | - Rrd108 86 | 87 | ## v0.5.1 88 | 89 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.4.2...v0.5.1) 90 | 91 | ### 🏡 Chore 92 | 93 | - Comments removed ([39f3025](https://github.com/rrd108/nuxt-api-shield/commit/39f3025)) 94 | 95 | ### ❤️ Contributors 96 | 97 | - Rrd108 98 | 99 | ## v0.4.2 100 | 101 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.4.1...v0.4.2) 102 | 103 | ### 🏡 Chore 104 | 105 | - **release:** V0.4.1 ([656f5d6](https://github.com/rrd108/nuxt-api-shield/commit/656f5d6)) 106 | 107 | ### ❤️ Contributors 108 | 109 | - Rrd108 110 | 111 | ## v0.4.1 112 | 113 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.4.0...v0.4.1) 114 | 115 | ## v0.3.1 116 | 117 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.3.0...v0.3.1) 118 | 119 | ### 🚀 Enhancements 120 | 121 | - Add optional retry-after header fix #4 ([#4](https://github.com/rrd108/nuxt-api-shield/issues/4)) 122 | 123 | ### 🏡 Chore 124 | 125 | - Comment added ([b5ed91a](https://github.com/rrd108/nuxt-api-shield/commit/b5ed91a)) 126 | 127 | ### ✅ Tests 128 | 129 | - Change test to pass for custom error message ([a13b631](https://github.com/rrd108/nuxt-api-shield/commit/a13b631)) 130 | - Fix error throwing problems ([9538b42](https://github.com/rrd108/nuxt-api-shield/commit/9538b42)) 131 | 132 | ### ❤️ Contributors 133 | 134 | - Rrd108 135 | 136 | ## v0.3.0 137 | 138 | [compare changes](https://github.com/rrd108/nuxt-api-shield/compare/v0.2.0...v0.2.1) 139 | 140 | ### 🚀 Enhancements 141 | 142 | - Implements auto cleanup fix #3 ([#3](https://github.com/rrd108/nuxt-api-shield/issues/3)) 143 | 144 | ### 🏡 Chore 145 | 146 | - Fix package description and link ([7d5ebff](https://github.com/rrd108/nuxt-api-shield/commit/7d5ebff)) 147 | 148 | ### ❤️ Contributors 149 | 150 | - Rrd108 151 | 152 | ## v0.2.0 153 | 154 | [compare changes](https://github.com/your-org/nuxt-api-shield/compare/v0.1.0...v0.1.1) 155 | 156 | ### 🚀 Enhancements 157 | 158 | - Delay on ban implemented fix #1 ([#1](https://github.com/your-org/nuxt-api-shield/issues/1)) 159 | 160 | ### 🏡 Chore 161 | 162 | - New keywords added ([3327645](https://github.com/your-org/nuxt-api-shield/commit/3327645)) 163 | - Ignore .shield ([d17878b](https://github.com/your-org/nuxt-api-shield/commit/d17878b)) 164 | - Add comments ([9fc0ce9](https://github.com/your-org/nuxt-api-shield/commit/9fc0ce9)) 165 | 166 | ### ✅ Tests 167 | 168 | - Increase timeout to test ban time ([b7bfefd](https://github.com/your-org/nuxt-api-shield/commit/b7bfefd)) 169 | 170 | ### ❤️ Contributors 171 | 172 | - Rrd108 173 | 174 | ## v0.0.2 175 | 176 | ### 🚀 Enhancements 177 | 178 | - Rate limiting implemented ([04b9ea7](https://github.com/your-org/nuxt-api-shield/commit/04b9ea7)) 179 | - Nitro settings added to example ([34febf7](https://github.com/your-org/nuxt-api-shield/commit/34febf7)) 180 | 181 | ### 🩹 Fixes 182 | 183 | - README contains all info ([3d0d979](https://github.com/your-org/nuxt-api-shield/commit/3d0d979)) 184 | 185 | ### 🏡 Chore 186 | 187 | - Keywords added ([a0ada9b](https://github.com/your-org/nuxt-api-shield/commit/a0ada9b)) 188 | - Use yarn for release ([dccd1aa](https://github.com/your-org/nuxt-api-shield/commit/dccd1aa)) 189 | - Endpoint renamed ([2ad82b0](https://github.com/your-org/nuxt-api-shield/commit/2ad82b0)) 190 | - Config order changed ([1343731](https://github.com/your-org/nuxt-api-shield/commit/1343731)) 191 | - Unused paramterer removed ([2e99f94](https://github.com/your-org/nuxt-api-shield/commit/2e99f94)) 192 | 193 | ### ❤️ Contributors 194 | 195 | - Rrd108 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt API Shield 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![License][license-src]][license-href] 6 | [![Nuxt][nuxt-src]][nuxt-href] 7 | 8 | This Nuxt module implements a rate limiting middleware to protect your API endpoints from excessive requests. 9 | 10 | - [✨  Release Notes](/CHANGELOG.md) 11 | 12 | - [📖  Documentation](https://github.com/rrd108/nuxt-api-shield) 13 | 14 | ## Features 15 | 16 | - **IP-Based Rate limiting and Brute Force Protection** 17 | - Tracks and enforces rate limits for individual IP addresses. 18 | - Prevents malicious actors or excessive requests from a single source from overwhelming your API. 19 | - **Customizable Rate Limits** 20 | - Configure maximum request count, duration within which the limit applies, and a ban period for exceeding the limit. 21 | - Add a delay to responses when a user is banned to discourage further abuse. 22 | - Customize the error message for banned users. 23 | - Optionally include the `Retry-After` header in responses when a user is banned. 24 | - Tailor the rate-limiting behavior to align with your API's specific needs and usage patterns. 25 | - **Event-Driven Handling** 26 | - Intercepts incoming API requests efficiently using Nuxt's event system. 27 | - Ensures seamless integration with your Nuxt application's request lifecycle. 28 | - **Flexible Storage** 29 | - Utilizes Nuxt's unstorage abstraction for versatile storage options. 30 | - Store rate-limiting data in various storage providers (filesystem, memory, databases, etc.) based on your project's requirements. 31 | - **Configurable with Runtime Config** 32 | - Easily adjust rate-limiting parameters without code changes. 33 | - Adapt to dynamic needs and maintain control over rate-limiting behavior through Nuxt's runtime configuration. 34 | - **Clear Error Handling** 35 | - Returns a standardized 429 "Too Many Requests" error response when rate limits are exceeded. 36 | - Facilitates proper error handling in client-side applications for a smooth user experience. 37 | 38 | ## Quick Setup 39 | 40 | ### 1. Add `nuxt-api-shield` dependency to your project 41 | 42 | ```bash 43 | # Using pnpm 44 | pnpm add nuxt-api-shield 45 | 46 | # Using yarn 47 | yarn add nuxt-api-shield 48 | 49 | # Using npm 50 | npm install nuxt-api-shield 51 | ``` 52 | 53 | ### 2. Add `nuxt-api-shield` to the `modules` section of `nuxt.config.ts` 54 | 55 | You should add only the values you want to use differently from the default values. 56 | 57 | ```js 58 | export default defineNuxtConfig({ 59 | modules: ["nuxt-api-shield"], 60 | nuxtApiShield: { 61 | /*limit: { 62 | max: 12, // maximum requests per duration time, default is 12/duration 63 | duration: 108, // duration time in seconds, default is 108 seconds 64 | ban: 3600, // ban time in seconds, default is 3600 seconds = 1 hour 65 | }, 66 | delayOnBan: true // delay every response with +1sec when the user is banned, default is true 67 | errorMessage: "Too Many Requests", // error message when the user is banned, default is "Too Many Requests" 68 | retryAfterHeader: false, // when the user is banned add the Retry-After header to the response, default is false 69 | log: { 70 | path: "logs", // path to the log file, every day a new log file will be created, use "" to disable logging 71 | attempts: 100, // if an IP reach 100 requests, all the requests will be logged, can be used for further analysis or blocking for example with fail2ban, use 0 to disable logging 72 | }, 73 | routes: [], // specify routes to apply rate limiting to, default is an empty array meaning all routes are protected. 74 | // Example: 75 | // routes: ["/api/v2/", "/api/v3/"], // /api/v1 will not be protected, /api/v2/ and /api/v3/ will be protected */ 76 | }, 77 | }); 78 | ``` 79 | 80 | ### 3. Add `nitro/storage` to `nuxt.config.ts` 81 | 82 | You can use any storage you want, but you have to use **shield** as the name of the storage. 83 | 84 | ```json 85 | { 86 | "nitro": { 87 | "storage": { 88 | "shield": { 89 | // storage name, you **must** use "shield" as the name 90 | "driver": "memory" 91 | } 92 | } 93 | } 94 | } 95 | ``` 96 | 97 | If you use for example redis, you can use the following configuration, define the host and port. 98 | 99 | ```json 100 | { 101 | "nitro": { 102 | "storage": { 103 | "shield": { 104 | "driver": "redis", 105 | "host": "localhost", 106 | "port": 6379, 107 | } 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | ### 4. Add `shield:clean` to `nuxt.config.ts` 114 | 115 | ```json 116 | { 117 | "nitro": { 118 | "experimental": { 119 | "tasks": true 120 | }, 121 | "scheduledTasks": { 122 | "*/15 * * * *": ["shield:clean"] // clean the shield storage every 15 minutes 123 | } 124 | } 125 | } 126 | ``` 127 | 128 | ### 5. Create your `clean` task 129 | 130 | In `server/tasks/shield/clean.ts` you should have something like this. 131 | 132 | ```ts 133 | import type { RateLimit } from "#imports"; 134 | 135 | export default defineTask({ 136 | meta: { 137 | description: "Clean expired bans", 138 | }, 139 | async run() { 140 | const shieldStorage = useStorage("shield"); 141 | 142 | const keys = await shieldStorage.getKeys(); 143 | keys.forEach(async (key) => { 144 | const rateLimit = (await shieldStorage.getItem(key)) as RateLimit; 145 | if (isBanExpired(rateLimit)) { 146 | await shieldStorage.removeItem(key); 147 | } 148 | }); 149 | return { result: keys }; 150 | }, 151 | }); 152 | ``` 153 | 154 | ## Development 155 | 156 | ```bash 157 | # Install dependencies 158 | yarn 159 | 160 | # Generate type stubs 161 | yarn dev:prepare 162 | 163 | # Develop with the playground 164 | yarn dev 165 | 166 | # Build the playground 167 | yarn dev:build 168 | 169 | # Run ESLint 170 | yarn lint 171 | 172 | # Run Vitest 173 | yarn test 174 | yarn test:watch 175 | 176 | # Release new version 177 | yarn release:patch 178 | yarn release:minor 179 | ``` 180 | 181 | 182 | 183 | [npm-version-src]: https://img.shields.io/npm/v/nuxt-api-shield/latest.svg?style=flat&colorA=020420&colorB=00DC82 184 | [npm-version-href]: https://npmjs.com/package/nuxt-api-shield 185 | [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-api-shield.svg?style=flat&colorA=020420&colorB=00DC82 186 | [npm-downloads-href]: https://npmjs.com/package/nuxt-api-shield 187 | [license-src]: https://img.shields.io/npm/l/nuxt-api-shield.svg?style=flat&colorA=020420&colorB=00DC82 188 | [license-href]: https://npmjs.com/package/nuxt-api-shield 189 | [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js 190 | [nuxt-href]: https://nuxt.com 191 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat' 3 | 4 | // Run `npx @eslint/config-inspector` to inspect the resolved config interactively 5 | export default createConfigForNuxt({ 6 | features: { 7 | // Rules for module authors 8 | tooling: true, 9 | // Rules for formatting 10 | stylistic: true, 11 | }, 12 | dirs: { 13 | src: [ 14 | './playground', 15 | ], 16 | }, 17 | }) 18 | .append( 19 | // your custom flat config here... 20 | ) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-api-shield", 3 | "version": "0.8.0", 4 | "description": "Nuxt API Shield - Rate Limiting", 5 | "repository": "rrd108/nuxt-api-shield", 6 | "license": "MIT", 7 | "type": "module", 8 | "keywords": [ 9 | "nuxt", 10 | "nuxt-module", 11 | "security", 12 | "rate-limit", 13 | "bruteforce" 14 | ], 15 | "exports": { 16 | ".": { 17 | "types": "./dist/types.d.ts", 18 | "import": "./dist/module.mjs", 19 | "require": "./dist/module.cjs" 20 | } 21 | }, 22 | "main": "./dist/module.cjs", 23 | "types": "./dist/types.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "prepack": "nuxt-module-build build", 29 | "dev": "nuxi dev playground", 30 | "dev:build": "nuxi build playground", 31 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 32 | "release:patch": "yarn lint && yarn test && yarn prepack && changelogen --release --patch && yarn publish && git push --follow-tags", 33 | "release:minor": "yarn lint && yarn test && yarn prepack && changelogen --release --minor && yarn publish && git push --follow-tags", 34 | "lint": "eslint .", 35 | "test": "vitest run --testTimeout 15000 --reporter=basic --disable-console-intercept", 36 | "test:watch": "vitest watch --testTimeout 15000 --reporter=basic --disable-console-intercept" 37 | }, 38 | "dependencies": { 39 | "@nuxt/kit": "^3.12.2", 40 | "defu": "^6.1.4" 41 | }, 42 | "devDependencies": { 43 | "@nuxt/devtools": "^1.4.2", 44 | "@nuxt/eslint-config": "^0.7.0", 45 | "@nuxt/module-builder": "^0.8.3", 46 | "@nuxt/schema": "^3.13.1", 47 | "@nuxt/test-utils": "^3.14.2", 48 | "@types/node": "latest", 49 | "changelogen": "^0.5.5", 50 | "eslint": "^9.10.0", 51 | "nuxt": "^3.13.0", 52 | "typescript": "latest", 53 | "vitest": "^2.0.5", 54 | "vue-tsc": "^2.1.6" 55 | }, 56 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 57 | } 58 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ['../src/module'], 3 | nuxtApiShield: { 4 | limit: { 5 | max: 12, 6 | duration: 10, 7 | ban: 30, 8 | }, 9 | delayOnBan: true, 10 | retryAfterHeader: true, 11 | log: { 12 | path: 'logs', 13 | attempts: 5, 14 | }, 15 | }, 16 | nitro: { 17 | storage: { 18 | shield: { 19 | // driver: "memory", 20 | driver: 'fs', 21 | base: '.shield', 22 | }, 23 | }, 24 | experimental: { 25 | tasks: true, 26 | }, 27 | scheduledTasks: { 28 | '*/5 * * * *': ['shield:clean'], 29 | }, 30 | }, 31 | devtools: { enabled: true }, 32 | }) 33 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nuxt-api-shield-playground", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "dependencies": { 11 | "nuxt": "latest" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /playground/server/api/example.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async () => { 2 | return { result: 'Gauranga' } 3 | }) 4 | -------------------------------------------------------------------------------- /playground/server/tasks/shield/clean.ts: -------------------------------------------------------------------------------- 1 | import type { RateLimit } from '../../../../src/runtime/server/types/RateLimit' 2 | 3 | export default defineTask({ 4 | meta: { 5 | description: 'Clean expired bans', 6 | }, 7 | async run() { 8 | const shieldStorage = useStorage('shield') 9 | 10 | const keys = await shieldStorage.getKeys() 11 | 12 | keys.forEach(async (key) => { 13 | const rateLimit = (await shieldStorage.getItem(key)) as RateLimit 14 | if (isBanExpired(rateLimit)) { 15 | await shieldStorage.removeItem(key) 16 | } 17 | }) 18 | return { result: keys } 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineNuxtModule, 3 | addPlugin, 4 | createResolver, 5 | addServerHandler, 6 | addServerImports, 7 | } from '@nuxt/kit' 8 | import defu from 'defu' 9 | import type { LogEntry } from './runtime/server/types/LogEntry' 10 | 11 | export interface ModuleOptions { 12 | limit: { 13 | max: number 14 | duration: number 15 | ban: number 16 | } 17 | delayOnBan: boolean 18 | errorMessage: string 19 | retryAfterHeader: boolean 20 | log?: LogEntry 21 | routes: string[] 22 | } 23 | 24 | export default defineNuxtModule({ 25 | meta: { 26 | name: 'nuxt-api-shield', 27 | configKey: 'nuxtApiShield', 28 | }, 29 | defaults: { 30 | limit: { 31 | max: 12, 32 | duration: 108, 33 | ban: 3600, 34 | }, 35 | delayOnBan: true, 36 | errorMessage: 'Too Many Requests', 37 | retryAfterHeader: false, 38 | log: { path: '', attempts: 0 }, 39 | routes: [], 40 | }, 41 | setup(options, nuxt) { 42 | const resolver = createResolver(import.meta.url) 43 | 44 | nuxt.options.runtimeConfig.public.nuxtApiShield = defu( 45 | nuxt.options.runtimeConfig.public.nuxtApiShield, 46 | options, 47 | ) 48 | 49 | addServerImports([ 50 | { 51 | name: 'RateLimit', 52 | as: 'RateLimit', 53 | from: resolver.resolve('./runtime/server/types/RateLimit'), 54 | }, 55 | { 56 | name: 'isBanExpired', 57 | as: 'isBanExpired', 58 | from: resolver.resolve('./runtime/server/utils/isBanExpired'), 59 | }, 60 | ]) 61 | 62 | addServerHandler({ 63 | middleware: true, 64 | handler: resolver.resolve('./runtime/server/middleware/shield'), 65 | }) 66 | 67 | addPlugin(resolver.resolve('./runtime/plugin')) 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /src/runtime/plugin.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from '#app' 2 | 3 | export default defineNuxtPlugin(() => {}) 4 | -------------------------------------------------------------------------------- /src/runtime/server/middleware/shield.ts: -------------------------------------------------------------------------------- 1 | import type { RateLimit } from '../types/RateLimit' 2 | import { isBanExpired } from '../utils/isBanExpired' 3 | import shieldLog from '../utils/shieldLog' 4 | import { 5 | createError, 6 | defineEventHandler, 7 | getRequestIP, 8 | getRequestURL, 9 | useRuntimeConfig, 10 | useStorage, 11 | } from '#imports' 12 | 13 | export default defineEventHandler(async (event) => { 14 | const config = useRuntimeConfig().public.nuxtApiShield 15 | const url = getRequestURL(event) 16 | 17 | if (!url?.pathname?.startsWith('/api/') || (config.routes?.length && !config.routes.some(route => url.pathname?.startsWith(route)))) { 18 | return 19 | } 20 | 21 | // console.log( 22 | // `👉 Handling request for URL: ${url} from IP: ${getRequestIP(event, { xForwardedFor: true }) || "unKnownIP" 23 | // }` 24 | // ); 25 | 26 | const shieldStorage = useStorage('shield') 27 | const requestIP = getRequestIP(event, { xForwardedFor: true }) || 'unKnownIP' 28 | 29 | if (!(await shieldStorage.hasItem(`ip:${requestIP}`))) { 30 | // console.log(" IP not found in storage, setting initial count.", requestIP); 31 | return await shieldStorage.setItem(`ip:${requestIP}`, { 32 | count: 1, 33 | time: Date.now(), 34 | }) 35 | } 36 | 37 | const req = (await shieldStorage.getItem(`ip:${requestIP}`)) as RateLimit 38 | req.count++ 39 | // console.log(` Set count for IP ${requestIP}: ${req.count}`); 40 | 41 | shieldLog(req, requestIP, url) 42 | 43 | if (!isRateLimited(req)) { 44 | // console.log(" Request not rate-limited, updating storage."); 45 | return await shieldStorage.setItem(`ip:${requestIP}`, { 46 | count: req.count, 47 | time: req.time, 48 | }) 49 | } 50 | 51 | // console.log(" Request is rate-limited."); 52 | 53 | if (isBanExpired(req)) { 54 | // console.log(" Ban expired, resetting count."); 55 | return await shieldStorage.setItem(`ip:${requestIP}`, { 56 | count: 1, 57 | time: Date.now(), 58 | }) 59 | } 60 | 61 | // console.log(" setItem for IP:", requestIP, "with count:", req.count, "and time:", req.time); 62 | shieldStorage.setItem(`ip:${requestIP}`, { 63 | count: req.count, 64 | time: req.time, 65 | }) 66 | 67 | await banDelay(req) 68 | 69 | const options = useRuntimeConfig().public.nuxtApiShield 70 | 71 | if (options.retryAfterHeader) { 72 | // console.log(" Setting Retry-After header", req.count + 1); 73 | event.node.res.setHeader('Retry-After', req.count + 1) // and extra second is added 74 | } 75 | 76 | // console.error("Throwing 429 error"); 77 | throw createError({ 78 | statusCode: 429, 79 | message: options.errorMessage, 80 | }) 81 | }) 82 | 83 | const isRateLimited = (req: RateLimit) => { 84 | const options = useRuntimeConfig().public.nuxtApiShield 85 | 86 | // console.log(` count: ${req.count} <= limit: ${options.limit.max}`); 87 | if (req.count <= options.limit.max) { 88 | return false 89 | } 90 | // console.log(" ", (Date.now() - req.time) / 1000, "<", options.limit.duration); 91 | return (Date.now() - req.time) / 1000 < options.limit.duration 92 | } 93 | 94 | const banDelay = async (req: RateLimit) => { 95 | const options = useRuntimeConfig().public.nuxtApiShield 96 | // console.log(" delayOnBan is: " + options.delayOnBan); 97 | if (options.delayOnBan && req.count > options.limit.max) { 98 | // INFO Nuxt Devtools will send a new request if the response is slow, 99 | // so we get the count incremented twice or more times, based on the ban delay time 100 | // console.log(` Applying ban delay for ${(req.count - options.limit.max) * options.limit.ban} sec (${Date.now()})`); 101 | await new Promise(resolve => 102 | setTimeout(resolve, (req.count - options.limit.max) * options.limit.ban * 1000), 103 | ) 104 | // console.log(` Ban delay completed (${Date.now()})`); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/runtime/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/runtime/server/types/LogEntry.ts: -------------------------------------------------------------------------------- 1 | export interface LogEntry { 2 | path: string 3 | attempts: number 4 | } 5 | -------------------------------------------------------------------------------- /src/runtime/server/types/RateLimit.ts: -------------------------------------------------------------------------------- 1 | export type RateLimit = { 2 | count: number 3 | time: number 4 | } 5 | -------------------------------------------------------------------------------- /src/runtime/server/utils/isBanExpired.ts: -------------------------------------------------------------------------------- 1 | import type { RateLimit } from '../types/RateLimit' 2 | import { useRuntimeConfig } from '#imports' 3 | 4 | export const isBanExpired = (req: RateLimit) => { 5 | const options = useRuntimeConfig().public.nuxtApiShield 6 | // console.log( 7 | // "Checking if ban is expired for IP:", 8 | // req, 9 | // "with :", 10 | // (Date.now() - req.time) / 1000, 11 | // " > ", 12 | // options.limit.ban 13 | // ); 14 | return (Date.now() - req.time) / 1000 > options.limit.ban 15 | } 16 | -------------------------------------------------------------------------------- /src/runtime/server/utils/shieldLog.ts: -------------------------------------------------------------------------------- 1 | import { access, appendFile, mkdir } from 'node:fs/promises' 2 | import type { RateLimit } from '../types/RateLimit' 3 | import type { LogEntry } from '../types/LogEntry' 4 | import { useRuntimeConfig } from '#imports' 5 | 6 | const shieldLog = async (req: RateLimit, requestIP: string, url: string) => { 7 | const options = useRuntimeConfig().public.nuxtApiShield 8 | 9 | if (!options.log.path || !options.log.attempts) { 10 | return 11 | } 12 | 13 | // console.log(`shieldLog(${req}, ${requestIP}, ${url})`); 14 | if ((options.log as LogEntry).attempts && req.count >= (options.log as LogEntry).attempts) { 15 | const logLine = `${requestIP} - (${req.count}) - ${new Date( 16 | req.time, 17 | ).toISOString()} - ${url}\n` 18 | 19 | const date = new Date().toISOString().split('T')[0].replace(/-/g, '') 20 | 21 | try { 22 | await access((options.log as LogEntry).path) 23 | await appendFile(`${(options.log as LogEntry).path}/shield-${date}.log`, logLine) 24 | } 25 | catch (error: unknown) { 26 | if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { 27 | await mkdir((options.log as LogEntry).path) 28 | await appendFile(`${(options.log as LogEntry).path}/shield-${date}.log`, logLine) 29 | } 30 | else { 31 | console.error('Unexpected error:', error) 32 | // Handle other potential errors 33 | } 34 | } 35 | } 36 | } 37 | 38 | export default shieldLog 39 | -------------------------------------------------------------------------------- /test/ApiResponse.ts: -------------------------------------------------------------------------------- 1 | export interface ApiResponse { 2 | id: number 3 | name: string 4 | } 5 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { readFile, rm } from 'node:fs/promises' 3 | import { beforeEach, describe, it, expect } from 'vitest' 4 | import { setup, $fetch } from '@nuxt/test-utils/e2e' 5 | import type { ApiResponse } from './ApiResponse' 6 | 7 | // TODO get these from the config 8 | const nuxtConfigBan = 10 9 | 10 | describe('shield', async () => { 11 | await setup({ 12 | rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), 13 | }) 14 | 15 | beforeEach(async () => { 16 | // await useStorage("shield").clear(); TODO waiting for https://github.com/nuxt/test-utils/issues/531 17 | // this is a workaround to clean the storage 18 | const storagePath = fileURLToPath(new URL('../_testBasicShield', import.meta.url)) 19 | await rm(storagePath, { recursive: true, force: true }) 20 | }) 21 | 22 | it('respond to api call 2 times (limit.max, limit.duration) and rejects the 3rd call', async () => { 23 | // req.count = 1 24 | let response = await $fetch('/api/basicexample?c=1/1', { 25 | method: 'GET', 26 | retryStatusCodes: [], 27 | }) 28 | expect((response as ApiResponse).name).toBe('Gauranga') 29 | 30 | // req.count = 2 31 | response = await $fetch('/api/basicexample?c=1/2', { 32 | method: 'GET', 33 | retryStatusCodes: [], 34 | }) 35 | expect((response as ApiResponse).name).toBe('Gauranga') 36 | 37 | try { 38 | // req.count = 3 39 | // as limit.max = 2, this should throw 429 and ban for 3 seconds (limit.ban) 40 | expect(async () => 41 | $fetch('/api/basicexample?c=1/3', { method: 'GET', retryStatusCodes: [] }), 42 | ).rejects.toThrowError() 43 | } 44 | catch (err) { 45 | const typedErr = err as { statusCode: number, statusMessage: string } 46 | expect(typedErr.statusCode).toBe(429) 47 | expect(typedErr.statusMessage).toBe('Leave me alone') 48 | } 49 | }) 50 | 51 | it('respond to the 2nd api call when more then limit.duration time passes', async () => { 52 | // see #13 53 | // req.count = 1 54 | let response = await $fetch('/api/basicexample?c=2/1', { 55 | method: 'GET', 56 | retryStatusCodes: [], 57 | }) 58 | 59 | // req.count = 2 60 | response = await $fetch('/api/basicexample?c=2/2', { 61 | method: 'GET', 62 | retryStatusCodes: [], 63 | }) 64 | expect((response as ApiResponse).name).toBe('Gauranga') 65 | }) 66 | 67 | it('respond to api call after limit.ban expires', async () => { 68 | // req.count reset here 69 | await $fetch('/api/basicexample?c=3/1', { method: 'GET', retryStatusCodes: [] }) // req.count = 1 70 | await $fetch('/api/basicexample?c=3/2', { method: 'GET', retryStatusCodes: [] }) // req.count = 2 71 | try { 72 | // req.count = 3 73 | expect(async () => 74 | $fetch('/api/basicexample?c=3/3', { method: 'GET', retryStatusCodes: [] }), 75 | ).rejects.toThrowError() 76 | } 77 | catch (err) { 78 | const typedErr = err as { 79 | response: Response 80 | statusCode: number 81 | statusMessage: string 82 | } 83 | expect(typedErr.statusCode).toBe(429) 84 | expect(typedErr.statusMessage).toBe('Leave me alone') 85 | // retry-after = req.count (4) + 2 86 | expect(typedErr.response.headers.get('Retry-After')).toBe('6') 87 | } 88 | 89 | // here we should wait for the 3 sec ban to expire 90 | await new Promise(resolve => setTimeout(resolve, nuxtConfigBan * 1000)) 91 | const response = await $fetch('/api/basicexample?c=3/4', { 92 | method: 'GET', 93 | retryStatusCodes: [], 94 | }) 95 | expect((response as ApiResponse).name).toBe('Gauranga') 96 | }) 97 | 98 | it('should created a log file', async () => { 99 | const logDate = new Date().toISOString().split('T')[0].replace(/-/g, '') 100 | const logFile = fileURLToPath( 101 | new URL(`../_logs/shield-${logDate}.log`, import.meta.url), 102 | ) 103 | const contents = await readFile(logFile, { encoding: 'utf8' }) 104 | expect(contents).toContain('127.0.0.1') 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /test/fixtures/basic/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /test/fixtures/basic/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import nuxtApiShield from '../../../src/module' 2 | 3 | export default defineNuxtConfig({ 4 | modules: [nuxtApiShield], 5 | nuxtApiShield: { 6 | limit: { 7 | max: 2, 8 | duration: 3, 9 | ban: 10, 10 | }, 11 | errorMessage: 'Leave me alone', 12 | retryAfterHeader: true, 13 | log: { 14 | path: '_logs', 15 | attempts: 3, 16 | }, 17 | }, 18 | nitro: { 19 | storage: { 20 | shield: { 21 | // driver: "memory", 22 | driver: 'fs', 23 | base: '_testBasicShield', 24 | }, 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /test/fixtures/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "basic", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/basic/server/api/basicexample.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async () => { 2 | return { id: 1, name: 'Gauranga' } 3 | }) 4 | -------------------------------------------------------------------------------- /test/fixtures/withroutes/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/fixtures/withroutes/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import nuxtApiShield from '../../../src/module' 2 | 3 | export default defineNuxtConfig({ 4 | modules: [nuxtApiShield], 5 | nuxtApiShield: { 6 | limit: { 7 | max: 2, 8 | duration: 3, 9 | ban: 10, 10 | }, 11 | errorMessage: 'Leave me alone', 12 | retryAfterHeader: true, 13 | log: { path: '', attempts: 0 }, 14 | routes: ['/api/v3'], 15 | }, 16 | nitro: { 17 | storage: { 18 | shield: { 19 | // driver: "memory", 20 | driver: 'fs', 21 | base: '_testWithRoutesShield', 22 | }, 23 | }, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /test/fixtures/withroutes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "withroutes", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/withroutes/server/api/v2/example.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async () => { 2 | return { id: 1, name: 'Gauranga' } 3 | }) 4 | -------------------------------------------------------------------------------- /test/fixtures/withroutes/server/api/v3/example.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async () => { 2 | return { id: 1, name: 'Gauranga' } 3 | }) 4 | -------------------------------------------------------------------------------- /test/withroutes.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { rm } from 'node:fs/promises' 3 | import { describe, it, expect, beforeEach } from 'vitest' 4 | import { setup, $fetch } from '@nuxt/test-utils/e2e' 5 | import type { ApiResponse } from './ApiResponse' 6 | 7 | beforeEach(async () => { 8 | // await useStorage("shield").clear(); TODO waiting for https://github.com/nuxt/test-utils/issues/531 9 | // this is a workaround to clean the storage 10 | const storagePath = fileURLToPath(new URL('../_testWithRoutesShield', import.meta.url)) 11 | await rm(storagePath, { recursive: true, force: true }) 12 | }) 13 | 14 | describe('shield with /api/v3 route', async () => { 15 | await setup({ 16 | rootDir: fileURLToPath(new URL('./fixtures/withroutes', import.meta.url)), 17 | }) 18 | 19 | it('respond to api call 2 times (limit.max, limit.duration) and rejects the 3rd call if the route matches the routes option', async () => { 20 | // req.count = 1 21 | let response = await $fetch('/api/v3/example?c=1/1', { 22 | method: 'GET', 23 | retryStatusCodes: [], 24 | }) 25 | expect((response as ApiResponse).name).toBe('Gauranga') 26 | 27 | // req.count = 2 28 | response = await $fetch('/api/v3/example?c=1/2', { 29 | method: 'GET', 30 | retryStatusCodes: [], 31 | }) 32 | expect((response as ApiResponse).name).toBe('Gauranga') 33 | 34 | try { 35 | // req.count = 3 36 | // as limit.max = 2, this should throw 429 and ban for 3 seconds (limit.ban) 37 | expect(async () => 38 | $fetch('/api/v3/example?c=1/3', { method: 'GET', retryStatusCodes: [] }), 39 | ).rejects.toThrowError() 40 | } 41 | catch (err) { 42 | const typedErr = err as { statusCode: number, statusMessage: string } 43 | expect(typedErr.statusCode).toBe(429) 44 | expect(typedErr.statusMessage).toBe('Leave me alone') 45 | } 46 | }) 47 | 48 | it('respond to api call 2 times (limit.max, limit.duration) and accept the 3rd call if the route does not matches the routes option', async () => { 49 | // req.count = 1 50 | let response = await $fetch('/api/v2/example?c=2/1', { 51 | method: 'GET', 52 | retryStatusCodes: [], 53 | }) 54 | expect((response as ApiResponse).name).toBe('Gauranga') 55 | 56 | // req.count = 2 57 | response = await $fetch('/api/v2/example?c=2/2', { 58 | method: 'GET', 59 | retryStatusCodes: [], 60 | }) 61 | expect((response as ApiResponse).name).toBe('Gauranga') 62 | 63 | // req.count = 3 64 | response = await $fetch('/api/v2/example?c=2/3', { 65 | method: 'GET', 66 | retryStatusCodes: [], 67 | }) 68 | expect((response as ApiResponse).name).toBe('Gauranga') 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------