Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
GITHUB_CLIENT_ID=your_client_id_here
GITHUB_CLIENT_SECRET=your_client_secret_here
ALLOWED_ORIGIN=http://localhost:5173
SESSION_KEY=your-base64-encoded-32-byte-key
SEAL_KEY=your-base64-encoded-32-byte-key
TURNSTILE_SECRET_KEY=your-turnstile-secret-from-cf-dashboard
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ GITHUB_TOKEN=your_github_token_here
# Port for the WebSocket relay server (MCP ↔ browser dashboard bridge).
# Default: 9876
# MCP_WS_PORT=9876

# ── Turnstile (Cloudflare) ─────────────────────────────────────────────────────
# Public site key — embedded into client-side bundle at build time by Vite.
# This is public information (visible in the Turnstile widget script).
# Get this from the Cloudflare Turnstile dashboard.
# Note: TURNSTILE_SECRET_KEY is a Worker secret (goes in .dev.vars, not .env).
VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key-from-cf-dashboard
112 changes: 112 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ wrangler secret put ALLOWED_ORIGIN
|----------|--------|---------|
| `/api/oauth/token` | POST | Exchange OAuth authorization code for permanent access token. |
| `/api/health` | GET | Health check. Returns `OK`. |
| `/api/proxy/seal` | POST | Encrypt an API token for client-side storage. Requires Turnstile + session. |

### Token Storage Security

Expand All @@ -95,6 +96,20 @@ The OAuth App access token is a permanent credential (no expiry). It is stored i

Copy `.dev.vars.example` to `.dev.vars` and fill in your values. Wrangler picks up `.dev.vars` automatically for local `wrangler dev` runs.

### HTTPS requirement for session cookies

The `__Host-session` cookie uses the `__Host-` prefix, which browsers **silently reject over HTTP**. To test session cookies locally, use:

```bash
wrangler dev --local-protocol https
```

The self-signed certificate from `--local-protocol https` must be accepted in the browser on first use (click through the "Not Secure" warning or add a security exception).

### Compatibility flags in local dev

The `global_fetch_strictly_public` compatibility flag (which blocks Worker subrequests to private/internal IPs) has **no effect** in local `wrangler dev` — workerd ignores it. No local dev workaround is needed for this flag.

## Deploy Manually

```sh
Expand All @@ -114,3 +129,100 @@ If you previously deployed with the GitHub App model (HttpOnly cookie refresh to
6. **Delete the old GitHub App** (optional): GitHub → Settings → Developer settings → GitHub Apps → your app → Advanced → Delete

The old `POST /api/oauth/refresh` and `POST /api/oauth/logout` endpoints no longer exist and return 404.

---

## WAF Security Rules

Configure these rules in the Cloudflare dashboard under **Security → WAF**.

### Custom Rules

**Rule name:** Block API requests without valid Origin
**Where:** Security → WAF → Custom Rules
**Expression:**
```
(http.request.uri.path starts_with "/api/") and
not (any(http.request.headers["origin"][*] in {"https://gh.gordoncode.dev"})) and
not (http.request.uri.path eq "/api/csp-report") and
not (http.request.uri.path eq "/api/error-reporting")
```
**Action:** Block

**Exemptions:**
- `/api/csp-report` is exempted because browser-generated CSP violation reports (via the Reporting API) may not include an `Origin` header.
- `/api/error-reporting` is exempted for consistency with the CSP tunnel — while the Sentry SDK does include `Origin` in its `fetch()` calls, the exemption keeps both tunnel endpoints treated identically. Both endpoints are low-risk (error reporting only, no sensitive data returned) and have their own validation (DSN check, payload format check).

**Notes:**
- This uses **1 of the 5 free WAF custom rules** available on all plans.
- Blocks scanners, `curl` without `Origin`, and cross-site browser attacks before the Worker runs (never billed as a Worker request).

### Rate Limiting Rules

> **Conditional:** WAF rate limiting rules may require a **Pro plan** or above. If unavailable on your current Cloudflare plan (Free plan), skip this step. The Workers Rate Limiting Binding provides per-session rate limiting instead, and the WAF custom rule (above) still enforces the Origin check layer.

**Rule name:** Rate limit API proxy endpoints
**Where:** Security → WAF → Rate Limiting Rules
**Matching expression:**
```
(http.request.uri.path starts_with "/api/") and
(http.request.method ne "OPTIONS")
```
**Rate:** 60 requests per 10 seconds per IP
**Action:** Block for 60 seconds

**Notes:**
- `OPTIONS` (CORS preflight) is excluded from counting to avoid blocking legitimate preflight requests.
- Provides globally-consistent rate limiting that runs before the Worker (not per-location like Workers Rate Limiting Binding).

---

## Workers Secrets

All secrets are set via the `wrangler` CLI and stored in the Cloudflare Worker runtime (never committed to source control).

### Generating keys

```bash
# Generate cryptographically strong keys (base64-encoded 32-byte random values):
openssl rand -base64 32 # Run once per key below
```

### Setting secrets

```bash
wrangler secret put SESSION_KEY # HKDF input key material for session cookies
wrangler secret put SEAL_KEY # HKDF input key material for sealed tokens
wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard
```

- `SESSION_KEY`: HKDF input key material used to derive the HMAC-SHA256 key for signing `__Host-session` cookies. Generate with `openssl rand -base64 32`.
- `SEAL_KEY`: HKDF input key material used to derive the AES-256-GCM key for encrypting API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`.
- `TURNSTILE_SECRET_KEY`: From the Cloudflare Turnstile dashboard (Security → Turnstile → your widget → Secret key).
- `VITE_TURNSTILE_SITE_KEY`: **Build-time env var (public)** — goes in `.env`, not a Worker secret. From the same Turnstile dashboard (Site key).

