diff --git a/.env.example b/.env.example index a8998fc..a5a1c66 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,8 @@ NODE_ENV=production # 'azure-dall-e' to the list of allowed model families. # ALLOWED_MODEL_FAMILIES=turbo,gpt4,gpt4-32k,gpt4-turbo,gpt4o,claude,claude-opus,gemini-pro,mistral-tiny,mistral-small,mistral-medium,mistral-large,aws-claude,aws-claude-opus,azure-turbo,azure-gpt4,azure-gpt4-32k,azure-gpt4-turbo,azure-gpt4o +# IP addresses or CIDR blocks from which requests will be blocked. +# IP_BLACKLIST=10.0.0.1/24 # URLs from which requests will be blocked. # BLOCKED_ORIGINS=reddit.com,9gag.com # Message to show when requests are blocked. @@ -74,6 +76,12 @@ NODE_ENV=production # Detail level of logging. (trace | debug | info | warn | error) # LOG_LEVEL=info +# Captcha verification settings. Refer to docs/pow-captcha.md for guidance. +# CAPTCHA_MODE=none +# POW_TOKEN_HOURS=24 +# POW_TOKEN_MAX_IPS=2 +# POW-DIFFICULTY_LEVEL=low + # ------------------------------------------------------------------------------ # Optional settings for user management, access control, and quota enforcement: # See `docs/user-management.md` for more information and setup instructions. @@ -136,6 +144,10 @@ AZURE_CREDENTIALS=azure-resource-name:deployment-id:api-key,another-azure-resour # With user_token gatekeeper, the admin password used to manage users. # ADMIN_KEY=your-very-secret-key +# To restrict access to the admin interface to specific IP addresses, set the +# ADMIN_WHITELIST environment variable to a comma-separated list of CIDR blocks. +# ADMIN_WHITELIST=0.0.0.0/0 + # With firebase_rtdb gatekeeper storage, the Firebase project credentials. # FIREBASE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/.prettierrc b/.prettierrc index 73be315..161e79e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,11 +1,10 @@ { + "plugins": ["prettier-plugin-ejs"], "overrides": [ { - "files": [ - "*.ejs" - ], + "files": "*.ejs", "options": { - "printWidth": 160, + "printWidth": 120, "bracketSameLine": true } } diff --git a/docs/pow-captcha.md b/docs/pow-captcha.md new file mode 100644 index 0000000..1ca9eae --- /dev/null +++ b/docs/pow-captcha.md @@ -0,0 +1,95 @@ +# Proof-of-work Verification + +You can require users to complete a proof-of-work before they can access the +proxy. This can increase the cost of denial of service attacks and slow down +automated abuse. + +When configured, users access the challenge UI and request a proof of work. The +server will generate a challenge according to the difficulty level you have set. +The user can then start the worker to solve the challenge. Once the challenge is +solved, the user can submit the solution to the server. The server will verify +the solution and issue a temporary token for that user. + +## Configuration + +To enable proof-of-work verification, set the following environment variables: + +``` +GATEKEEPER=user_token +CAPTCHA_MODE=proof_of_work +# Validity of the token in hours +POW_TOKEN_HOURS=24 +# Max number of IPs that can use a user_token issued via proof-of-work +POW_TOKEN_MAX_IPS=2 +# The difficulty level of the proof-of-work challenge +POW_DIFFICULTY_LEVEL=low +``` + +## Difficulty Levels + +The difficulty level controls how long it takes to solve the proof-of-work, +specifically by adjusting the average number of iterations required to find a +valid solution. Due to randomness, the actual number of iterations required can +vary significantly. + +You can adjust the difficulty while the proxy is running from the admin interface. + +### Extreme + +- Average of 4000 iterations required +- Not recommended unless you are expecting very high levels of abuse + +### High + +- Average of 1900 iterations required + +### Medium + +- Average of 900 iterations required + +### Low + +- Average of 200 iterations required +- Default setting. + +## Custom argon2id parameters + +You can set custom argon2id parameters for the proof-of-work challenge. +Generally, you should not need to change these unless you have a specific +reason to do so. + +The listed values are the defaults. + +``` +ARGON2_TIME_COST=8 +ARGON2_MEMORY_KB=65536 +ARGON2_PARALLELISM=1 +ARGON2_HASH_LENGTH=32 +``` + +Increasing parallelism will not do much except increase memory consumption for +both the client and server, because browser proof-of-work implementations are +single-threaded. It's better to increase the time cost if you want to increase +the difficulty. + +Increasing memory too much may cause memory exhaustion on some mobile devices, +particularly on iOS due to the way Safari handles WebAssembly memory allocation. + +## Tested hash rates + +These were measured with the default argon2id parameters listed above. These +tests were not at all scientific so take them with a grain of salt. + +Safari does not like large WASM memory usage, so concurrency is limited to 4 to +avoid overallocating memory on mobile WebKit browsers. Thermal throttling can +also significantly reduce hash rates on mobile devices. + +- Intel Core i9-13900K (Chrome): 33-35 H/s +- Intel Core i9-13900K (Firefox): 29-32 H/s +- Intel Core i9-13900K (Chrome, in VM limited to 4 cores): 12.2 - 13.0 H/s +- iPad Pro (M2) (Safari, 6 workers): 8.0 - 10 H/s + - Thermal throttles early. 8 cores is normal concurrency, but unstable. +- iPhone 13 Pro (Safari): 4.0 - 4.6 H/s +- Samsung Galaxy S10e (Chrome): 3.6 - 3.8 H/s + - This is a 2019 phone almost matching an iPhone five years newer because of + bad Safari performance. diff --git a/docs/user-management.md b/docs/user-management.md index f80d9de..930e980 100644 --- a/docs/user-management.md +++ b/docs/user-management.md @@ -12,6 +12,7 @@ Several of these features require you to set secrets in your environment. If usi - [Memory](#memory) - [Firebase Realtime Database](#firebase-realtime-database) - [Firebase setup instructions](#firebase-setup-instructions) +- [Whitelisting admin IP addresses](#whitelisting-admin-ip-addresses) ## No user management (`GATEKEEPER=none`) @@ -61,3 +62,12 @@ To use Firebase Realtime Database to persist user data, set the following enviro 8. Set `GATEKEEPER_STORE` to `firebase_rtdb` in your environment if you haven't already. The proxy server will attempt to connect to your Firebase Realtime Database at startup and will throw an error if it cannot connect. If you see this error, check that your `FIREBASE_RTDB_URL` and `FIREBASE_KEY` secrets are set correctly. + +## Whitelisting admin IP addresses +You can add your own IP ranges to the `ADMIN_WHITELIST` environment variable for additional security. + +You can provide a comma-separated list containing individual IPv4 or IPv6 addresses, or CIDR ranges. + +To whitelist an entire IP range, use CIDR notation. For example, `192.168.0.1/24` would whitelist all addresses from `192.168.0.0` to `192.168.0.255`. + +To disable the whitelist, set `ADMIN_WHITELIST=0.0.0.0/0`, which will allow access from any IP address. This is the default behavior. diff --git a/package-lock.json b/package-lock.json index 933f950..e938090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@aws-crypto/sha256-js": "^5.2.0", + "@node-rs/argon2": "^1.8.3", "@smithy/eventstream-codec": "^2.1.3", "@smithy/eventstream-serde-node": "^2.1.3", "@smithy/protocol-http": "^3.2.1", @@ -31,6 +32,7 @@ "glob": "^10.3.12", "googleapis": "^122.0.0", "http-proxy-middleware": "^3.0.0-beta.1", + "ipaddr.js": "^2.1.0", "memorystore": "^1.6.7", "multer": "^1.4.5-lts.1", "node-schedule": "^2.1.1", @@ -65,6 +67,7 @@ "nodemon": "^3.0.1", "pino-pretty": "^10.2.3", "prettier": "^3.0.3", + "prettier-plugin-ejs": "^1.0.3", "ts-node": "^10.9.1", "typescript": "^5.4.2" }, @@ -166,6 +169,24 @@ "node": ">=12" } }, + "node_modules/@emnapi/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.1.1.tgz", + "integrity": "sha512-eu4KjHfXg3I+UUR7vSuwZXpRo4c8h4Rtb5Lu2F7Z4JqJFl/eidquONEBiRs6viXKpWBC3BaJBy68xGJ2j56idw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.1.1.tgz", + "integrity": "sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.17.16", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.16.tgz", @@ -970,6 +991,251 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", + "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@node-rs/argon2": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-1.8.3.tgz", + "integrity": "sha512-sf/QAEI59hsMEEE2J8vO4hKrXrv4Oplte3KI2N4MhMDYpytH0drkVfErmHBfWFZxxIEK03fX1WsBNswS2nIZKg==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "1.8.3", + "@node-rs/argon2-android-arm64": "1.8.3", + "@node-rs/argon2-darwin-arm64": "1.8.3", + "@node-rs/argon2-darwin-x64": "1.8.3", + "@node-rs/argon2-freebsd-x64": "1.8.3", + "@node-rs/argon2-linux-arm-gnueabihf": "1.8.3", + "@node-rs/argon2-linux-arm64-gnu": "1.8.3", + "@node-rs/argon2-linux-arm64-musl": "1.8.3", + "@node-rs/argon2-linux-x64-gnu": "1.8.3", + "@node-rs/argon2-linux-x64-musl": "1.8.3", + "@node-rs/argon2-wasm32-wasi": "1.8.3", + "@node-rs/argon2-win32-arm64-msvc": "1.8.3", + "@node-rs/argon2-win32-ia32-msvc": "1.8.3", + "@node-rs/argon2-win32-x64-msvc": "1.8.3" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-1.8.3.tgz", + "integrity": "sha512-JFZPlNM0A8Og+Tncb8UZsQrhEMlbHBXPsT3hRoKImzVmTmq28Os0ucFWow0AACp2coLHBSydXH3Dh0lZup3rWw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-1.8.3.tgz", + "integrity": "sha512-zaf8P3T92caeW2xnMA7P1QvRA4pIt/04oilYP44XlTCtMye//vwXDMeK53sl7dvYiJKnzAWDRx41k8vZvpZazg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-1.8.3.tgz", + "integrity": "sha512-DV/IbmLGdNXBtXb5o2UI5ba6kvqXqPAJgmMOTUCuHeBSp992GlLHdfU4rzGu0dNrxudBnunNZv+crd0YdEQSUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-1.8.3.tgz", + "integrity": "sha512-YMjmBGFZhLfYjfQ2gll9A+BZu/zAMV7lWZIbKxb7ZgEofILQwuGmExjDtY3Jplido/6leCEdpmlk2oIsME00LA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-1.8.3.tgz", + "integrity": "sha512-Hq3Rj5Yb2RolTG/luRPnv+XiGCbi5nAK25Pc8ou/tVapwX+iktEm/NXbxc5zsMxraYVkCvfdwBjweC5O+KqCGw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-1.8.3.tgz", + "integrity": "sha512-x49l8RgzKoG0/V0IXa5rrEl1TcJEc936ctlYFvqcunSOyowZ6kiWtrp1qrbOR8gbaNILl11KTF52vF6+h8UlEQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-1.8.3.tgz", + "integrity": "sha512-gJesam/qA63reGkb9qJ2TjFSLBtY41zQh2oei7nfnYsmVQPuHHWItJxEa1Bm21SPW53gZex4jFJbDIgj0+PxIw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-1.8.3.tgz", + "integrity": "sha512-7O6kQdSKzB4Tjx/EBa8zKIxnmLkQE8VdJgPm6Ksrpn+ueo0mx2xf76fIDnbbTCtm3UbB+y+FkTo2wLA7tOqIKg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-1.8.3.tgz", + "integrity": "sha512-OBH+EFG7BGjFyldaao2H2gSCLmjtrrwf420B1L+lFn7JLW9UAjsIPFKAcWsYwPa/PwYzIge9Y7SGcpqlsSEX0w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-1.8.3.tgz", + "integrity": "sha512-bDbMuyekIxZaN7NaX+gHVkOyABB8bcMEJYeRPW1vCXKHj3brJns1wiUFSxqeUXreupifNVJlQfPt1Y5B/vFXgQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-wasm32-wasi": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-1.8.3.tgz", + "integrity": "sha512-NBf2cMCDbNKMzp13Pog8ZPmI0M9U4Ak5b95EUjkp17kdKZFds12dwW67EMnj7Zy+pRqby2QLECaWebDYfNENTg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-1.8.3.tgz", + "integrity": "sha512-AHpPo7UbdW5WWjwreVpgFSY0o1RY4A7cUFaqDXZB2OqEuyrhMxBdZct9PX7PQKI18D85pLsODnR+gvVuTwJ6rQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-1.8.3.tgz", + "integrity": "sha512-bqzn2rcQkEwCINefhm69ttBVVkgHJb/V03DdBKsPFtiX6H47axXKz62d1imi26zFXhOEYxhKbu3js03GobJOLw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-1.8.3.tgz", + "integrity": "sha512-ILlrRThdbp5xNR5gwYM2ic1n/vG5rJ8dQZ+YMRqksl+lnTJ/6FDe5BOyIhiPtiDwlCiCtUA+1NxpDB9KlUCAIA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1223,6 +1489,15 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -3610,11 +3885,11 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-arrayish": { @@ -4644,6 +4919,15 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-ejs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/prettier-plugin-ejs/-/prettier-plugin-ejs-1.0.3.tgz", + "integrity": "sha512-wTL4U/ou6dHHp1ZTfS67SHVb/dRgVhpIOTgCvkgdqF/Lw6A472W90dxFtsWSXIR7GmLZRgZb2PdArR9ozxX7cg==", + "dev": true, + "peerDependencies": { + "prettier": "2.x - 3.x" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4710,6 +4994,14 @@ "node": ">= 0.10" } }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/package.json b/package.json index da633e2..1b606eb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@aws-crypto/sha256-js": "^5.2.0", + "@node-rs/argon2": "^1.8.3", "@smithy/eventstream-codec": "^2.1.3", "@smithy/eventstream-serde-node": "^2.1.3", "@smithy/protocol-http": "^3.2.1", @@ -39,6 +40,7 @@ "glob": "^10.3.12", "googleapis": "^122.0.0", "http-proxy-middleware": "^3.0.0-beta.1", + "ipaddr.js": "^2.1.0", "memorystore": "^1.6.7", "multer": "^1.4.5-lts.1", "node-schedule": "^2.1.1", @@ -73,6 +75,7 @@ "nodemon": "^3.0.1", "pino-pretty": "^10.2.3", "prettier": "^3.0.3", + "prettier-plugin-ejs": "^1.0.3", "ts-node": "^10.9.1", "typescript": "^5.4.2" }, diff --git a/public/js/hash-worker.js b/public/js/hash-worker.js new file mode 100644 index 0000000..32775b6 --- /dev/null +++ b/public/js/hash-worker.js @@ -0,0 +1,121 @@ +importScripts( + "https://cdn.jsdelivr.net/npm/hash-wasm@4.11.0/dist/argon2.umd.min.js" +); + +let active = false; +let nonce = 0; +let signature = ""; +let lastNotify = 0; +let hashesSinceLastNotify = 0; +let params = { + salt: null, + hashLength: 0, + iterations: 0, + memorySize: 0, + parallelism: 0, + targetValue: BigInt(0), + safariFix: false, +}; + +self.onmessage = async (event) => { + const { data } = event; + switch (data.type) { + case "stop": + active = false; + self.postMessage({ type: "paused", hashes: hashesSinceLastNotify }); + return; + case "start": + active = true; + signature = data.signature; + nonce = data.nonce; + + const c = data.challenge; + // decode salt to Uint8Array + const salt = new Uint8Array(c.s.length / 2); + for (let i = 0; i < c.s.length; i += 2) { + salt[i / 2] = parseInt(c.s.slice(i, i + 2), 16); + } + + params = { + salt: salt, + hashLength: c.hl, + iterations: c.t, + memorySize: c.m, + parallelism: c.p, + targetValue: BigInt(c.d.slice(0, -1)), + safariFix: data.isMobileWebkit, + }; + + console.log("Started", params); + self.postMessage({ type: "started" }); + setTimeout(solve, 0); + break; + } +}; + +const doHash = async (password) => { + const { salt, hashLength, iterations, memorySize, parallelism } = params; + return await self.hashwasm.argon2id({ + password, + salt, + hashLength, + iterations, + memorySize, + parallelism, + }); +}; + +const checkHash = (hash) => { + const { targetValue } = params; + const hashValue = BigInt(`0x${hash}`); + return hashValue <= targetValue; +}; + +const solve = async () => { + if (!active) { + console.log("Stopped solver", nonce); + return; + } + + // Safari WASM doesn't like multiple calls in one worker + const batchSize = 1; + const batch = []; + for (let i = 0; i < batchSize; i++) { + batch.push(nonce++); + } + + try { + const results = await Promise.all( + batch.map(async (nonce) => { + const hash = await doHash(String(nonce)); + return { hash, nonce }; + }) + ); + hashesSinceLastNotify += batchSize; + + const solution = results.find(({ hash }) => checkHash(hash)); + if (solution) { + console.log("Solution found", solution, params.salt); + self.postMessage({ type: "solved", nonce: solution.nonce }); + active = false; + } else { + if (Date.now() - lastNotify > 1000) { + console.log("Last nonce", nonce, "Hashes", hashesSinceLastNotify); + self.postMessage({ type: "progress", hashes: hashesSinceLastNotify }); + lastNotify = Date.now(); + hashesSinceLastNotify = 0; + } + setTimeout(solve, 10); + } + } catch (error) { + console.error("Error", error); + const stack = error.stack; + const debug = { + stack, + lastNonce: nonce, + targetValue: params.targetValue, + }; + self.postMessage({ type: "error", error: error.message, debug }); + active = false; + } +}; diff --git a/src/admin/routes.ts b/src/admin/routes.ts index fefc420..a377a9d 100644 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -1,17 +1,30 @@ import express, { Router } from "express"; -import { authorize } from "./auth"; +import { createWhitelistMiddleware } from "../shared/cidr"; import { HttpError } from "../shared/errors"; +import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf"; import { injectLocals } from "../shared/inject-locals"; import { withSession } from "../shared/with-session"; -import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf"; +import { config } from "../config"; import { renderPage } from "../info-page"; import { buildInfo } from "../service-info"; +import { authorize } from "./auth"; import { loginRouter } from "./login"; import { usersApiRouter as apiRouter } from "./api/users"; import { usersWebRouter as webRouter } from "./web/manage"; +import { logger } from "../logger"; const adminRouter = Router(); +const whitelist = createWhitelistMiddleware( + "ADMIN_WHITELIST", + config.adminWhitelist +); + +if (!whitelist.ranges.length && config.adminKey?.length) { + logger.error("ADMIN_WHITELIST is empty. No admin requests will be allowed. Set 0.0.0.0/0 to allow all."); +} + +adminRouter.use(whitelist); adminRouter.use( express.json({ limit: "20mb" }), express.urlencoded({ extended: true, limit: "20mb" }) diff --git a/src/admin/web/manage.ts b/src/admin/web/manage.ts index 296eb78..b47fe07 100644 --- a/src/admin/web/manage.ts +++ b/src/admin/web/manage.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import ipaddr from "ipaddr.js"; import multer from "multer"; import { z } from "zod"; import { config } from "../../config"; @@ -15,6 +16,7 @@ import { UserTokenCounts, } from "../../shared/users/schema"; import { getLastNImages } from "../../shared/file-storage/image-history"; +import { blacklists, parseCidrs, whitelists } from "../../shared/cidr"; const router = Router(); @@ -40,6 +42,74 @@ router.get("/create-user", (req, res) => { }); }); +router.get("/anti-abuse", (_req, res) => { + const wl = [...whitelists.entries()]; + const bl = [...blacklists.entries()]; + + res.render("admin_anti-abuse", { + captchaMode: config.captchaMode, + difficulty: config.powDifficultyLevel, + whitelists: wl.map((w) => ({ + name: w[0], + mode: "whitelist", + ranges: w[1].ranges, + })), + blacklists: bl.map((b) => ({ + name: b[0], + mode: "blacklist", + ranges: b[1].ranges, + })), + }); +}); + +router.post("/cidr", (req, res) => { + const body = req.body; + const valid = z + .object({ + action: z.enum(["add", "remove"]), + mode: z.enum(["whitelist", "blacklist"]), + name: z.string().min(1), + mask: z.string().min(1), + }) + .safeParse(body); + + if (!valid.success) { + throw new HttpError( + 400, + valid.error.issues.flatMap((issue) => issue.message).join(", ") + ); + } + + const { mode, name, mask } = valid.data; + const list = (mode === "whitelist" ? whitelists : blacklists).get(name); + if (!list) { + throw new HttpError(404, "List not found"); + } + if (valid.data.action === "remove") { + const newRanges = new Set(list.ranges); + newRanges.delete(mask); + list.updateRanges([...newRanges]); + req.session.flash = { + type: "success", + message: `${mode} ${name} updated`, + }; + return res.redirect("/admin/manage/anti-abuse"); + } else if (valid.data.action === "add") { + const result = parseCidrs(mask); + if (result.length === 0) { + throw new HttpError(400, "Invalid CIDR mask"); + } + + const newRanges = new Set([...list.ranges, mask]); + list.updateRanges([...newRanges]); + req.session.flash = { + type: "success", + message: `${mode} ${name} updated`, + }; + return res.redirect("/admin/manage/anti-abuse"); + } +}); + router.post("/create-user", (req, res) => { const body = req.body; @@ -223,10 +293,14 @@ router.post("/maintenance", (req, res) => { break; } case "downloadImageMetadata": { - const data = JSON.stringify({ - exportedAt: new Date().toISOString(), - generations: getLastNImages() - }, null, 2); + const data = JSON.stringify( + { + exportedAt: new Date().toISOString(), + generations: getLastNImages(), + }, + null, + 2 + ); res.setHeader( "Content-Disposition", `attachment; filename=image-metadata-${new Date().toISOString()}.json` @@ -234,14 +308,125 @@ router.post("/maintenance", (req, res) => { res.setHeader("Content-Type", "application/json"); return res.send(data); } + case "expireTempTokens": { + const users = userStore.getUsers(); + const temps = users.filter((u) => u.type === "temporary"); + temps.forEach((user) => { + user.expiresAt = Date.now(); + userStore.upsertUser(user); + }); + flash.type = "success"; + flash.message = `${temps.length} temporary users marked for expiration.`; + break; + } + case "cleanTempTokens": { + const users = userStore.getUsers(); + const disabledTempUsers = users.filter( + (u) => u.type === "temporary" && u.expiresAt && u.expiresAt < Date.now() + ); + disabledTempUsers.forEach((user) => { + user.disabledAt = 1; //will be cleaned up by the next cron job + userStore.upsertUser(user); + }); + flash.type = "success"; + flash.message = `${disabledTempUsers.length} disabled temporary users marked for cleanup.`; + break; + } + case "setDifficulty": { + const selected = req.body["pow-difficulty"]; + const valid = ["low", "medium", "high", "extreme"]; + if (!selected || !valid.includes(selected)) { + throw new HttpError(400, "Invalid difficulty" + selected); + } + config.powDifficultyLevel = selected; + break; + } + case "generateTempIpReport": { + const tempUsers = userStore + .getUsers() + .filter((u) => u.type === "temporary"); + const ipv4RangeMap: Map> = new Map< + string, + Set + >(); + const ipv6RangeMap: Map> = new Map< + string, + Set + >(); + + tempUsers.forEach((u) => { + u.ip.forEach((ip) => { + try { + const parsed = ipaddr.parse(ip); + if (parsed.kind() === "ipv4") { + const subnet = + parsed.toNormalizedString().split(".").slice(0, 3).join(".") + + ".0/24"; + const userSet = ipv4RangeMap.get(subnet) || new Set(); + userSet.add(u.token); + ipv4RangeMap.set(subnet, userSet); + } else if (parsed.kind() === "ipv6") { + const subnet = + parsed.toNormalizedString().split(":").slice(0, 3).join(":") + + "::/56"; + const userSet = ipv6RangeMap.get(subnet) || new Set(); + userSet.add(u.token); + ipv6RangeMap.set(subnet, userSet); + } + } catch (e) { + req.log.warn( + { ip, error: e.message }, + "Invalid IP address; skipping" + ); + } + }); + }); + + const ipv4Ranges = Array.from(ipv4RangeMap.entries()) + .map(([subnet, userSet]) => ({ + subnet, + distinctTokens: userSet.size, + })) + .sort((a, b) => b.distinctTokens - a.distinctTokens); + + const ipv6Ranges = Array.from(ipv6RangeMap.entries()) + .map(([subnet, userSet]) => ({ + subnet, + distinctTokens: userSet.size, + })) + .sort((a, b) => { + if (a.distinctTokens === b.distinctTokens) { + return a.subnet.localeCompare(b.subnet); + } + return b.distinctTokens - a.distinctTokens; + }); + + const data = JSON.stringify( + { + exportedAt: new Date().toISOString(), + ipv4Ranges, + ipv6Ranges, + }, + null, + 2 + ); + + res.setHeader( + "Content-Disposition", + `attachment; filename=temp-ip-report-${new Date().toISOString()}.json` + ); + res.setHeader("Content-Type", "application/json"); + return res.send(data); + } default: { throw new HttpError(400, "Invalid action"); } } req.session.flash = flash; + const referer = req.get("referer"); - return res.redirect(`/admin/manage`); + return res.redirect(referer || "/admin/manage"); }); router.get("/download-stats", (_req, res) => { diff --git a/src/admin/web/views/admin_anti-abuse.ejs b/src/admin/web/views/admin_anti-abuse.ejs new file mode 100644 index 0000000..5efb9b1 --- /dev/null +++ b/src/admin/web/views/admin_anti-abuse.ejs @@ -0,0 +1,141 @@ +<%- include("partials/shared_header", { title: "Proof of Work Verification Settings - OAI Reverse Proxy Admin" }) %> + + +

