Temporary usertokens via proof-of-work challenge (khanon/oai-reverse-proxy!68)

This commit is contained in:
khanon 2024-05-19 16:31:56 +00:00
parent 930bac0072
commit 205ffa69ce
29 changed files with 1985 additions and 70 deletions

View File

@ -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

View File

@ -1,11 +1,10 @@
{
"plugins": ["prettier-plugin-ejs"],
"overrides": [
{
"files": [
"*.ejs"
],
"files": "*.ejs",
"options": {
"printWidth": 160,
"printWidth": 120,
"bracketSameLine": true
}
}

95
docs/pow-captcha.md Normal file
View File

@ -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.

View File

@ -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.

300
package-lock.json generated
View File

@ -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",

View File

@ -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"
},

121
public/js/hash-worker.js Normal file
View File

@ -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;
}
};

View File

@ -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" })

View File

@ -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({
const data = JSON.stringify(
{
exportedAt: new Date().toISOString(),
generations: getLastNImages()
}, null, 2);
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<string, Set<string>> = new Map<
string,
Set<string>
>();
const ipv6RangeMap: Map<string, Set<string>> = new Map<
string,
Set<string>
>();
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<string>();
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<string>();
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) => {

View File

@ -0,0 +1,141 @@
<%- include("partials/shared_header", { title: "Proof of Work Verification Settings - OAI Reverse Proxy Admin" }) %>
<style>
details {
margin-top: 1em;
}
details summary {
font-weight: bold;
cursor: pointer;
}
details p {
margin-left: 1em;
}
#token-manage {
width: 100%;
display: flex;
}
#token-manage button {
padding: 0.5em;
margin: 0 0.5em;
flex-grow: 1;
}
</style>
<h1>Abuse Mitigation Settings</h1>
<div>
<h2>Proof-of-Work Verification</h2>
<p>
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.
</p>
<%if (captchaMode === "none") { %>
<p>
<strong>PoW verification is not enabled. Set <code>CAPTCHA_MODE=proof_of_work</code> to enable.</strong>
</p>
<% } else { %>
<h3>Difficulty Level</h3>
<div>
<label for="difficulty">Difficulty Level:</label>
<span id="currentDifficulty">Current: <%= difficulty %></span>
<select name="difficulty" id="difficulty">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="extreme">Extreme</option>
</select>
<button onclick='doAction("setDifficulty")'>Update Difficulty</button>
</div>
<% } %>
<form id="maintenanceForm" action="/admin/manage/maintenance" method="post">
<input id="_csrf" type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input id="hiddenAction" type="hidden" name="action" value="" />
<input id="hiddenDifficulty" type="hidden" name="pow-difficulty" value="" />
</form>
<h3>Manage Temporary User Tokens</h3>
<div id="token-manage">
<p><button onclick='doAction("expireTempTokens")'>🕒 Expire All Temp Tokens</button></p>
<p><button onclick='doAction("cleanTempTokens")'>🧹 Delete Expired Temp Tokens</button></p>
<p><button onclick='doAction("generateTempIpReport")'>📊 Generate Temp Token IP Report</button></p>
</div>
</div>
<div>
<h2>IP Whitelists and Blacklists</h2>
<p>
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.
</p>
<p>
Entries can be specified as single addresses or
<a href="https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation">CIDR notation</a>. IPv6 is
supported but not recommended for use with the current version of the proxy.
</p>
<% 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] }) %>
<% } %>
<form action="/admin/manage/cidr" method="post" id="cidrForm">
<input id="_csrf" type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input type="hidden" name="action" value="add" />
<input type="hidden" name="name" value="" />
<input type="hidden" name="mode" value="" />
<input type="hidden" name="mask" value="" />
</form>
<details>
<summary>Copy environment variables</summary>
<p>
If you have made changes with the UI, you can copy the values below to your deployment configuration to persist
them across server restarts.
</p>
<pre>
<% 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(",") %><% } %>
</pre>
</details>
</div>
<script>
function doAction(action) {
document.getElementById("hiddenAction").value = action;
if (action === "setDifficulty") {
document.getElementById("hiddenDifficulty").value = document.getElementById("difficulty").value;
}
document.getElementById("maintenanceForm").submit();
}
function onAddCidr(event) {
const list = event.target.dataset;
const newMask = prompt("Enter the IP or CIDR range to add to the list:");
if (!newMask) {
return;
}
const form = document.getElementById("cidrForm");
form["action"].value = "add";
form["name"].value = list.name;
form["mode"].value = list.mode;
form["mask"].value = newMask;
form.submit();
}
function onRemoveCidr(event) {
const list = event.target.dataset;
const removeMask = event.target.dataset.mask;
if (!removeMask) {
return;
}
const form = document.getElementById("cidrForm");
form["action"].value = "remove";
form["name"].value = list.name;
form["mode"].value = list.mode;
form["mask"].value = removeMask;
form.submit();
}
</script>
<%- include("partials/admin-footer") %>

