Skip to content

Commit

Permalink
Merge pull request #19 from movie-web/turnstile
Browse files Browse the repository at this point in the history
Add turnstile integration
  • Loading branch information
binaryoverload authored Dec 20, 2023
2 parents eb58298 + 9e5d1a2 commit a03e1c1
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 5 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# 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

---

### features:
- 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

> [!WARNING]
> Turnstile integration only works properly with cloudflare workers as platform
### supported platforms:
- cloudflare workers
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "simple-proxy",
"private": true,
"version": "2.0.1",
"version": "2.1.0",
"scripts": {
"prepare": "nitropack prepare",
"dev": "nitropack dev",
Expand All @@ -15,6 +15,7 @@
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"@tsndr/cloudflare-worker-jwt": "^2.3.2",
"h3": "^1.8.1",
"nitropack": "latest"
},
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,14 +19,24 @@ export default defineEventHandler(async (event) => {
if (!destination)
return await sendJson({
event,
status: 400,
status: 200,
data: {
message: 'Proxy is working as expected',
},
});

if (!(await isAllowedToMakeRequest(event)))
return await sendJson({
event,
status: 401,
data: {
error: 'destination query parameter invalid',
error: 'Invalid or missing token',
},
});

// read body
const body = await getBodyBuffer(event);
const token = await createTokenIfNeeded(event);

// proxy
cleanupHeadersBeforeProxy(event);
Expand All @@ -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);
},
});
});
10 changes: 10 additions & 0 deletions src/utils/ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { EventHandlerRequest, H3Event } from 'h3';

export function getIp(event: H3Event<EventHandlerRequest>) {
const value = getHeader(event, 'CF-Connecting-IP');
if (!value)
throw new Error(
'Ip header not found, turnstile only works on cloudflare workers',
);
return value;
}
87 changes: 87 additions & 0 deletions src/utils/turnstile.ts
Original file line number Diff line number Diff line change
@@ -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<EventHandlerRequest>,
token: string,
) {
setHeader(event, tokenHeader, token);
}

export async function createTokenIfNeeded(
event: H3Event<EventHandlerRequest>,
): Promise<null | string> {
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<EventHandlerRequest>,
) {
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;
}

0 comments on commit a03e1c1

Please sign in to comment.