Abuse Mitigation Settings

+
+

Proof-of-Work Verification

+

+ The Proof-of-Work difficulty level is used to determine how much work a client must perform to earn a temporary user + token. Higher difficulty levels require more work, which can help mitigate abuse by making it more expensive for + attackers to generate tokens. However, higher difficulty levels can also make it more difficult for legitimate users + to generate tokens. Refer to documentation for guidance. +

+ <%if (captchaMode === "none") { %> +

+ PoW verification is not enabled. Set CAPTCHA_MODE=proof_of_work to enable. +

+ <% } else { %> +

Difficulty Level

+
+ + Current: <%= difficulty %> + + +
+ <% } %> +
+ + + +
+

Manage Temporary User Tokens

+
+

+

+

+
+
+
+

IP Whitelists and Blacklists

+

+ You can specify IP ranges to whitelist or blacklist from accessing the proxy. Note that changes here are not + persisted across server restarts. If you want to make changes permanent, you can copy the values to your deployment + configuration. +

+

+ Entries can be specified as single addresses or + CIDR notation. IPv6 is + supported but not recommended for use with the current version of the proxy. +

+ <% for (let i = 0; i < whitelists.length; i++) { %> + <%- include("partials/admin-cidr-widget", { list: whitelists[i] }) %> + <% } %> + <% for (let i = 0; i < blacklists.length; i++) { %> + <%- include("partials/admin-cidr-widget", { list: blacklists[i] }) %> + <% } %> +
+ + + + + +
+
+ Copy environment variables +