View File

@ -25,6 +25,7 @@
<li><a href="/admin/manage/import-users">Import Users</a></li>
<li><a href="/admin/manage/export-users">Export Users</a></li>
<li><a href="/admin/manage/download-stats">Download Rentry Stats</a>
<li><a href="/admin/manage/anti-abuse">Abuse Mitigation Settings</a></li>
<li><a href="/admin/service-info">Service Info</a></li>
</ul>
<h3>Maintenance</h3>

View File

@ -6,7 +6,7 @@
<% } else { %>
<input type="checkbox" id="toggle-nicknames" onchange="toggleNicknames()" />
<label for="toggle-nicknames">Show Nicknames</label>
<table class="striped">
<table class="striped" style="width: calc(100vw - 3em)">
<thead>
<tr>
<th>User</th>

View File

@ -55,8 +55,9 @@
<td><%- user.disabledReason %></td>
<% if (user.disabledAt) { %>
<td class="actions">
<a title="Edit" id="edit-disabledReason" href="#" data-field="disabledReason"
data-token="<%= user.token %>">✏️</a>
<a title="Edit" id="edit-disabledReason" href="#" data-field="disabledReason" data-token="<%= user.token %>"
>✏️</a
>
</td>
<% } %>
</tr>
@ -72,7 +73,8 @@
<td colspan="2"><%- include("partials/shared_user_ip_list", { user, shouldRedact: false }) %></td>
</tr>
<tr>
<th scope="row">Admin Note <span title="Unlike nickname, this is not visible to or editable by the user">🔒</span>
<th scope="row">
Admin Note <span title="Unlike nickname, this is not visible to or editable by the user">🔒</span>
</th>
<td><%- user.adminNote ?? "none" %></td>
<td class="actions">
@ -85,6 +87,12 @@
<td colspan="2"><%- user.expiresAt %></td>
</tr>
<% } %>
<% if (user.meta) { %>
<tr>
<th scope="row">Meta</th>
<td colspan="2"><%- JSON.stringify(user.meta) %></td>
</tr>
<% } %>
</tbody>
</table>
@ -102,7 +110,8 @@
<input type="hidden" name="_csrf" value="<%- csrfToken %>" />
<button type="submit" class="btn btn-primary">Refresh Quotas for User</button>
</form>
<% } %> <%- include("partials/shared_quota-info", { quota, user }) %>
<% } %>
<%- include("partials/shared_quota-info", { quota, user }) %>
<p><a href="/admin/manage/list-users">Back to User List</a></p>
@ -144,4 +153,5 @@
});
</script>
<%- include("partials/admin-ban-xhr-script") %> <%- include("partials/admin-footer") %>
<%- include("partials/admin-ban-xhr-script") %>
<%- include("partials/admin-footer") %>

View File

@ -0,0 +1,13 @@
<h3>
<%= list.name %>
(<%= list.mode %>)
</h3>
<ul>
<% list.ranges.forEach(function(mask) { %>
<li>
<%= mask %>
<button class="remove" data-mode="<%= list.mode %>" data-name="<%= list.name %>" data-mask="<%= mask %>" onclick="onRemoveCidr(event)">Remove</button>
</li>
<% }); %>
</ul>
<button class="add" data-mode="<%= list.mode %>" data-name="<%= list.name %>" onclick="onAddCidr(event)">Add</button>

View File

@ -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];

View File

@ -90,9 +90,9 @@ export function renderPage(info: ServiceInfo) {
<body>
${headerHtml}
<hr />
${getSelfServiceLinks()}
<h2>Service Info</h2>
<pre>${JSON.stringify(info, null, 2)}</pre>
${getSelfServiceLinks()}
</body>
</html>`;
}
@ -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 `<footer style="font-size: 0.8em;"><hr /><a target="_blank" href="/user/lookup">Check your user token info</a></footer>`;
const links = [
["Request a user token", "/user/captcha",],
["Check your user token", "/user/lookup",]
]
return `<div style="font-size: 0.8em;">${links.map(([text, link]) => `<a target="_blank" href="${link}">${text}</a>`).join(" / ")}</div><hr />`;
}
function getServerTitle() {

View File

@ -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);

View File

@ -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.`;
}
}