### First deployment

On initial deployment, set only `SESSION_KEY`, `SEAL_KEY`, and `TURNSTILE_SECRET_KEY`. Do **not** set `SESSION_KEY_PREV` or `SEAL_KEY_PREV` — these are only needed during key rotation after the initial keys are in use.

### Key rotation

To rotate a key without invalidating existing sessions/tokens:

1. Set the `*_PREV` secret to the **current** key value:
```bash
wrangler secret put SESSION_KEY_PREV # Copy current SESSION_KEY value here first
wrangler secret put SEAL_KEY_PREV # Copy current SEAL_KEY value here first
```
2. Generate a new key and update the main secret:
```bash
openssl rand -base64 32 # generate new value
wrangler secret put SESSION_KEY # update with new value
wrangler secret put SEAL_KEY # update with new value
```
3. The Worker will accept tokens signed/sealed with either the current or previous key during the transition window.
4. After all clients have cycled (sessions expire after 8 hours), optionally remove `*_PREV`:
```bash
wrangler secret delete SESSION_KEY_PREV
wrangler secret delete SEAL_KEY_PREV
```
2 changes: 1 addition & 1 deletion public/_headers
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint
Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="/api/csp-report"
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Expand Down
140 changes: 140 additions & 0 deletions src/app/lib/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPA-side proxy utilities: Turnstile script loader, token acquisition,
// sealed-token helper, and proxyFetch wrapper.

const TURNSTILE_SCRIPT_URL =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

let turnstilePromise: Promise<void> | null = null;

function loadTurnstileScript(): Promise<void> {
if (turnstilePromise !== null) {
return turnstilePromise;
}
turnstilePromise = new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = TURNSTILE_SCRIPT_URL;
script.async = true;
script.onload = () => resolve();
script.onerror = () => {
script.remove();
turnstilePromise = null;
reject(new Error("Failed to load Turnstile script"));
};
document.head.appendChild(script);
});
return turnstilePromise;
}

export async function acquireTurnstileToken(siteKey: string): Promise<string> {
if (!siteKey) {
throw new Error("VITE_TURNSTILE_SITE_KEY not configured");
}

await loadTurnstileScript();

return new Promise<string>((resolve, reject) => {
let settled = false;

const container = document.createElement("div");
container.style.cssText =
"position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9999; min-width: 300px; min-height: 65px;";
document.body.appendChild(container);

const cleanup = (widgetId: string) => {
window.turnstile.remove(widgetId);
container.remove();
};

const widgetId = window.turnstile.render(container, {
sitekey: siteKey,
size: "invisible",
execution: "execute",
callback: (token: string) => {
if (settled) return;
settled = true;
cleanup(widgetId);
resolve(token);
},
"error-callback": (errorCode: string) => {
if (settled) return;
settled = true;
cleanup(widgetId);
reject(new Error(`Turnstile error: ${errorCode}`));
},
"expired-callback": () => {
if (settled) return;
settled = true;
cleanup(widgetId);
reject(new Error("Turnstile token expired before submission"));
},
});

window.turnstile.execute(widgetId);

setTimeout(() => {
if (settled) return;
settled = true;
cleanup(widgetId);
reject(new Error("Turnstile challenge timed out after 30 seconds"));
}, 30_000);
});
}

export async function proxyFetch(
path: string,
options?: RequestInit,
): Promise<Response> {
const defaultHeaders: Record<string, string> = {
"X-Requested-With": "fetch",
"Content-Type": "application/json",
};

const callerHeaders =
options?.headers instanceof Headers
? Object.fromEntries(options.headers.entries())
: (options?.headers as Record<string, string> | undefined) ?? {};

const mergedHeaders = { ...defaultHeaders, ...callerHeaders };

return fetch(path, {
...options,
headers: mergedHeaders,
});
}

export class SealError extends Error {
readonly status: number;

constructor(status: number, code: string) {
super(code);
this.name = "SealError";
this.status = status;
}
}

export async function sealApiToken(token: string, purpose: string): Promise<string> {
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined;
const turnstileToken = await acquireTurnstileToken(siteKey ?? "");

const res = await proxyFetch("/api/proxy/seal", {
method: "POST",
headers: {
"cf-turnstile-response": turnstileToken,
},
body: JSON.stringify({ token, purpose }),
});

if (!res.ok) {
let code = "unknown_error";
try {
const body = (await res.json()) as { error?: string };
code = body.error ?? code;
} catch {
// ignore parse errors — keep default code
}
throw new SealError(res.status, code);
}

const data = (await res.json()) as { sealed: string };
return data.sealed;
}
23 changes: 23 additions & 0 deletions src/types/turnstile.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Cloudflare Turnstile client-side API type declarations.
// Turnstile assigns `window.turnstile` synchronously when its script executes.

interface TurnstileRenderOptions {
sitekey: string;
size?: "normal" | "compact" | "invisible" | "flexible";
execution?: "render" | "execute";
callback?: (token: string) => void;
"error-callback"?: (errorCode: string) => void;
"expired-callback"?: () => void;
"timeout-callback"?: () => void;
}

interface Turnstile {
render(container: HTMLElement | string, options: TurnstileRenderOptions): string;
execute(widgetId: string): void;
remove(widgetId: string): void;
reset(widgetId: string): void;
}

interface Window {
turnstile: Turnstile;
}
Loading