diff --git a/README.md b/README.md index 060c2b2..1c69650 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # simple-proxy Simple reverse proxy to bypass CORS, used by [movie-web](https://movie-web.app). - -[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/movie-web/simple-proxy) +Read the docs at https://docs.movie-web.app/proxy --- @@ -10,6 +9,7 @@ Simple reverse proxy to bypass CORS, used by [movie-web](https://movie-web.app). - Deployable on many platforms - thanks to nitro - header rewrites - read and write protected headers - bypass CORS - always allows browser to send requests through it + - secure it with turnstile - prevent bots from using your proxy ### supported platforms: - cloudflare workers diff --git a/package.json b/package.json index 6bed52a..eb26a3d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "preinstall": "npx only-allow pnpm" }, "dependencies": { + "@tsndr/cloudflare-worker-jwt": "^2.3.2", "h3": "^1.8.1", "nitropack": "latest" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b2c18d..2c6251a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@tsndr/cloudflare-worker-jwt': + specifier: ^2.3.2 + version: 2.3.2 h3: specifier: ^1.8.1 version: 1.8.1 @@ -701,6 +704,10 @@ packages: rollup: 3.29.1 dev: false + /@tsndr/cloudflare-worker-jwt@2.3.2: + resolution: {integrity: sha512-g1jSm5olPqKh15kadnj0666YPudibHYGyFyM0URLXSeY5MzNIGkfhFedLgKHq8NCDBMzLUMX7Oz8d+jmQXqBuw==} + dev: false + /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} dev: false diff --git a/src/routes/index.ts b/src/routes/index.ts index 7fd6d54..07adbac 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,11 @@ import { getAfterResponseHeaders, cleanupHeadersBeforeProxy, } from '@/utils/headers'; +import { + createTokenIfNeeded, + isAllowedToMakeRequest, + setTokenHeader, +} from '@/utils/turnstile'; export default defineEventHandler(async (event) => { // handle cors, if applicable @@ -14,14 +19,24 @@ export default defineEventHandler(async (event) => { if (!destination) return await sendJson({ event, - status: 400, + status: 200, data: { - error: 'destination query parameter invalid', + error: 'Proxy is working as expected', + }, + }); + + if (!(await isAllowedToMakeRequest(event))) + return await sendJson({ + event, + status: 401, + data: { + error: 'Invalid or missing token', }, }); // read body const body = await getBodyBuffer(event); + const token = await createTokenIfNeeded(event); // proxy cleanupHeadersBeforeProxy(event); @@ -34,6 +49,7 @@ export default defineEventHandler(async (event) => { onResponse(outputEvent, response) { const headers = getAfterResponseHeaders(response.headers, response.url); setResponseHeaders(outputEvent, headers); + if (token) setTokenHeader(event, token); }, }); }); diff --git a/src/utils/ip.ts b/src/utils/ip.ts new file mode 100644 index 0000000..017b868 --- /dev/null +++ b/src/utils/ip.ts @@ -0,0 +1,5 @@ +import { EventHandlerRequest, H3Event } from 'h3'; + +export function getIp(_event: H3Event) { + return 'not-a-real-ip'; // TODO cross platform IP +} diff --git a/src/utils/turnstile.ts b/src/utils/turnstile.ts new file mode 100644 index 0000000..2e4f22e --- /dev/null +++ b/src/utils/turnstile.ts @@ -0,0 +1,87 @@ +import { H3Event, EventHandlerRequest } from 'h3'; +import jsonwebtoken from '@tsndr/cloudflare-worker-jwt'; +import { getIp } from '@/utils/ip'; + +const turnstileSecret = process.env.TURNSTILE_SECRET ?? null; +const jwtSecret = process.env.JWT_SECRET ?? null; + +const tokenHeader = 'X-Token'; +const jwtPrefix = 'jwt|'; +const turnstilePrefix = 'turnstile|'; + +export function isTurnstileEnabled() { + return !!turnstileSecret && !!jwtSecret; +} + +export async function makeToken(ip: string) { + if (!jwtSecret) throw new Error('Cannot make token without a secret'); + return await jsonwebtoken.sign( + { + ip, + exp: Math.floor(Date.now() / 1000) + 60 * 10, // 10 Minutes + }, + jwtSecret, + ); +} + +export function setTokenHeader( + event: H3Event, + token: string, +) { + setHeader(event, tokenHeader, token); +} + +export async function createTokenIfNeeded( + event: H3Event, +): Promise { + if (!isTurnstileEnabled()) return null; + if (!jwtSecret) return null; + const token = event.headers.get(tokenHeader); + if (!token) return null; + if (!token.startsWith(turnstilePrefix)) return null; + + return await makeToken(getIp(event)); +} + +export async function isAllowedToMakeRequest( + event: H3Event, +) { + if (!isTurnstileEnabled()) return true; + + const token = event.headers.get(tokenHeader); + if (!token) return false; + if (!jwtSecret || !turnstileSecret) return false; + + if (token.startsWith(jwtPrefix)) { + const jwtToken = token.slice(jwtPrefix.length); + const isValid = await jsonwebtoken.verify(jwtToken, jwtSecret, { + algorithm: 'HS256', + }); + if (!isValid) return false; + const jwtBody = jsonwebtoken.decode<{ ip: string }>(jwtToken); + if (!jwtBody.payload) return false; + if (getIp(event) !== jwtBody.payload.ip) return false; + return true; + } + + if (token.startsWith(turnstilePrefix)) { + const turnstileToken = token.slice(turnstilePrefix.length); + const formData = new FormData(); + formData.append('secret', turnstileSecret); + formData.append('response', turnstileToken); + formData.append('remoteip', getIp(event)); + + const result = await fetch( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + { + body: formData, + method: 'POST', + }, + ); + + const outcome: { success: boolean } = await result.json(); + return outcome.success; + } + + return false; +}