99
src/shared/cidr.ts Normal file
View File

@ -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<string, IpCheckMiddleware>();
export const blacklists = new Map<string, IpCheckMiddleware>();
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;
}

View File

@ -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<AwsBedrockKey> {
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<AwsBedrockKey> {
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<AwsBedrockKey> {
}
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<GetLoggingConfigResponse>(config);
await axios.request<GetLoggingConfigResponse>(req);
let result: AwsBedrockKey["awsLoggingStatus"] = "unknown";

View File

@ -98,7 +98,7 @@ export class AwsBedrockKeyProvider implements KeyProvider<AwsBedrockKey> {
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";

View File

@ -120,13 +120,16 @@ export abstract class KeyCheckerBase<TKey extends Key> {
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."
);
}

View File

@ -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();

View File

@ -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<User> = {};
@ -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) {

View File

@ -1,14 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="<%= csrfToken %>">
<meta charset="utf-8" />
<meta name="csrf-token" content="<%= csrfToken %>" />
<!-- prettier-ignore -->
<title><%= title %></title>
<style>
body {
font-family: sans-serif;
background-color: #f0f0f0;
padding: 1em;
max-width: 800px;
}
a:hover {
@ -41,9 +43,10 @@
border: 1px solid #ccc;
}
table.striped tr:nth-child(even) {
background-color: #eaeaea
background-color: #eaeaea;
}
table td, table th {
table td,
table th {
border: 1px solid #ccc;
padding: 0.25em 0.5em;
}
@ -66,11 +69,16 @@
background-color: #e0e6f6;
}
@media (max-width: 600px) {
@media (max-width: 800px) {
body {
padding: 0.5em;
}
table {
width: 100%;
}
table td, table th {
table td,
table th {
display: block;
width: 100%;
}
@ -82,11 +90,13 @@
color: #eee;
}
a:link, a:visited {
a:link,
a:visited {
color: #bbe;
}
a:link:hover, a:visited:hover {
a:link:hover,
a:visited:hover {
background-color: #446;
}
@ -102,5 +112,5 @@
</head>
<body>
<%- include("partials/shared_flash", { flashData: flash }) %>
</body>
</html>

View File

@ -2,6 +2,7 @@ import express, { Router } from "express";
import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf";
import { browseImagesRouter } from "./web/browse-images";
import { selfServiceRouter } from "./web/self-service";
import { powRouter } from "./web/pow-captcha";
import { injectLocals } from "../shared/inject-locals";
import { withSession } from "../shared/with-session";
import { config } from "../config";
@ -18,17 +19,25 @@ userRouter.use(injectLocals);
if (config.showRecentImages) {
userRouter.use(browseImagesRouter);
}
if (config.captchaMode !== "none") {
userRouter.use("/captcha", powRouter);
}
userRouter.use(selfServiceRouter);
userRouter.use(
(
err: Error,
_req: express.Request,
req: express.Request,
res: express.Response,
_next: express.NextFunction
) => {
const data: any = { message: err.message, stack: err.stack, status: 500 };
res.status(500).render("user_error", { ...data, flash: null });
if (req.accepts("json", "html") === "json") {
return res.status(500).json({ error: err.message });
} else {
return res.status(500).render("user_error", { ...data, flash: null });
}
}
);

326
src/user/web/pow-captcha.ts Normal file
View File

@ -0,0 +1,326 @@
import crypto from "crypto";
import express from "express";
import argon2 from "@node-rs/argon2";
import { z } from "zod";
import { createUser, getUser, upsertUser } from "../../shared/users/user-store";
import { config } from "../../config";
/** HMAC key for signing challenges; regenerated on startup */
const HMAC_KEY = crypto.randomBytes(32).toString("hex");
/** Expiry time for a challenge in milliseconds */
const POW_EXPIRY = 1000 * 60 * 30; // 30 minutes
/** Lockout time after verification in milliseconds */
const LOCKOUT_TIME = 1000 * 60; // 60 seconds
const argon2Params = {
ARGON2_TIME_COST: parseInt(process.env.ARGON2_TIME_COST || "8"),
ARGON2_MEMORY_KB: parseInt(process.env.ARGON2_MEMORY_KB || String(1024 * 64)),
ARGON2_PARALLELISM: parseInt(process.env.ARGON2_PARALLELISM || "1"),
ARGON2_HASH_LENGTH: parseInt(process.env.ARGON2_HASH_LENGTH || "32"),
};
/**
* Work factor for each difficulty. This is the expected number of hashes that
* will be computed to solve the challenge, on average. The actual number of
* hashes will vary due to randomness.
*/
const workFactors = { extreme: 4000, high: 1900, medium: 900, low: 200 };
type Challenge = {
/** Salt */
s: string;
/** Argon2 hash length */
hl: number;
/** Argon2 time cost */
t: number;
/** Argon2 memory cost */
m: number;
/** Argon2 parallelism */
p: number;
/** Challenge target value (difficulty) */
d: string;
/** Expiry time in milliseconds */
e: number;
/** IP address of the client */
ip?: string;
/** Challenge version */
v?: number;
/** Usertoken for refreshing */
token?: string;
};
const verifySchema = z.object({
challenge: z.object({
s: z
.string()
.min(1)
.max(64)
.regex(/^[0-9a-f]+$/),
hl: z.number().int().positive().max(64),
t: z.number().int().positive().min(2).max(10),
m: z
.number()
.int()
.positive()
.max(1024 * 1024 * 2),
p: z.number().int().positive().max(16),
d: z.string().regex(/^[0-9]+n$/),
e: z.number().int().positive(),
ip: z.string().min(1).max(64).optional(),
v: z.literal(1).optional(),
token: z.string().min(1).max(64).optional(),
}),
solution: z.string().min(1).max(64),
signature: z.string().min(1),
proxyKey: z.string().min(1).max(1024).optional(),
});
const challengeSchema = z.object({
action: z.union([z.literal("new"), z.literal("refresh")]),
refreshToken: z.string().min(1).max(64).optional(),
proxyKey: z.string().min(1).max(1024).optional(),
});
/** Solutions by timestamp */
const solves = new Map<string, number>();
/** Recent attempts by IP address */
const recentAttempts = new Map<string, number>();
setInterval(() => {
const now = Date.now();
for (const [ip, timestamp] of recentAttempts) {
if (now - timestamp > LOCKOUT_TIME) {
recentAttempts.delete(ip);
}
}
for (const [key, timestamp] of solves) {
if (now - timestamp > POW_EXPIRY) {
solves.delete(key);
}
}
}, 1000);
function generateChallenge(clientIp?: string, token?: string): Challenge {
let workFactor = workFactors[config.powDifficultyLevel];
if (token) {
// Challenge difficulty is reduced for token refreshes
workFactor = Math.floor(workFactor / 2);
}
const hashBits = BigInt(argon2Params.ARGON2_HASH_LENGTH) * 8n;
const hashMax = 2n ** hashBits;
const targetValue = hashMax / BigInt(workFactor);
return {
s: crypto.randomBytes(32).toString("hex"),
hl: argon2Params.ARGON2_HASH_LENGTH,
t: argon2Params.ARGON2_TIME_COST,
m: argon2Params.ARGON2_MEMORY_KB,
p: argon2Params.ARGON2_PARALLELISM,
d: targetValue.toString() + "n",
e: Date.now() + POW_EXPIRY,
ip: clientIp,
token,
};
}
function signMessage(msg: any): string {
const hmac = crypto.createHmac("sha256", HMAC_KEY);
if (typeof msg === "object") {
hmac.update(JSON.stringify(msg));
} else {
hmac.update(msg);
}
return hmac.digest("hex");
}
async function verifySolution(
challenge: Challenge,
solution: string,
logger: any
): Promise<boolean> {
logger.info({ solution, challenge }, "Verifying solution");
const hash = await argon2.hashRaw(String(solution), {
salt: Buffer.from(challenge.s, "hex"),
outputLen: challenge.hl,
timeCost: challenge.t,
memoryCost: challenge.m,
parallelism: challenge.p,
algorithm: argon2.Algorithm.Argon2id,
});
const hashStr = hash.toString("hex");
const target = BigInt(challenge.d.slice(0, -1));
const hashValue = BigInt("0x" + hashStr);
const result = hashValue <= target;
logger.info({ hashStr, target, hashValue, result }, "Solution verified");
return result;
}
function verifyTokenRefreshable(token?: string, logger?: any): boolean {
if (!token) {
logger?.warn("No token provided for refresh");
return false;
}
const user = getUser(token);
if (!user) {
logger?.warn({ token }, "No user found for token");
return false;
}
if (user.type !== "temporary") {
logger?.warn({ token }, "User is not temporary");
return false;
}
logger?.info(
{ token, refreshable: user.meta?.refreshable },
"Token refreshable"
);
return user.meta?.refreshable;
}
const router = express.Router();
router.post("/challenge", (req, res) => {
const data = challengeSchema.safeParse(req.body);
if (!data.success) {
res
.status(400)
.json({ error: "Invalid challenge request", details: data.error });
return;
}
const { action, refreshToken, proxyKey } = data.data;
if (config.proxyKey && proxyKey !== config.proxyKey) {
res.status(400).json({ error: "Invalid proxy password" });
return;
}
if (action === "refresh") {
if (verifyTokenRefreshable(refreshToken, req.log)) {
res
.status(400)
.json({
error: "Not allowed to refresh that token; request a new one",
});
return;
}
const challenge = generateChallenge(req.ip, refreshToken);
const signature = signMessage(challenge);
res.json({ challenge, signature });
} else {
const challenge = generateChallenge(req.ip);
const signature = signMessage(challenge);
res.json({ challenge, signature });
}
});
router.post("/verify", async (req, res) => {
const ip = req.ip;
req.log.info("Got verification request");
if (recentAttempts.has(ip)) {
res
.status(429)
.json({ error: "Rate limited; wait a minute before trying again" });
return;
}
const result = verifySchema.safeParse(req.body);
if (!result.success) {
res
.status(400)
.json({ error: "Invalid verify request", details: result.error });
return;
}
const { challenge, signature, solution } = result.data;
if (signMessage(challenge) !== signature) {
res.status(400).json({
error:
"Invalid signature; server may have restarted since challenge was issued. Please request a new challenge.",
});
return;
}
if (config.proxyKey && result.data.proxyKey !== config.proxyKey) {
res.status(401).json({ error: "Invalid proxy password" });
return;
}
if (challenge.ip && challenge.ip !== ip) {
req.log.warn("Attempt to verify from different IP address");
res.status(400).json({
error: "Solution must be verified from original IP address",
});
return;
}
if (solves.has(signature)) {
req.log.warn("Attempt to reuse signature");
res.status(400).json({ error: "Reused signature" });
return;
}
if (Date.now() > challenge.e) {
req.log.warn("Verification took too long");
res.status(400).json({ error: "Verification took too long" });
return;
}
if (challenge.token && !verifyTokenRefreshable(challenge.token, req.log)) {
res.status(400).json({ error: "Not allowed to refresh that usertoken" });
return;
}
recentAttempts.set(ip, Date.now());
try {
const success = await verifySolution(challenge, solution, req.log);
if (!success) {
req.log.warn("Solution failed verification");
res.status(400).json({ error: "Solution failed verification" });
return;
}
solves.set(signature, Date.now());
} catch (err) {
req.log.error(err, "Error verifying proof-of-work");
res.status(500).json({ error: "Internal error" });
return;
}
if (challenge.token) {
const user = getUser(challenge.token);
if (user) {
user.expiresAt = Date.now() + config.powTokenHours * 60 * 60 * 1000;
upsertUser(user);
req.log.info(
{ token: `...${challenge.token.slice(-5)}` },
"Token refreshed"
);
return res.json({ success: true, token: challenge.token });
}
} else {
const token = createUser({
type: "temporary",
expiresAt: Date.now() + config.powTokenHours * 60 * 60 * 1000,
});
upsertUser({
token,
ip: [ip],
maxIps: config.powTokenMaxIps,
meta: { refreshable: true },
});
req.log.info(
{ ip, token: `...${token.slice(-5)}` },
"Proof-of-work token issued"
);
return res.json({ success: true, token });
}
});
router.get("/", (_req, res) => {
res.render("user_request_token", {
keyRequired: !!config.proxyKey,
difficultyLevel: config.powDifficultyLevel,
tokenLifetime: config.powTokenHours,
tokenMaxIps: config.powTokenMaxIps,
});
});
export { router as powRouter };

View File

@ -0,0 +1,353 @@
<noscript>
<p style="color: darkorange; background-color: #ffeecc; padding: 1em">
JavaScript needs to be enabled to complete verification.
</p>
</noscript>
<style>
#captcha-container {
max-width: 500px;
margin: 50px auto;
}
@media (max-width: 1000px) {
#captcha-container {
margin: 20px;
}
}
#captcha-container p {
padding: 2px;
line-height: 1.5;
}
#captcha-container details {
margin: 10px 0;
}
#captcha-container details p {
margin-left: 20px;
}
#captcha-container details li {
margin: 2px 0 0 20px;
line-height: 1.5;
}
#captcha-control {
display: flex;
flex-direction: column;
justify-content: space-between;
}
#captcha-control button {
flex-grow: 1;
margin: 10px;
padding: 10px 20px;
}
#captcha-progress-container {
margin: 20px 0;
}
#captcha-progress-container textarea {
margin-top: 5px;
background-color: transparent;
color: inherit;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #e0e6f6;
border-radius: 5px;
overflow: hidden;
}
.progress {
width: 0;
height: 100%;
background-color: #76c7c0;
transition: width 0.2s;
}
</style>
<div style="display: none" id="captcha-container">
<p>
Your device needs to perform a verification task before a user token will be issued. This verification might take
anywhere from a few seconds to a few minutes, depending on your device and the proxy's security settings.
</p>
<p>Click the button below to start.</p>
<details>
<summary>What is this?</summary>
<p>
This is an anti-abuse measure to slow down automated requests. It requires your device's CPU to find a solution to
a cryptographic puzzle, after which a user token will be issued.
</p>
<p>
Your browser may slow down during the verification process. If you want to do something else while waiting, reduce
the number of workers to reduce the load on your device's CPU.
</p>
</details>
<details>
<summary>How long does verification take?</summary>
<p>
The exact time depends on the device you're using and the server's difficulty setting (currently
<strong><%= difficultyLevel %></strong>). It could take anywhere from a few seconds to a few minutes.
</p>
</details>
<details>
<summary>How often do I need to do this?</summary>
<p>Once you've earned a user token, you can use it for <%= tokenLifetime %> hours before it expires.</p>
<p>
You can refresh an expired token by returning to this page and completing the verification again, which will be
faster than the first time.
</p>
</details>
<details>
<summary>Other important information</summary>
<ul>
<li>Verification must be submitted from the same device and IP address that started the verification.</li>
<li>Don't close this tab until verification is complete or you will need to start over.</li>
<li>You can pause the task, but verification must be finished <strong>within 30 minutes</strong> of issuance.</li>
<li>
Up to <strong><%= tokenMaxIps || "unlimited" %></strong> IP addresses can be associated with a user token at
once.
</li>
<li>JavaScript is required to complete verification.</li>
<li>If the proxy is restarted, any verification tasks currently in progress will be invalidated.</li>
</ul>
</details>
<form id="captcha-form" style="display: none">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input type="hidden" name="tokenLifetime" value="<%= tokenLifetime %>" />
</form>
<div id="captcha-control">
<div>
<label for="workers">Workers:</label>
<input type="number" id="workers" value="1" min="1" max="16" onchange="spawnWorkers()" />
</div>
<button id="worker-control" onclick="toggleWorker()">Start verification</button>
</div>
<div id="captcha-progress-container" style="display: none">
<label for="captcha-progress-text">Status:</label>
<div id="captcha-progress" class="progress-bar">
<div class="progress"></div>
</div>
<textarea disabled id="captcha-progress-text" style="width: 100%; height: 100px; resize: none"></textarea>
</div>
<div id="captcha-result"></div>
</div>
<script>
let workers = [];
let challenge = null;
let signature = null;
let solution = null;
let totalHashes = 0;
let startTime = 0;
let lastUpdateTime = 0;
let reports = 0;
let elapsedTime = 0;
let workFactor = 0;
let active = false;
// Safari is all kinds of fucked and throws WASM Memory errors when memory
// pressure is high. Batch size and worker count need to be reduced to prevent
// this.
function isIOSiPadOSWebKit() {
const userAgent = navigator.userAgent;
const isWebKit = userAgent.includes("Safari") && !userAgent.includes("Chrome") && !userAgent.includes("Android");
const isIOS =
/iPad|iPhone|iPod/.test(navigator.platform) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
return isWebKit && isIOS;
}
let isMobileWebkit = isIOSiPadOSWebKit();
function handleWorkerMessage(e) {
switch (e.data.type) {
case "progress":
totalHashes += e.data.hashes;
reports++;
break;
case "started":
active = true;
document.getElementById("worker-control").textContent = "Pause verification";
startTime = Date.now();
lastUpdateTime = startTime;
document.getElementById("captcha-progress-container").style.display = "block";
break;
case "paused":
active = false;
document.getElementById("worker-control").textContent = "Start verification";
document.getElementById("workers").disabled = false;
break;
case "solved":
if (solution) {
return;
}
workers.forEach((w) => w.postMessage({ type: "stop" }));
active = false;
solution = e.data.nonce;
document.getElementById("captcha-result").textContent =
"Verification completed. Submitting solution for verification...";
document.getElementById("captcha-control").style.display = "none";
submitVerification();
break;
case "error":
workers.forEach((w) => w.postMessage({ type: "stop" }));
active = false;
const msg = e.data.error || "An unknown error occurred.";
const debug = e.data.debug || "";
document.getElementById("captcha-result").innerHTML = `
<p style="color:red">Error: ${msg}</p>
<pre style="color: red">${debug.stack}</pre>
<pre style="color: red">${debug.lastNonce}, ${String(debug.targetValue)}</pre>
<p>Refresh the page and try again. Use another device or browser if the problem persists, or lower the number of workers.</p>
`;
break;
}
estimateProgress();
}
function loadNewChallenge(c, s) {
const btn = document.getElementById("worker-control");
btn.textContent = "Start verification";
document.getElementById("captcha-container").style.display = "block";
document.getElementById("workers").disabled = false;
const maxWorkers = isMobileWebkit ? 6 : 16;
document.getElementById("workers").value = Math.min(maxWorkers, navigator.hardwareConcurrency || 4).toString();
challenge = c;
signature = s;
solution = null;
nonce = 0;
startTime = 0;
lastUpdateTime = 0;
elapsedTime = 0;
const targetValue = challenge.d.slice(0, -1);
const hashLength = challenge.hl;
workFactor = Number(BigInt(2) ** BigInt(8 * hashLength) / BigInt(targetValue));
spawnWorkers();
}
function spawnWorkers() {
for (const worker of workers) {
worker.terminate();
}
workers = [];
const selectedWorkers = document.getElementById("workers").value;
const workerCount = Math.min(16, Math.max(1, parseInt(selectedWorkers)));
for (let i = 0; i < workerCount; i++) {
const worker = new Worker("/res/js/hash-worker.js");
worker.onmessage = handleWorkerMessage;
workers.push(worker);
}
}
function toggleWorker() {
if (active) {
workers.forEach((w) => w.postMessage({ type: "stop" }));
} else {
const workerCount = workers.length;
const hashSpace = BigInt(challenge.hl * 8) ** BigInt(2);
const workerSpace = hashSpace / BigInt(workerCount);
const alreadyHashed = Math.floor(totalHashes / workerCount);
document.getElementById("workers").disabled = true;
for (let i = 0; i < workerCount; i++) {
const startNonce = workerSpace * BigInt(i) + BigInt(alreadyHashed);
workers[i].postMessage({
type: "start",
challenge: challenge,
signature: signature,
nonce: startNonce,
isMobileWebkit,
});
}
}
}
function submitVerification() {
if (!solution) {
return;
}
const body = {
challenge: challenge,
signature: signature,
solution: String(solution),
_csrf: document.querySelector("meta[name=csrf-token]").getAttribute("content"),
};
fetch("/user/captcha/verify", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
document.getElementById("captcha-result").textContent = "Error: " + data.error;
} else {
const lifetime = document.getElementById("captcha-form").querySelector('input[name="tokenLifetime"]').value;
window.localStorage.setItem(
"captcha-temp-token",
JSON.stringify({
token: data.token,
expires: Date.now() + lifetime * 3600 * 1000,
})
);
document.getElementById("captcha-progress").style.display = "none";
document.getElementById("captcha-result").innerHTML = `
<p style="color: green">Verification complete</p>
<p>Your user token is: <code>${data.token}</code></p>
<p>Valid until: ${new Date(Date.now() + lifetime * 3600 * 1000).toLocaleString()}</p>
`;
}
});
}
function estimateProgress() {
if (reports % workers.length !== 0) {
return;
}
elapsedTime += (Date.now() - lastUpdateTime) / 1000;
lastUpdateTime = Date.now();
const hashRate = totalHashes / elapsedTime;
const expectedTimeRemaining = (workFactor - totalHashes) / hashRate;
const progress = 100 * (1 - Math.exp(-totalHashes / workFactor));
const formatTime = (time) => {
if (time < 60) {
return time.toFixed(1) + "s";
} else if (time < 3600) {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return minutes + "m " + seconds + "s";
} else {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
return hours + "h " + minutes + "m";
}
};
const p = 1 / workFactor;
const odds = ((1 - p) ** totalHashes * 100).toFixed(2);
let note = "";
if (odds < 33) {
note = " (" + odds + "% odds of no solution yet)";
}
document.getElementById("captcha-progress").style.width = Math.min(progress, 100) + "%";
document.getElementById("captcha-progress-text").value = `
Average hashes needed: ${workFactor.toLocaleString()}
Hashes computed: ${totalHashes.toLocaleString()}${note}
Elapsed time: ${formatTime(elapsedTime)}
Hash rate: ${hashRate.toFixed(2)} H/s
Workers: ${workers.length}${isMobileWebkit ? " (iOS/iPadOS detected)" : ""}
${active ? `Approx. time remaining: ${formatTime(expectedTimeRemaining)}` : "Verification task stopped"}`.trim();
}
</script>

