-
Notifications
You must be signed in to change notification settings - Fork 0
Add hosted captun.sh safety controls #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3ca4f7e
e3b61b4
cac2726
a92a0a5
256fd37
9d43030
fe19c91
fd12c6a
0937d55
babe3a5
e0475ba
04328e3
5cfbeab
36aad15
0859820
b77ca3f
4377903
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { | ||
| HOSTED_CAPTUN_HOSTNAME, | ||
| TUNNEL_OWNER_TOKEN_HEADER, | ||
| TUNNEL_OWNER_TOKEN_QUERY_PARAM, | ||
| } from "./routing.js"; | ||
|
|
||
| export type HostedAdmissionEnv = { | ||
| CAPTUN_SECRET?: string; | ||
| CUSTOM_HOSTNAME?: string; | ||
| }; | ||
|
|
||
| export type TunnelAdmission = | ||
| | { ok: true; ownerToken: string | undefined } | ||
| | { ok: false; response: Response }; | ||
|
|
||
| export function decideTunnelAdmission(input: { | ||
| request: Request; | ||
| env: HostedAdmissionEnv; | ||
| activeOwnerToken: string | undefined; | ||
| }): TunnelAdmission { | ||
| const expected = input.env.CAPTUN_SECRET ? `Bearer ${input.env.CAPTUN_SECRET}` : undefined; | ||
| if (expected && !constantTimeEqual(input.request.headers.get("authorization") || "", expected)) { | ||
| return { ok: false, response: new Response("Unauthorized\n", { status: 401 }) }; | ||
| } | ||
|
|
||
| const ownerToken = hostedAnonymousOwnerToken(input.request, input.env); | ||
| if (ownerToken instanceof Response) return { ok: false, response: ownerToken }; | ||
|
|
||
| if (ownerToken !== undefined && input.activeOwnerToken && input.activeOwnerToken !== ownerToken) { | ||
| return { ok: false, response: reject("Tunnel name is already connected\n", 409) }; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| return { ok: true, ownerToken }; | ||
| } | ||
|
|
||
| function hostedAnonymousOwnerToken( | ||
| request: Request, | ||
| env: HostedAdmissionEnv, | ||
| ): string | Response | undefined { | ||
| if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; | ||
| if (env.CAPTUN_SECRET) return undefined; | ||
|
|
||
| const token = | ||
| request.headers.get(TUNNEL_OWNER_TOKEN_HEADER) || | ||
| new URL(request.url).searchParams.get(TUNNEL_OWNER_TOKEN_QUERY_PARAM) || | ||
| ""; | ||
| if (!token) return reject("Missing tunnel ownership token\n", 400); | ||
| if (!/^[a-zA-Z0-9._~-]{1,128}$/.test(token)) { | ||
| return reject("Invalid tunnel ownership token\n", 400); | ||
| } | ||
|
|
||
| return token; | ||
| } | ||
|
|
||
| function reject(body: string, status: number) { | ||
| return new Response(body, { | ||
| status, | ||
| headers: { | ||
| "content-type": "text/plain; charset=utf-8", | ||
| "cache-control": "no-store", | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| function constantTimeEqual(actual: string, expected: string) { | ||
| const actualBytes = new TextEncoder().encode(actual); | ||
| const expectedBytes = new TextEncoder().encode(expected); | ||
| let diff = actualBytes.length ^ expectedBytes.length; | ||
| const length = Math.max(actualBytes.length, expectedBytes.length); | ||
| for (let index = 0; index < length; index++) { | ||
| diff |= (actualBytes[index] || 0) ^ (expectedBytes[index] || 0); | ||
| } | ||
| return diff === 0; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,11 @@ | ||
| import { newWebSocketRpcSession, RpcTarget } from "capnweb"; | ||
| import { getTunnelUrlFromServerUrl, HOSTED_CAPTUN_SERVER_URL } from "./routing.js"; | ||
| import { | ||
| getTunnelUrlFromServerUrl, | ||
| HOSTED_CAPTUN_SERVER_URL, | ||
| TUNNEL_CONNECT_DIAGNOSTIC_HEADER, | ||
| TUNNEL_OWNER_TOKEN_HEADER, | ||
| TUNNEL_OWNER_TOKEN_QUERY_PARAM, | ||
| } from "./routing.js"; | ||
|
|
||
| /** Fetch is all you need! | ||
| * | ||
|
|
@@ -28,26 +34,54 @@ export interface Fetcher { | |
| */ | ||
| export type CaptunTunnel = Disposable & { | ||
| url: string; | ||
| ownerToken: string; | ||
| }; | ||
|
|
||
| export class CaptunTunnelConnectError extends Error { | ||
| response: { status: number; statusText: string; body: string } | undefined; | ||
|
|
||
| constructor( | ||
| message: string, | ||
| response: { status: number; statusText: string; body: string } | undefined, | ||
| ) { | ||
| super(message); | ||
| this.name = "CaptunTunnelConnectError"; | ||
| this.response = response; | ||
| } | ||
| } | ||
|
|
||
| const WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500; | ||
|
|
||
| export async function createCaptunTunnel( | ||
| options: Fetcher & { | ||
| url?: string | URL; | ||
| serverUrl?: string; | ||
| name?: string; | ||
| headers?: Record<string, string>; | ||
| ownerToken?: string; | ||
| }, | ||
| ): Promise<CaptunTunnel> { | ||
| const endpoint = resolveTunnelEndpoint(options); | ||
| const socket = createWebSocket({ url: endpoint.connectUrl, headers: options.headers }); | ||
| const ownership = withAnonymousOwnershipToken({ | ||
| connectUrl: endpoint.connectUrl, | ||
| headers: options.headers, | ||
| ownerToken: options.ownerToken, | ||
| }); | ||
| const socket = createWebSocket({ url: ownership.connectUrl, headers: options.headers }); | ||
| // tunnelTargetFetcher is the "main object" that comes out on the other side in acceptCaptunTunnel | ||
| // as a capnweb rpc stub that the server can just call fetch on | ||
| const tunnelTargetFetcher = new TunnelTargetFetcher({ fetch: options.fetch }); | ||
| const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); | ||
| await waitUntilOpen(socket); | ||
| try { | ||
| await waitUntilOpen(socket, { connectUrl: ownership.connectUrl, headers: options.headers }); | ||
| } catch (error) { | ||
| session[Symbol.dispose](); | ||
| throw error; | ||
| } | ||
|
|
||
| return { | ||
| url: endpoint.publicUrl, | ||
| ownerToken: ownership.ownerToken, | ||
| [Symbol.dispose]: () => session[Symbol.dispose](), | ||
| }; | ||
| } | ||
|
|
@@ -71,15 +105,47 @@ function resolveTunnelEndpoint(options: { | |
| function publicUrlFromConnectUrl(connectUrl: URL) { | ||
| const publicUrl = new URL(connectUrl); | ||
| publicUrl.pathname = publicUrl.pathname.replace(/\/__captun-connect\/?$/, "") || "/"; | ||
| publicUrl.search = ""; | ||
| publicUrl.hash = ""; | ||
| return publicUrl.toString().replace(/\/$/, ""); | ||
| } | ||
|
|
||
| function withAnonymousOwnershipToken(options: { | ||
| connectUrl: string; | ||
| headers: Record<string, string> | undefined; | ||
| ownerToken: string | undefined; | ||
| }) { | ||
| const headerToken = getHeader(options.headers, TUNNEL_OWNER_TOKEN_HEADER); | ||
| if (headerToken) return { connectUrl: options.connectUrl, ownerToken: headerToken }; | ||
|
|
||
| const connectUrl = new URL(options.connectUrl); | ||
| const ownerToken = | ||
| options.ownerToken || | ||
| connectUrl.searchParams.get(TUNNEL_OWNER_TOKEN_QUERY_PARAM) || | ||
| randomOwnershipToken(); | ||
| connectUrl.searchParams.set(TUNNEL_OWNER_TOKEN_QUERY_PARAM, ownerToken); | ||
| return { connectUrl: connectUrl.toString(), ownerToken }; | ||
| } | ||
|
|
||
| function getHeader(headers: Record<string, string> | undefined, name: string) { | ||
| if (!headers) return undefined; | ||
| const lowerName = name.toLowerCase(); | ||
| const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === lowerName); | ||
| return key ? headers[key] : undefined; | ||
| } | ||
|
|
||
| function randomTunnelName() { | ||
| const bytes = new Uint8Array(8); | ||
| crypto.getRandomValues(bytes); | ||
| return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | ||
| } | ||
|
|
||
| export function randomOwnershipToken() { | ||
| const bytes = new Uint8Array(16); | ||
| crypto.getRandomValues(bytes); | ||
| return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | ||
| } | ||
|
|
||
| class TunnelTargetFetcher extends RpcTarget implements Fetcher { | ||
| private fetcher: Fetcher; | ||
|
|
||
|
|
@@ -109,7 +175,10 @@ function createWebSocket(options: { url: string | URL; headers?: Record<string, | |
| ); | ||
| } | ||
|
|
||
| async function waitUntilOpen(socket: WebSocket) { | ||
| async function waitUntilOpen( | ||
| socket: WebSocket, | ||
| options: { connectUrl: string; headers: Record<string, string> | undefined }, | ||
| ) { | ||
| if (socket.readyState === WebSocket.OPEN) return; | ||
| if (socket.readyState !== WebSocket.CONNECTING) { | ||
| throw new Error("WebSocket closed before opening"); | ||
|
|
@@ -124,7 +193,10 @@ async function waitUntilOpen(socket: WebSocket) { | |
| socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal }); | ||
| socket.addEventListener( | ||
| "error", | ||
| () => settle(() => reject(new Error("WebSocket connection failed"))), | ||
| () => | ||
| settle(() => { | ||
| void webSocketConnectionFailedError(options).then(reject); | ||
| }), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Close handler skips rejection probeMedium Severity In Reviewed by Cursor Bugbot for commit 4377903. Configure here. |
||
| { signal: listeners.signal }, | ||
| ); | ||
| socket.addEventListener( | ||
|
|
@@ -138,6 +210,49 @@ async function waitUntilOpen(socket: WebSocket) { | |
| }); | ||
| } | ||
|
|
||
| async function webSocketConnectionFailedError(options: { | ||
| connectUrl: string; | ||
| headers: Record<string, string> | undefined; | ||
| }) { | ||
| const response = await readWebSocketRejection(options); | ||
| if (!response) return new CaptunTunnelConnectError("WebSocket connection failed", undefined); | ||
| return new CaptunTunnelConnectError( | ||
| `WebSocket connection failed: ${response.status} ${response.statusText}: ${response.body}`.trim(), | ||
| response, | ||
| ); | ||
| } | ||
|
|
||
| async function readWebSocketRejection(options: { | ||
| connectUrl: string; | ||
| headers: Record<string, string> | undefined; | ||
| }) { | ||
| const abort = new AbortController(); | ||
| const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS); | ||
| try { | ||
| const response = await fetch(options.connectUrl, { | ||
| headers: diagnosticHeaders(options.headers), | ||
| signal: abort.signal, | ||
| }); | ||
| if (response.ok) return undefined; | ||
| const body = (await response.text()).trim(); | ||
| return { | ||
| status: response.status, | ||
| statusText: response.statusText || "Rejected", | ||
| body: body || "No response body", | ||
| }; | ||
| } catch { | ||
| return undefined; | ||
| } finally { | ||
| clearTimeout(timeout); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function diagnosticHeaders(headers: Record<string, string> | undefined) { | ||
| const diagnostic = new Headers(headers); | ||
| diagnostic.set(TUNNEL_CONNECT_DIAGNOSTIC_HEADER, "1"); | ||
| return diagnostic; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Tunnel server (formerly src/server.ts) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.