Skip to content
Merged
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
53 changes: 52 additions & 1 deletion DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ against `api.github.com` with no server-side component.

**Host the `dist/` build output on any static platform:** GitHub Pages, Netlify, Vercel,
S3 + CloudFront, or any CDN that serves SPAs with `index.html` fallback for client-side
routing.
routing. Note: `public/.nojekyll` is copied into `dist/client/.nojekyll` during `pnpm run build`
— keep this file when deploying to GitHub Pages so Jekyll does not strip the `_app/` directory
containing hashed JS and CSS bundles.

**What works without a backend:**
- All dashboard features (Issues, PRs, Actions tabs)
Expand Down Expand Up @@ -214,6 +216,20 @@ The OAuth App access token is a permanent credential (no expiry). It is stored i

The two tunnel endpoints (`/api/error-reporting` and `/api/csp-report`) receive untrusted browser data and forward it to Sentry. They are hardened with layered fail-fast guards (IP rate limit → Origin check → DSN validation). See `hack/docs/security-runbook.md` for the full threat model, per-endpoint rate limit values, Origin check behavior, CSP field sanitization details, Content-Length pre-check semantics, and fan-out amplification analysis.

### Static asset caching

- **Hashed bundles (`/_app/*`):** Vite outputs content-hashed JS and CSS bundles to `dist/client/_app/`. `public/_headers` configures `Cache-Control: public, max-age=31536000, immutable` (one year) on `/_app/*` while inheriting the global security headers from `/*`.
- **Entry HTML and unhashed public assets (`index.html`, `/assets/*`):** `index.html` and unhashed images under `public/assets/*` (favicon, logos, social preview) have no custom `Cache-Control` rule in `public/_headers` and use the Cloudflare Workers Static Assets platform default (`Cache-Control: public, max-age=0, must-revalidate`), ensuring browsers revalidate `index.html` on every load.
- **One-time cache miss on initial `/_app/` rollout:** Moving hashed bundles from `/assets/` to `/_app/` causes a one-time cache miss on the deployment that introduces `/_app/`.
- **Stale-chunk 404 handling (`stale_asset_404`):** Under SPA `not_found_handling`, missing non-navigation requests (such as `<script>` or `import()` fetches for a chunk from a superseded deploy) invoke the Worker and `env.ASSETS.fetch(request)` returns the `200 text/html` SPA fallback. When `url.pathname.startsWith("/_app/")`, `assetResponse.status === 200`, and `Content-Type` starts with `text/html`, the Worker logs `stale_asset_404` and returns `404 Not Found` (`text/plain; charset=utf-8`) with `Cache-Control: no-store` and `SECURITY_HEADERS` so neither the browser nor CDN caches HTML under an immutable chunk URL.
- **Accepted navigation edge case:** A direct browser *navigation* (`Sec-Fetch-Mode: navigate`) to a non-existent `/_app/*` URL is answered by Cloudflare's static asset router with `index.html` before the Worker runs and inherits the `/_app/*` immutable header from `_headers`. This is harmless (no application route or asset reference ever navigates to a missing `/_app/*` path) and avoids routing every `/_app/*` asset hit through the Worker.
- **GitHub Pages `.nojekyll` requirement:** `public/.nojekyll` (emitted to `dist/client/.nojekyll`) disables Jekyll processing on GitHub Pages static-only deployments so the `_`-prefixed `_app/` directory is not dropped.
- **Post-deploy verification:**
```sh
curl -sI https://<domain>/_app/<hashed-file> | grep -i cache-control
# Expected: cache-control: public, max-age=31536000, immutable
```

## Local Development

Copy `.dev.vars.example` to `.dev.vars` and fill in your values. Wrangler picks up `.dev.vars` automatically for local `wrangler dev` runs.
Expand Down Expand Up @@ -262,6 +278,41 @@ Configure these rules in the Cloudflare dashboard under **Security → WAF**.

### Custom Rules

#### Rule 1: Path Allowlist

**Rule name:** Path Allowlist (block unknown paths)
**Where:** Security → WAF → Custom Rules
**Action:** Block

**Previous expression (pre-`/_app/` rollout, synthesized from `scripts/waf-smoke-test.sh`):**
```
not (
http.request.uri.path in {"/" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy" "/index.html"} or
starts_with(http.request.uri.path, "/assets/") or
starts_with(http.request.uri.path, "/api/")
)
```

**Updated expression (allows `/_app/*` hashed chunks and static/metadata routes on `GET`/`HEAD`):**
```
not (
(
http.request.method in {"GET" "HEAD"} and (
http.request.uri.path in {"/" "/app" "/landing" "/landing.html" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy" "/index.html" "/version.json" "/robots.txt" "/sitemap.xml" "/manifest.webmanifest" "/favicon.ico"} or
starts_with(http.request.uri.path, "/_app/") or
starts_with(http.request.uri.path, "/assets/")
)
) or
starts_with(http.request.uri.path, "/api/")
)
```