View File

@ -0,0 +1,114 @@
<%- include("partials/shared_header", { title: "Request User Token" }) %>
<style>
#request-buttons {
display: flex;
justify-content: space-between;
margin-top: 20px;
width: 400px;
}
#request-buttons button {
margin: 0 10px;
flex: 1;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
</style>
<h1>Request User Token</h1>
<p>
You can request a temporary user token to use this proxy. The token will be valid for <%= tokenLifetime %> hours.
</p>
<% if (keyRequired) { %>
<div>
<p>You need to supply the proxy password to request or refresh a token.</p>
<div>
<label for="proxy-key">Proxy password:</label>
<input type="password" id="proxy-key" />
</div>
</div>
<% } %>
<div id="existing-token" style="display: none">
<p>
It looks like you might have an older temporary user token. You can refresh its expiration by completing a
faster verification challenge.
</p>
<strong id="existing-token-value">Existing token:</strong>
</div>
<div id="request-buttons">
<button disabled id="refresh-token" onclick="requestChallenge('refresh')">Refresh old token</button>
<button id="request_token" onclick="requestChallenge('new')">Request new token</button>
</div>
<%- include("partials/user_challenge_widget") %>
<script>
function requestChallenge(action) {
const token = localStorage.getItem("captcha-temp-token");
if (token && action === "new") {
const data = JSON.parse(token);
const { expires } = data;
const expiresDate = new Date(expires);
const now = new Date();
if (expiresDate > now) {
if (!confirm("You already have an existing token. Are you sure you want to request a new one?")) {
return;
}
localStorage.removeItem("captcha-temp-token");
document.getElementById("existing-token").style.display = "none";
document.getElementById("refresh-token").disabled = true;
}
} else if (!token && action === "refresh") {
alert("You don't have an existing token to refresh");
return;
}
const refreshToken = token && action === "refresh" ? JSON.parse(token).token : undefined;
const keyInput = document.getElementById("proxy-key");
const proxyKey = (keyInput && keyInput.value) || undefined;
localStorage.setItem("captcha-proxy-key", proxyKey);
fetch("/user/captcha/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, proxyKey, refreshToken, _csrf: "<%= csrfToken %>" }),
})
.then((response) => response.json())
.then(function (data) {
if (data.error) {
throw new Error(data.error);
}
const { challenge, signature } = data;
loadNewChallenge(challenge, signature);
document.getElementById("request-buttons").style.display = "none";
})
.catch(function (error) {
console.error(error);
alert(`Error getting verification - ${error.message}`);
});
}
const existingToken = localStorage.getItem("captcha-temp-token");
if (existingToken) {
const data = JSON.parse(existingToken);
const { token, expires } = data;
const expiresDate = new Date(expires);
const now = new Date();
if (expiresDate > now) {
document.getElementById(
"existing-token-value"
).textContent = `Your token: ${token} (valid until ${expiresDate.toLocaleString()})`;
document.getElementById("existing-token").style.display = "block";
document.getElementById("refresh-token").disabled = false;
} else {
localStorage.removeItem("captcha-temp-token");
}
}
const proxyKey = localStorage.getItem("captcha-proxy-key");
if (proxyKey && document.getElementById("proxy-key")) {
document.getElementById("proxy-key").value = proxyKey;
}
</script>
<%- include("partials/user_footer") %>