Skip to content

Commit a879a1e

Browse files
authored
feat: adds observability with Worker logging and Sentry (#26)
* feat: adds Worker logging, Sentry tunnel, and error tracking * refactor(worker): simplifies Sentry config to single SENTRY_DSN var * refactor(sentry): uses VITE_SENTRY_DSN env var, removes placeholder * refactor(sentry): hardcodes DSN directly, removes env var indirection * chore(sentry): sets production DSN * fix: addresses PR review findings across security, testing, and quality - Fixes CI failure: removes test:waf script referencing gitignored hack/ dir - Fixes Content-Length bypass: enforces body.length after read, not header - Fixes parseSentryDsn path extraction: uses split/pop instead of replace - Fixes query_string scrubbing: uses scrubUrl instead of blanket redaction - Caches parsed DSN per isolate to avoid repeated URL parsing - Replaces securityHeaders() function with SECURITY_HEADERS constant - Moves token_exchange_started log after method check for accuracy - Simplifies token_exchange_missing_code log payload to 2 fields - Removes allowed_origin from api_request log (env var leak pattern) - Exports scrubUrl/beforeSendHandler/beforeBreadcrumbHandler for testing - Adds 26 new tests: sentry.ts unit tests, 413 enforcement, tunnel log assertions, OPTIONS tunnel, CORS mismatch for tunnel path * fix(ci): moves WAF smoke tests from gitignored hack/ to tracked scripts/ * fix(ci): removes WAF smoke tests from CI (Cloudflare blocks runner IPs) * feat(ci): adds WAF bypass header for CI smoke tests * fix(ci): moves WAF smoke tests to deploy workflow
1 parent 315833f commit a879a1e

File tree

12 files changed

+1370
-20
lines changed

12 files changed

+1370
-20
lines changed

.github/workflows/deploy.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ jobs:
2020
- run: pnpm test
2121
- name: Verify CSP hash
2222
run: node scripts/verify-csp-hash.mjs
23+
- name: WAF smoke tests
24+
run: pnpm test:waf
25+
env:
26+
WAF_BYPASS_TOKEN: ${{ secrets.WAF_BYPASS_TOKEN }}
2327
- run: pnpm run build
2428
env:
2529
VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212
"test:watch": "vitest --config vitest.workspace.ts",
1313
"deploy": "wrangler deploy",
1414
"typecheck": "tsc --noEmit",
15-
"test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test"
15+
"test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test",
16+
"test:waf": "bash scripts/waf-smoke-test.sh"
1617
},
1718
"dependencies": {
1819
"@kobalte/core": "^0.13.11",
1920
"@octokit/core": "^7.0.6",
2021
"@octokit/plugin-paginate-rest": "^14.0.0",
2122
"@octokit/plugin-retry": "^8.1.0",
2223
"@octokit/plugin-throttling": "^11.0.3",
24+
"@sentry/solid": "^10.46.0",
2325
"@solidjs/router": "^0.16.1",
2426
"corvu": "^0.7.2",
2527
"idb": "^8.0.3",

pnpm-lock.yaml

Lines changed: 76 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

prek.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ pass_filenames = false
4040
always_run = true
4141
priority = 0
4242

43+
[[repos.hooks]]
44+
id = "waf"
45+
name = "WAF smoke tests"
46+
language = "system"
47+
entry = "pnpm test:waf"
48+
pass_filenames = false
49+
always_run = true
50+
priority = 0
51+
4352
[[repos.hooks]]
4453
id = "e2e"
4554
name = "Playwright E2E tests"

scripts/waf-smoke-test.sh

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env bash
2+
# WAF Smoke Tests — validates Cloudflare WAF rules for gh.gordoncode.dev
3+
#
4+
# Usage: pnpm test:waf
5+
#
6+
# Rules validated:
7+
# 1. Path Allowlist — blocks all paths except known SPA routes, /assets/*, /api/*
8+
# 2. Scanner User-Agents — challenges empty/malicious User-Agent strings
9+
# Rate limit rule exists but is not tested here (triggers a 10-minute IP block).
10+
11+
set -euo pipefail
12+
13+
BASE="https://gh.gordoncode.dev"
14+
PASS=0
15+
FAIL=0
16+
17+
# When WAF_BYPASS_TOKEN is set (CI), send a header that a Cloudflare WAF rule
18+
# uses to skip Bot Fight Mode for this request. Without it (local dev), requests
19+
# pass through normally since residential IPs aren't challenged.
20+
BYPASS=()
21+
if [[ -n "${WAF_BYPASS_TOKEN:-}" ]]; then
22+
BYPASS=(-H "X-CI-Bypass: ${WAF_BYPASS_TOKEN}")
23+
fi
24+
25+
assert_status() {
26+
local expected="$1" actual="$2" label="$3"
27+
if [[ "$actual" == "$expected" ]]; then
28+
echo " PASS [${actual}] ${label}"
29+
PASS=$((PASS + 1))
30+
else
31+
echo " FAIL [${actual}] ${label} (expected ${expected})"
32+
FAIL=$((FAIL + 1))
33+
fi
34+
}
35+
36+
fetch() {
37+
curl -s -o /dev/null -w "%{http_code}" "${BYPASS[@]}" "$@"
38+
}
39+
40+
# ============================================================
41+
# Rule 1: Path Allowlist
42+
# ============================================================
43+
echo "=== Rule 1: Path Allowlist ==="
44+
echo "--- Allowed paths (should pass) ---"
45+
46+
for path in "/" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy"; do
47+
status=$(fetch "${BASE}${path}")
48+
assert_status "200" "$status" "GET ${path}"
49+
done
50+
51+
status=$(fetch "${BASE}/index.html")
52+
assert_status "307" "$status" "GET /index.html (html_handling redirect)"
53+
54+
status=$(fetch "${BASE}/assets/nonexistent.js")
55+
assert_status "200" "$status" "GET /assets/nonexistent.js"
56+
57+
status=$(fetch "${BASE}/api/health")
58+
assert_status "200" "$status" "GET /api/health"
59+
60+
status=$(fetch -X POST "${BASE}/api/oauth/token")
61+
assert_status "400" "$status" "POST /api/oauth/token (no body)"
62+
63+
status=$(fetch "${BASE}/api/nonexistent")
64+
assert_status "404" "$status" "GET /api/nonexistent"
65+
66+
echo "--- Blocked paths (should be 403) ---"
67+
68+
for path in "/wp-admin" "/wp-login.php" "/.env" "/.env.production" \
69+
"/.git/config" "/.git/HEAD" "/xmlrpc.php" \
70+
"/phpmyadmin/" "/phpMyAdmin/" "/.htaccess" "/.htpasswd" \
71+
"/cgi-bin/" "/admin/" "/wp-content/debug.log" \
72+
"/config.php" "/backup.zip" "/actuator/health" \
73+
"/manager/html" "/wp-config.php" "/eval-stdin.php" \
74+
"/.aws/credentials" "/.ssh/id_rsa" "/robots.txt" \
75+
"/sitemap.xml" "/favicon.ico" "/random/garbage/path"; do
76+
status=$(fetch "${BASE}${path}")
77+
assert_status "403" "$status" "GET ${path}"
78+
done
79+
80+
# ============================================================
81+
# Rule 2: Scanner User-Agents
82+
# ============================================================
83+
echo ""
84+
echo "=== Rule 2: Scanner User-Agents ==="
85+
echo "--- Normal UAs (should pass) ---"
86+
87+
status=$(fetch -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" "${BASE}/")
88+
assert_status "200" "$status" "Normal browser UA"
89+
90+
status=$(fetch "${BASE}/")
91+
assert_status "200" "$status" "Default curl UA"
92+
93+
echo "--- Malicious UAs (should be 403 — managed challenge, no JS) ---"
94+
95+
status=$(fetch -H "User-Agent:" "${BASE}/")
96+
assert_status "403" "$status" "Empty User-Agent"
97+
98+
for ua in "sqlmap/1.7" "Nikto/2.1.6" "Nmap Scripting Engine" "masscan/1.3" "Mozilla/5.0 zgrab/0.x"; do
99+
status=$(fetch -H "User-Agent: ${ua}" "${BASE}/")
100+
assert_status "403" "$status" "UA: ${ua}"
101+
done
102+
103+
# ============================================================
104+
# Summary
105+
# ============================================================
106+
echo ""
107+
TOTAL=$((PASS + FAIL))
108+
echo "=== Results: ${PASS}/${TOTAL} passed, ${FAIL} failed ==="
109+
if [[ $FAIL -gt 0 ]]; then
110+
exit 1
111+
fi

src/app/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import "./index.css";
22
import { render } from "solid-js/web";
3+
import { initSentry } from "./lib/sentry";
34
import App from "./App";
45

6+
// Initialize Sentry before rendering — captures errors from first paint.
7+
// No-ops in dev/test (guarded by import.meta.env.DEV check).
8+
initSentry();
9+
510
render(() => <App />, document.getElementById("app")!);

src/app/lib/sentry.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as Sentry from "@sentry/solid";
2+
import type { ErrorEvent, Breadcrumb } from "@sentry/solid";
3+
4+
/** Strip OAuth credentials from any captured URL or query string. */
5+
export function scrubUrl(url: string): string {
6+
return url
7+
.replace(/code=[^&\s]+/g, "code=[REDACTED]")
8+
.replace(/state=[^&\s]+/g, "state=[REDACTED]")
9+
.replace(/access_token=[^&\s]+/g, "access_token=[REDACTED]");
10+
}
11+
12+
/** Allowed console breadcrumb prefixes — drop everything else. */
13+
const ALLOWED_CONSOLE_PREFIXES = [
14+
"[auth]",
15+
"[api]",
16+
"[poll]",
17+
"[dashboard]",
18+
"[settings]",
19+
];
20+
21+
const SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240";
22+
23+
export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null {
24+
// Strip OAuth params from captured URLs
25+
if (event.request?.url) {
26+
event.request.url = scrubUrl(event.request.url);
27+
}
28+
if (event.request?.query_string) {
29+
event.request.query_string =
30+
typeof event.request.query_string === "string"
31+
? scrubUrl(event.request.query_string)
32+
: "[REDACTED]";
33+
}
34+
// Remove headers and cookies entirely
35+
delete event.request?.headers;
36+
delete event.request?.cookies;
37+
// Remove user identity — we never want to track users
38+
delete event.user;
39+
// Scrub URLs in stack trace frames
40+
if (event.exception?.values) {
41+
for (const ex of event.exception.values) {
42+
if (ex.stacktrace?.frames) {
43+
for (const frame of ex.stacktrace.frames) {
44+
if (frame.abs_path) {
45+
frame.abs_path = scrubUrl(frame.abs_path);
46+
}
47+
}
48+
}
49+
}
50+
}
51+
return event;
52+
}
53+
54+
export function beforeBreadcrumbHandler(
55+
breadcrumb: Breadcrumb,
56+
): Breadcrumb | null {
57+
// Scrub URLs in navigation breadcrumbs
58+
if (breadcrumb.category === "navigation") {
59+
if (breadcrumb.data?.from)
60+
breadcrumb.data.from = scrubUrl(breadcrumb.data.from as string);
61+
if (breadcrumb.data?.to)
62+
breadcrumb.data.to = scrubUrl(breadcrumb.data.to as string);
63+
}
64+
// Scrub URLs in fetch/xhr breadcrumbs
65+
if (
66+
breadcrumb.category === "fetch" ||
67+
breadcrumb.category === "xhr"
68+
) {
69+
if (breadcrumb.data?.url)
70+
breadcrumb.data.url = scrubUrl(breadcrumb.data.url as string);
71+
}
72+
// Only keep our own tagged console logs — drop third-party noise
73+
if (breadcrumb.category === "console") {
74+
const msg = breadcrumb.message ?? "";
75+
if (!ALLOWED_CONSOLE_PREFIXES.some((p) => msg.startsWith(p))) {
76+
return null;
77+
}
78+
}
79+
return breadcrumb;
80+
}
81+
82+
export function initSentry(): void {
83+
if (import.meta.env.DEV || !SENTRY_DSN) return;
84+
85+
Sentry.init({
86+
dsn: SENTRY_DSN,
87+
tunnel: "/api/error-reporting",
88+
environment: import.meta.env.MODE,
89+
90+
// ── Privacy: absolute minimum data ──────────────────────────
91+
sendDefaultPii: false,
92+
93+
// ── Disable everything except error tracking ────────────────
94+
tracesSampleRate: 0,
95+
profilesSampleRate: 0,
96+
97+
// ── Only capture errors from our own code ───────────────────
98+
allowUrls: [/^https:\/\/gh\.gordoncode\.dev/],
99+
100+
// ── Scrub sensitive data before it leaves the browser ────────
101+
beforeSend: beforeSendHandler,
102+
beforeBreadcrumb: beforeBreadcrumbHandler,
103+
});
104+
}

0 commit comments

Comments
 (0)