**Notes:**
- Blocks vulnerability scanners and probes for unmapped paths (`/wp-admin`, `/.env`, `/.git/config`, arbitrary paths) at the edge before the Worker or static asset router runs.
- `scripts/waf-smoke-test.sh` (`pnpm test:waf`) validates this rule before every commit (`prek`) and deploy (`deploy.yml`).

#### Rule 3: Origin Gate

**Rule name:** Block API requests without valid Origin
**Where:** Security → WAF → Custom Rules
**Expression:**
Expand Down
Empty file added public/.nojekyll
Empty file.
3 changes: 3 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@
Permissions-Policy: geolocation=(), microphone=(), camera=()
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Frame-Options: DENY

/_app/*
Cache-Control: public, max-age=31536000, immutable
11 changes: 6 additions & 5 deletions scripts/waf-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# e.g. pnpm test:waf https://my-tracker.example.com
#
# Rules validated:
# 1. Path Allowlist — blocks all paths except known SPA routes, /assets/*, /api/*
# 1. Path Allowlist — blocks all paths except known SPA/static routes, /_app/*, /assets/*, /api/*, and public metadata files (/version.json, /robots.txt, /sitemap.xml, /manifest.webmanifest, /favicon.ico)
# 2. Scanner User-Agents — challenges empty/malicious User-Agent strings
# 3. Origin Gate — blocks /api/* requests without valid Origin header
# Rate limit rule exists but is not tested here (triggers a 10-minute IP block).
Expand All @@ -30,7 +30,7 @@ run_test() {
[[ "$actual" != "000" && "$actual" != "502" && "$actual" != "503" ]] && break
sleep "$attempt"
done
if [[ "$actual" == "$expected" ]]; then
if [[ ",$expected," == *",$actual,"* ]]; then
printf ' PASS [%s] %s\n' "$actual" "$label"
else
printf ' FAIL [%s] %s (expected %s)\n' "$actual" "$label" "$expected"
Expand Down Expand Up @@ -62,6 +62,7 @@ TESTS=(
"200|GET /settings|${BASE}/settings"
"200|GET /privacy|${BASE}/privacy"
"307|GET /index.html (html_handling redirect)|${BASE}/index.html"
"403,200,404|GET /_app/nonexistent.js|${BASE}/_app/nonexistent.js"
"200|GET /assets/nonexistent.js|${BASE}/assets/nonexistent.js"
"200|GET /api/health (with Origin)|-H|Origin: ${BASE}|${BASE}/api/health"
"400|POST /api/oauth/token (no body)|-X|POST|-H|Origin: ${BASE}|${BASE}/api/oauth/token"
Expand Down Expand Up @@ -92,9 +93,9 @@ TESTS=(
"403|GET /eval-stdin.php|${BASE}/eval-stdin.php"
"403|GET /.aws/credentials|${BASE}/.aws/credentials"
"403|GET /.ssh/id_rsa|${BASE}/.ssh/id_rsa"
"403|GET /robots.txt|${BASE}/robots.txt"
"403|GET /sitemap.xml|${BASE}/sitemap.xml"
"403|GET /favicon.ico|${BASE}/favicon.ico"
"403,200|GET /robots.txt|${BASE}/robots.txt"
"403,200|GET /sitemap.xml|${BASE}/sitemap.xml"
"403,200|GET /favicon.ico|${BASE}/favicon.ico"
"403|GET /random/garbage/path|${BASE}/random/garbage/path"
# Rule 2: Scanner User-Agents — normal UAs
"200|Normal browser UA|-H|User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36|${BASE}/"
Expand Down
20 changes: 18 additions & 2 deletions src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function errorResponse(
});
}

const SECURITY_HEADERS: Record<string, string> = {
export const SECURITY_HEADERS: Record<string, string> = {
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
Expand Down Expand Up @@ -1726,7 +1726,23 @@ export default Sentry.withSentry(
}

// Forward non-API requests to static assets
return env.ASSETS.fetch(request);
const assetResponse = await env.ASSETS.fetch(request);
if (
url.pathname.startsWith("/_app/") &&
assetResponse.status === 200 &&
(assetResponse.headers.get("content-type") ?? "").startsWith("text/html")
) {
log("warn", "stale_asset_404", { pathname: url.pathname }, request);
return new Response(request.method === "HEAD" ? null : "Not Found", {
status: 404,
headers: {
...SECURITY_HEADERS,
"Cache-Control": "no-store",
"Content-Type": "text/plain; charset=utf-8",
},
});
}
return assetResponse;
},
}
);
192 changes: 192 additions & 0 deletions tests/worker/static-assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import worker, { SECURITY_HEADERS, type Env } from "../../src/worker/index";
import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers";

function makeEnv(overrides: Partial<Env> = {}): Env {
return {
ASSETS: { fetch: async () => new Response("asset") },
GITHUB_CLIENT_ID: "test_client_id",
GITHUB_CLIENT_SECRET: "test_client_secret",
ALLOWED_ORIGIN,
SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123",
SESSION_KEY: "dGVzdC1zZXNzaW9uLWtleQ==",
SEAL_KEY: "dGVzdC1zZWFsLWtleQ==",
TURNSTILE_SECRET_KEY: "test-turnstile-secret",
PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) },
...overrides,
};
}

describe("Worker static asset handling for /_app/*", () => {
let consoleSpy: {
info: ReturnType<typeof vi.spyOn>;
warn: ReturnType<typeof vi.spyOn>;
error: ReturnType<typeof vi.spyOn>;
};

beforeEach(() => {
consoleSpy = {
info: vi.spyOn(console, "info").mockImplementation(() => {}),
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
error: vi.spyOn(console, "error").mockImplementation(() => {}),
};
});

afterEach(() => {
vi.restoreAllMocks();
});

it("returns 404 with no-store, SECURITY_HEADERS, and logs stale_asset_404 on GET /_app/* when ASSETS returns 200 text/html", async () => {
const env = makeEnv({
ASSETS: {
fetch: async () =>
new Response("<!doctype html><html><body>SPA</body></html>", {
status: 200,
headers: { "Content-Type": "text/html" },
}),
},
});
const req = new Request("https://gh.gordoncode.dev/_app/missing-abc123.js", {
method: "GET",
});

const resp = await worker.fetch(req, env);

expect(resp.status).toBe(404);
expect(await resp.text()).toBe("Not Found");
expect(resp.headers.get("Cache-Control")).toBe("no-store");
expect(resp.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
expect(resp.headers.get(key)).toBe(value);
}

const logs = collectLogs(consoleSpy);
const staleLog = findLog(logs, "stale_asset_404");
expect(staleLog).toBeDefined();
expect(staleLog?.level).toBe("warn");
expect(staleLog?.entry.pathname).toBe("/_app/missing-abc123.js");
});

it("returns 404 on GET /_app/* when ASSETS returns 200 text/html; charset=utf-8", async () => {
const env = makeEnv({
ASSETS: {
fetch: async () =>
new Response("<!doctype html><html><body>SPA</body></html>", {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
}),
},
});
const req = new Request("https://gh.gordoncode.dev/_app/missing-abc123.js", {
method: "GET",
});

const resp = await worker.fetch(req, env);

expect(resp.status).toBe(404);
expect(await resp.text()).toBe("Not Found");
expect(resp.headers.get("Cache-Control")).toBe("no-store");
expect(resp.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
expect(resp.headers.get(key)).toBe(value);
}
});

it("returns 404 with empty body on HEAD /_app/* when ASSETS returns 200 text/html", async () => {
const env = makeEnv({
ASSETS: {
fetch: async () =>
new Response("<!doctype html><html><body>SPA</body></html>", {
status: 200,
headers: { "Content-Type": "text/html" },
}),
},
});
const req = new Request("https://gh.gordoncode.dev/_app/missing-abc123.js", {
method: "HEAD",
});

const resp = await worker.fetch(req, env);

expect(resp.status).toBe(404);
expect(resp.body).toBeNull();
expect(await resp.text()).toBe("");
expect(resp.headers.get("Cache-Control")).toBe("no-store");
expect(resp.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
expect(resp.headers.get(key)).toBe(value);
}
});

it("passes through 200 application/javascript response for /_app/index-abc.js", async () => {
const env = makeEnv({
ASSETS: {
fetch: async () =>
new Response("console.log('ok');", {
status: 200,
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=31536000, immutable",
},
}),
},
});
const req = new Request("https://gh.gordoncode.dev/_app/index-abc.js", {
method: "GET",
});

const resp = await worker.fetch(req, env);

expect(resp.status).toBe(200);
expect(await resp.text()).toBe("console.log('ok');");
expect(resp.headers.get("Content-Type")).toBe("application/javascript");
expect(resp.headers.get("Cache-Control")).toBe(
"public, max-age=31536000, immutable"
);
expect(findLog(collectLogs(consoleSpy), "stale_asset_404")).toBeUndefined();
});

it("passes through 200 text/html SPA fallback for non-/_app/ routes like /dashboard", async () => {
const html = "<!doctype html><html><body><div id='root'></div></body></html>";
const env = makeEnv({
ASSETS: {
fetch: async () =>
new Response(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
}),
},
});
const req = new Request("https://gh.gordoncode.dev/dashboard", {
method: "GET",
});

const resp = await worker.fetch(req, env);

expect(resp.status).toBe(200);
expect(await resp.text()).toBe(html);
expect(resp.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(findLog(collectLogs(consoleSpy), "stale_asset_404")).toBeUndefined();
});

it("passes through 304 Not Modified response from ASSETS for /_app/x.js", async () => {
const env = makeEnv({
ASSETS: {
fetch: async () =>
new Response(null, {
status: 304,
headers: { ETag: '"abc123"' },
}),
},
});
const req = new Request("https://gh.gordoncode.dev/_app/x.js", {
method: "GET",
});

const resp = await worker.fetch(req, env);

expect(resp.status).toBe(304);
expect(resp.headers.get("ETag")).toBe('"abc123"');
expect(findLog(collectLogs(consoleSpy), "stale_asset_404")).toBeUndefined();
});
});
7 changes: 7 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ export default defineConfig({
build: {
outDir: "dist",
},
environments: {
client: {
build: {
assetsDir: "_app",
},
},
},
});
Loading