|
| 1 | +/** |
| 2 | + * Cloudflare Worker for vpn.cumulusvpn.com. |
| 3 | + * |
| 4 | + * Serves the built static site (via the ASSETS binding) AND proxies the web |
| 5 | + * app's gateway calls so an https page can reach the plain-http gateway control |
| 6 | + * API without a mixed-content block: |
| 7 | + * |
| 8 | + * GET/POST /gw/<ip>:<port>/<path> → http://<ip>:<port>/<path> |
| 9 | + * |
| 10 | + * The gateway signs its response bodies, and this is same-origin, so the browser |
| 11 | + * can read the signature headers and verify as usual. |
| 12 | + * |
| 13 | + * SSRF guard: only the gateway control port + the Flux node port, and only |
| 14 | + * public IPv4 targets, so it can't be used as an open relay to arbitrary hosts |
| 15 | + * or internal addresses. (A tighter follow-up: allowlist IPs from the signed |
| 16 | + * directory.) |
| 17 | + */ |
| 18 | + |
| 19 | +const ALLOWED_PORTS = new Set(['51821', '16127']); |
| 20 | + |
| 21 | +/** True only for a routable public IPv4 literal (blocks private / loopback / link-local). */ |
| 22 | +function isPublicIPv4(host) { |
| 23 | + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); |
| 24 | + if (!m) return false; |
| 25 | + const o = [m[1], m[2], m[3], m[4]].map(Number); |
| 26 | + if (o.some((n) => n > 255)) return false; |
| 27 | + const [a, b] = o; |
| 28 | + if (a === 0 || a === 10 || a === 127) return false; // this-network / private / loopback |
| 29 | + if (a === 169 && b === 254) return false; // link-local |
| 30 | + if (a === 172 && b >= 16 && b <= 31) return false; // private |
| 31 | + if (a === 192 && b === 168) return false; // private |
| 32 | + return true; |
| 33 | +} |
| 34 | + |
| 35 | +export default { |
| 36 | + async fetch(request, env) { |
| 37 | + const url = new URL(request.url); |
| 38 | + |
| 39 | + if (url.pathname.startsWith('/gw/')) { |
| 40 | + const rest = url.pathname.slice('/gw/'.length); |
| 41 | + const slash = rest.indexOf('/'); |
| 42 | + const authority = slash === -1 ? rest : rest.slice(0, slash); |
| 43 | + const path = slash === -1 ? '/' : rest.slice(slash); |
| 44 | + const [host, port] = authority.split(':'); |
| 45 | + |
| 46 | + if (!host || !port || !ALLOWED_PORTS.has(port) || !isPublicIPv4(host)) { |
| 47 | + return new Response('proxy target not allowed', { status: 403 }); |
| 48 | + } |
| 49 | + |
| 50 | + const target = `http://${host}:${port}${path}${url.search}`; |
| 51 | + const init = { method: request.method, headers: {} }; |
| 52 | + const ct = request.headers.get('content-type'); |
| 53 | + if (ct) init.headers['content-type'] = ct; |
| 54 | + if (request.method !== 'GET' && request.method !== 'HEAD') { |
| 55 | + init.body = await request.text(); |
| 56 | + } |
| 57 | + try { |
| 58 | + return await fetch(target, init); |
| 59 | + } catch { |
| 60 | + return new Response('gateway unreachable', { status: 502 }); |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + // Everything else: the static site (with SPA/hash-routing fallback). |
| 65 | + return env.ASSETS.fetch(request); |
| 66 | + }, |
| 67 | +}; |
0 commit comments