+ If you have made changes with the UI, you can copy the values below to your deployment configuration to persist + them across server restarts. +

+
+    <% for (let i = 0; i < whitelists.length; i++) { %><%= whitelists[i].name %>=<%= whitelists[i].ranges.join(",") %><% } %>
+    <% for (let i = 0; i < blacklists.length; i++) { %><%= blacklists[i].name %>=<%= blacklists[i].ranges.join(",") %><% } %>
+    
+
+
+ + +<%- include("partials/admin-footer") %> diff --git a/src/admin/web/views/admin_index.ejs b/src/admin/web/views/admin_index.ejs index 96bd59a..61a10fd 100644 --- a/src/admin/web/views/admin_index.ejs +++ b/src/admin/web/views/admin_index.ejs @@ -25,6 +25,7 @@
  • Import Users
  • Export Users
  • Download Rentry Stats +
  • Abuse Mitigation Settings
  • Service Info
  • Maintenance

    diff --git a/src/admin/web/views/admin_list-users.ejs b/src/admin/web/views/admin_list-users.ejs index 3526c44..0c55659 100644 --- a/src/admin/web/views/admin_list-users.ejs +++ b/src/admin/web/views/admin_list-users.ejs @@ -6,7 +6,7 @@ <% } else { %> - +
    diff --git a/src/admin/web/views/admin_view-user.ejs b/src/admin/web/views/admin_view-user.ejs index de08c5a..d060aec 100644 --- a/src/admin/web/views/admin_view-user.ejs +++ b/src/admin/web/views/admin_view-user.ejs @@ -55,8 +55,9 @@ <% if (user.disabledAt) { %> <% } %> @@ -72,7 +73,8 @@ - <% } %> + <% if (user.meta) { %> + + + + + <% } %>
    User<%- user.disabledReason %> - โœ๏ธ + โœ๏ธ
    <%- include("partials/shared_user_ip_list", { user, shouldRedact: false }) %>
    Admin Note ๐Ÿ”’ + + Admin Note ๐Ÿ”’ <%- user.adminNote ?? "none" %> @@ -85,10 +87,16 @@ <%- user.expiresAt %>
    Meta<%- JSON.stringify(user.meta) %>
    - -<% } %> <%- include("partials/shared_quota-info", { quota, user }) %> +<% } %> +<%- include("partials/shared_quota-info", { quota, user }) %>

    Back to User List

    @@ -144,4 +153,5 @@ }); -<%- include("partials/admin-ban-xhr-script") %> <%- include("partials/admin-footer") %> +<%- include("partials/admin-ban-xhr-script") %> +<%- include("partials/admin-footer") %> diff --git a/src/admin/web/views/partials/admin-cidr-widget.ejs b/src/admin/web/views/partials/admin-cidr-widget.ejs new file mode 100644 index 0000000..3d7e22e --- /dev/null +++ b/src/admin/web/views/partials/admin-cidr-widget.ejs @@ -0,0 +1,13 @@ +

    + <%= list.name %> + (<%= list.mode %>) +

    +
      + <% list.ranges.forEach(function(mask) { %> +
    • + <%= mask %> + +
    • + <% }); %> +
    + diff --git a/src/config.ts b/src/config.ts index c7fb2a1..ed835b2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import dotenv from "dotenv"; import type firebase from "firebase-admin"; import path from "path"; @@ -107,9 +108,28 @@ type Config = { * `maxIpsPerUser` limit, or if only connections from new IPs are be rejected. */ maxIpsAutoBan: boolean; - /** Per-IP limit for requests per minute to text and chat models. */ + /** + * Which captcha verification mode to use. Requires `user_token` gatekeeper. + * Allows users to automatically obtain a token by solving a captcha. + * - `none`: No captcha verification; tokens are issued manually. + * - `proof_of_work`: Users must solve an Argon2 proof of work to obtain a + * temporary usertoken valid for a limited period. + */ + captchaMode: "none" | "proof_of_work"; + /** + * Duration in hours for which a PoW-issued temporary user token is valid. + */ + powTokenHours: number; + /** Maximum number of IPs allowed per PoW-issued temporary user token. */ + powTokenMaxIps: number; + /** + * Difficulty level for the proof-of-work. Refer to docs/pow-captcha.md for + * details on the available modes. + */ + powDifficultyLevel: "low" | "medium" | "high" | "extreme"; + /** Per-user limit for requests per minute to text and chat models. */ textModelRateLimit: number; - /** Per-IP limit for requests per minute to image generation models. */ + /** Per-user limit for requests per minute to image generation models. */ imageModelRateLimit: number; /** * For OpenAI, the maximum number of context tokens (prompt + max output) a @@ -264,6 +284,20 @@ type Config = { * A leading slash is required. */ proxyEndpointRoute: string; + /** + * If set, only requests from these IP addresses will be permitted to use the + * admin API and UI. Provide a comma-separated list of IP addresses or CIDR + * ranges. If not set, the admin API and UI will be open to all requests. + */ + adminWhitelist: string[]; + /** + * If set, requests from these IP addresses will be blocked from using the + * application. Provide a comma-separated list of IP addresses or CIDR ranges. + * If not set, no IP addresses will be blocked. + * + * Takes precedence over the adminWhitelist. + */ + ipBlacklist: string[]; }; // To change configs, create a file called .env in the root directory. @@ -283,7 +317,11 @@ export const config: Config = { gatekeeper: getEnvWithDefault("GATEKEEPER", "none"), gatekeeperStore: getEnvWithDefault("GATEKEEPER_STORE", "memory"), maxIpsPerUser: getEnvWithDefault("MAX_IPS_PER_USER", 0), - maxIpsAutoBan: getEnvWithDefault("MAX_IPS_AUTO_BAN", true), + maxIpsAutoBan: getEnvWithDefault("MAX_IPS_AUTO_BAN", false), + captchaMode: getEnvWithDefault("CAPTCHA_MODE", "none"), + powTokenHours: getEnvWithDefault("POW_TOKEN_HOURS", 24), + powTokenMaxIps: getEnvWithDefault("POW_TOKEN_MAX_IPS", 2), + powDifficultyLevel: getEnvWithDefault("POW_DIFFICULTY_LEVEL", "low"), firebaseRtdbUrl: getEnvWithDefault("FIREBASE_RTDB_URL", undefined), firebaseKey: getEnvWithDefault("FIREBASE_KEY", undefined), textModelRateLimit: getEnvWithDefault("TEXT_MODEL_RATE_LIMIT", 4), @@ -320,7 +358,7 @@ export const config: Config = { "azure-gpt4", "azure-gpt4-32k", "azure-gpt4-turbo", - "azure-gpt4o" + "azure-gpt4o", ]), rejectPhrases: parseCsv(getEnvWithDefault("REJECT_PHRASES", "")), rejectMessage: getEnvWithDefault( @@ -367,19 +405,44 @@ export const config: Config = { allowOpenAIToolUsage: getEnvWithDefault("ALLOW_OPENAI_TOOL_USAGE", false), allowImagePrompts: getEnvWithDefault("ALLOW_IMAGE_PROMPTS", false), proxyEndpointRoute: getEnvWithDefault("PROXY_ENDPOINT_ROUTE", "/proxy"), + adminWhitelist: parseCsv(getEnvWithDefault("ADMIN_WHITELIST", "0.0.0.0/0")), + ipBlacklist: parseCsv(getEnvWithDefault("IP_BLACKLIST", "")), } as const; -function generateCookieSecret() { +function generateSigningKey() { if (process.env.COOKIE_SECRET !== undefined) { + // legacy, replaced by SIGNING_KEY return process.env.COOKIE_SECRET; + } else if (process.env.SIGNING_KEY !== undefined) { + return process.env.SIGNING_KEY; } - const seed = "" + config.adminKey + config.openaiKey + config.anthropicKey; - const crypto = require("crypto"); + const secrets = [ + config.adminKey, + config.openaiKey, + config.anthropicKey, + config.googleAIKey, + config.mistralAIKey, + config.awsCredentials, + config.azureCredentials, + ]; + if (secrets.filter((s) => s).length === 0) { + startupLogger.warn( + "No SIGNING_KEY or secrets are set. All sessions, cookies, and proofs of work will be invalidated on restart." + ); + return crypto.randomBytes(32).toString("hex"); + } + + startupLogger.info("No SIGNING_KEY set; one will be generated from secrets."); + startupLogger.info( + "It's recommended to set SIGNING_KEY explicitly to ensure users' sessions and cookies always persist across restarts." + ); + const seed = secrets.map((s) => s || "n/a").join(""); return crypto.createHash("sha256").update(seed).digest("hex"); } -export const COOKIE_SECRET = generateCookieSecret(); +const signingKey = generateSigningKey(); +export const COOKIE_SECRET = signingKey; export async function assertConfigIsValid() { if (process.env.MODEL_RATE_LIMIT !== undefined) { @@ -413,15 +476,15 @@ export async function assertConfigIsValid() { ); } - if (config.gatekeeper === "proxy_key" && !config.proxyKey) { + if (config.captchaMode === "proof_of_work" && config.gatekeeper !== "user_token") { throw new Error( - "`proxy_key` gatekeeper mode requires a `PROXY_KEY` to be set." + "Captcha mode 'proof_of_work' requires gatekeeper mode 'user_token'." ); } - if (config.gatekeeper !== "proxy_key" && config.proxyKey) { + if (config.gatekeeper === "proxy_key" && !config.proxyKey) { throw new Error( - "`PROXY_KEY` is set, but gatekeeper mode is not `proxy_key`. Make sure to set `GATEKEEPER=proxy_key`." + "`proxy_key` gatekeeper mode requires a `PROXY_KEY` to be set." ); } @@ -494,6 +557,8 @@ export const OMITTED_KEYS = [ "allowedModelFamilies", "trustedProxies", "proxyEndpointRoute", + "adminWhitelist", + "ipBlacklist", ] satisfies (keyof Config)[]; type OmitKeys = (typeof OMITTED_KEYS)[number]; diff --git a/src/info-page.ts b/src/info-page.ts index 38d43b7..12c076c 100644 --- a/src/info-page.ts +++ b/src/info-page.ts @@ -90,9 +90,9 @@ export function renderPage(info: ServiceInfo) { ${headerHtml}
    + ${getSelfServiceLinks()}

    Service Info

    ${JSON.stringify(info, null, 2)}
    - ${getSelfServiceLinks()} `; } @@ -146,7 +146,11 @@ This proxy keeps full logs of all prompts and AI responses. Prompt logs are anon function getSelfServiceLinks() { if (config.gatekeeper !== "user_token") return ""; - return ``; + const links = [ + ["Request a user token", "/user/captcha",], + ["Check your user token", "/user/lookup",] + ] + return `
    ${links.map(([text, link]) => `${text}`).join(" / ")}

    `; } function getServerTitle() { diff --git a/src/server.ts b/src/server.ts index bfd1d25..d222314 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import pinoHttp from "pino-http"; import os from "os"; import childProcess from "child_process"; import { logger } from "./logger"; +import { createBlacklistMiddleware } from "./shared/cidr"; import { setupAssetsDir } from "./shared/file-storage/setup-assets-dir"; import { keyPool } from "./shared/key-management"; import { adminRouter } from "./admin/routes"; @@ -62,9 +63,17 @@ app.set("views", [ ]); app.use("/user_content", express.static(USER_ASSETS_DIR, { maxAge: "2h" })); +app.use( + "/res", + express.static(path.join(__dirname, "..", "public"), { etag: true }) +); app.get("/health", (_req, res) => res.sendStatus(200)); app.use(cors()); + +const blacklist = createBlacklistMiddleware("IP_BLACKLIST", config.ipBlacklist); +app.use(blacklist); + app.use(checkOrigin); app.use("/admin", adminRouter); diff --git a/src/service-info.ts b/src/service-info.ts index 57a60a2..a159eca 100644 --- a/src/service-info.ts +++ b/src/service-info.ts @@ -399,7 +399,7 @@ function addKeyToAggregates(k: KeyPoolKey) { // Ignore revoked keys for aws logging stats, but include keys where the // logging status is unknown. const countAsLogged = - k.lastChecked && !k.isDisabled && k.awsLoggingStatus !== "disabled"; + k.lastChecked && !k.isDisabled && k.awsLoggingStatus === "enabled"; increment(modelStats, `aws-claude__awsLogged`, countAsLogged ? 1 : 0); break; } @@ -448,7 +448,7 @@ function getInfoForFamily(family: ModelFamily): BaseFamilyInfo { const logged = modelStats.get(`${family}__awsLogged`) || 0; if (logged > 0) { info.privacy = config.allowAwsLogging - ? `${logged} active keys are potentially logged.` + ? `AWS logging verification inactive. Prompts could be logged.` : `${logged} active keys are potentially logged and can't be used. Set ALLOW_AWS_LOGGING=true to override.`; } } diff --git a/src/shared/cidr.ts b/src/shared/cidr.ts new file mode 100644 index 0000000..2674f5e --- /dev/null +++ b/src/shared/cidr.ts @@ -0,0 +1,99 @@ +import { Request, Response, NextFunction } from "express"; +import ipaddr, { IPv4, IPv6 } from "ipaddr.js"; +import { logger } from "../logger"; + +const log = logger.child({ module: "cidr" }); + +type IpCheckMiddleware = (( + req: Request, + res: Response, + next: NextFunction +) => void) & { + ranges: string[]; + updateRanges: (ranges: string[] | string) => void; +}; + +export const whitelists = new Map(); +export const blacklists = new Map(); + +export function parseCidrs(cidrs: string[] | string): [IPv4 | IPv6, number][] { + const list = Array.isArray(cidrs) + ? cidrs + : cidrs.split(",").map((s) => s.trim()); + return list + .map((input) => { + try { + if (input.includes("/")) { + return ipaddr.parseCIDR(input); + } else { + const ip = ipaddr.parse(input); + return ipaddr.parseCIDR( + `${input}/${ip.kind() === "ipv4" ? 32 : 128}` + ); + } + } catch (e) { + log.error({ input, error: e.message }, "Invalid CIDR mask; skipping"); + return null; + } + }) + .filter((cidr): cidr is [IPv4 | IPv6, number] => cidr !== null); +} + +export function createWhitelistMiddleware( + name: string, + base: string[] | string +) { + let cidrs: string[] = []; + let matchers: [IPv4 | IPv6, number][] = []; + + const middleware = (req: Request, res: Response, next: NextFunction) => { + const ip = ipaddr.process(req.ip); + const allowed = matchers.some((cidr) => ip.match(cidr)); + if (allowed) { + return next(); + } + req.log.warn({ ip: req.ip, list: name }, "Request denied by whitelist"); + res.status(403).json({ error: `Forbidden (by ${name})` }); + }; + middleware.ranges = [] as string[]; + middleware.updateRanges = (ranges: string[] | string) => { + cidrs = Array.isArray(ranges) ? ranges.slice() : [ranges]; + matchers = parseCidrs(cidrs); + log.info({ list: name, matchers }, "IP whitelist configured"); + middleware.ranges = cidrs; + }; + + middleware.updateRanges(base); + + whitelists.set(name, middleware); + return middleware; +} + +export function createBlacklistMiddleware( + name: string, + base: string[] | string +) { + let cidrs: string[] = []; + let matchers: [IPv4 | IPv6, number][] = []; + + const middleware = (req: Request, res: Response, next: NextFunction) => { + const ip = ipaddr.process(req.ip); + const denied = matchers.some((cidr) => ip.match(cidr)); + if (denied) { + req.log.warn({ ip: req.ip, list: name }, "Request denied by blacklist"); + return res.status(403).json({ error: `Forbidden (by ${name})` }); + } + return next(); + }; + middleware.ranges = [] as string[]; + middleware.updateRanges = (ranges: string[] | string) => { + cidrs = Array.isArray(ranges) ? ranges.slice() : [ranges]; + matchers = parseCidrs(cidrs); + log.info({ list: name, matchers }, "IP blacklist configured"); + middleware.ranges = cidrs; + }; + middleware.updateRanges(base); + + blacklists.set(name, middleware); + return middleware; +} diff --git a/src/shared/key-management/aws/checker.ts b/src/shared/key-management/aws/checker.ts index 164886d..e965770 100644 --- a/src/shared/key-management/aws/checker.ts +++ b/src/shared/key-management/aws/checker.ts @@ -6,9 +6,10 @@ import { URL } from "url"; import { KeyCheckerBase } from "../key-checker-base"; import type { AwsBedrockKey, AwsBedrockKeyProvider } from "./provider"; import { AwsBedrockModelFamily } from "../../models"; +import { config } from "../../../config"; const MIN_CHECK_INTERVAL = 3 * 1000; // 3 seconds -const KEY_CHECK_PERIOD = 30 * 60 * 1000; // 30 minutes +const KEY_CHECK_PERIOD = 90 * 60 * 1000; // 90 minutes const AMZ_HOST = process.env.AMZ_HOST || "bedrock-runtime.%REGION%.amazonaws.com"; const GET_CALLER_IDENTITY_URL = `https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15`; @@ -66,6 +67,15 @@ export class AwsKeyChecker extends KeyCheckerBase { const families: AwsBedrockModelFamily[] = []; if (claudeV2 || sonnet || haiku) families.push("aws-claude"); if (opus) families.push("aws-claude-opus"); + + if (families.length === 0) { + this.log.warn( + { key: key.hash }, + "Key does not have access to any models; disabling." + ); + return this.updateKey(key.hash, { isDisabled: true }); + } + this.updateKey(key.hash, { sonnetEnabled: sonnet, haikuEnabled: haiku, @@ -190,13 +200,14 @@ export class AwsKeyChecker extends KeyCheckerBase { const correctErrorType = errorType === "ValidationException"; const correctErrorMessage = errorMessage?.match(/max_tokens/); if (!correctErrorType || !correctErrorMessage) { - throw new AxiosError( - `Unexpected error when invoking model ${model}: ${errorMessage}`, - "AWS_ERROR", - response.config, - response.request, - response - ); + return false; + // throw new AxiosError( + // `Unexpected error when invoking model ${model}: ${errorMessage}`, + // "AWS_ERROR", + // response.config, + // response.request, + // response + // ); } this.log.debug( @@ -207,16 +218,22 @@ export class AwsKeyChecker extends KeyCheckerBase { } private async checkLoggingConfiguration(key: AwsBedrockKey) { + if (config.allowAwsLogging) { + // Don't check logging status if we're allowing it to reduce API calls. + this.updateKey(key.hash, { awsLoggingStatus: "unknown" }); + return true; + } + const creds = AwsKeyChecker.getCredentialsFromKey(key); - const config: AxiosRequestConfig = { + const req: AxiosRequestConfig = { method: "GET", url: GET_INVOCATION_LOGGING_CONFIG_URL(creds.region), headers: { accept: "application/json" }, validateStatus: () => true, }; - await AwsKeyChecker.signRequestForAws(config, key); + await AwsKeyChecker.signRequestForAws(req, key); const { data, status, headers } = - await axios.request(config); + await axios.request(req); let result: AwsBedrockKey["awsLoggingStatus"] = "unknown"; diff --git a/src/shared/key-management/aws/provider.ts b/src/shared/key-management/aws/provider.ts index 57c07d1..38699d1 100644 --- a/src/shared/key-management/aws/provider.ts +++ b/src/shared/key-management/aws/provider.ts @@ -98,7 +98,7 @@ export class AwsBedrockKeyProvider implements KeyProvider { public get(model: string) { const availableKeys = this.keys.filter((k) => { - const isNotLogged = k.awsLoggingStatus === "disabled"; + const isNotLogged = k.awsLoggingStatus !== "enabled"; const neededFamily = getAwsBedrockModelFamily(model); const needsSonnet = model.includes("sonnet") && neededFamily === "aws-claude"; diff --git a/src/shared/key-management/key-checker-base.ts b/src/shared/key-management/key-checker-base.ts index 752576b..196dd72 100644 --- a/src/shared/key-management/key-checker-base.ts +++ b/src/shared/key-management/key-checker-base.ts @@ -120,13 +120,16 @@ export abstract class KeyCheckerBase { this.lastCheck + this.minCheckInterval ); - const delay = nextCheck - Date.now(); + const baseDelay = nextCheck - Date.now(); + const jitter = (Math.random() - 0.5) * baseDelay * 0.5; + const jitteredDelay = Math.max(1000, baseDelay + jitter); + this.timeout = setTimeout( () => this.checkKey(oldestKey).then(() => this.scheduleNextCheck()), - delay + jitteredDelay ); checkLog.debug( - { key: oldestKey.hash, nextCheck: new Date(nextCheck), delay }, + { key: oldestKey.hash, nextCheck: new Date(nextCheck), jitteredDelay }, "Scheduled next recurring check." ); } diff --git a/src/shared/users/schema.ts b/src/shared/users/schema.ts index 2e99be1..fc9ff32 100644 --- a/src/shared/users/schema.ts +++ b/src/shared/users/schema.ts @@ -51,6 +51,7 @@ export const UserSchema = z maxIps: z.coerce.number().int().min(0).optional(), /** Private note about the user. */ adminNote: z.string().optional(), + meta: z.record(z.any()).optional(), }) .strict(); diff --git a/src/shared/users/user-store.ts b/src/shared/users/user-store.ts index 7c008fc..5c0474c 100644 --- a/src/shared/users/user-store.ts +++ b/src/shared/users/user-store.ts @@ -80,6 +80,7 @@ export function createUser(createOptions?: { tokenCounts: { ...INITIAL_TOKENS }, tokenLimits: createOptions?.tokenLimits ?? { ...config.tokenQuota }, createdAt: Date.now(), + meta: {}, }; if (createOptions?.type === "temporary") { @@ -123,6 +124,7 @@ export function upsertUser(user: UserUpdate) { tokenCounts: { ...INITIAL_TOKENS }, tokenLimits: { ...config.tokenQuota }, createdAt: Date.now(), + meta: {}, }; const updates: Partial = {}; @@ -274,6 +276,10 @@ export function disableUser(token: string, reason?: string) { if (!user) return; user.disabledAt = Date.now(); user.disabledReason = reason; + if (user.meta) { + // manually banned tokens cannot be refreshed + user.meta.refreshable = false; + } usersToFlush.add(token); } @@ -295,6 +301,10 @@ function cleanupExpiredTokens() { if (user.type !== "temporary") continue; if (user.expiresAt && user.expiresAt < now && !user.disabledAt) { disableUser(user.token, "Temporary token expired."); + if (!user.meta) { + user.meta = {}; + } + user.meta.refreshable = config.captchaMode !== "none"; disabled++; } if (user.disabledAt && user.disabledAt + 72 * 60 * 60 * 1000 < now) { diff --git a/src/shared/views/partials/shared_header.ejs b/src/shared/views/partials/shared_header.ejs index 2e5ebae..bb0d86a 100644 --- a/src/shared/views/partials/shared_header.ejs +++ b/src/shared/views/partials/shared_header.ejs @@ -1,24 +1,26 @@ - + - - + + + <%= title %> + + + diff --git a/src/user/web/views/user_request_token.ejs b/src/user/web/views/user_request_token.ejs new file mode 100644 index 0000000..1384aa0 --- /dev/null +++ b/src/user/web/views/user_request_token.ejs @@ -0,0 +1,114 @@ +<%- include("partials/shared_header", { title: "Request User Token" }) %> + + + +

    Request User Token

    +

    + You can request a temporary user token to use this proxy. The token will be valid for <%= tokenLifetime %> hours. +

    +<% if (keyRequired) { %> +
    +

    You need to supply the proxy password to request or refresh a token.

    +
    + + +
    +
    +<% } %> + +
    + + +
    +<%- include("partials/user_challenge_widget") %> + + +<%- include("partials/user_footer") %>