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
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ jobs:
- run: pnpm test
- name: Verify CSP hash
run: node scripts/verify-csp-hash.mjs
- name: WAF smoke tests
run: pnpm test:waf
env:
WAF_BYPASS_TOKEN: ${{ secrets.WAF_BYPASS_TOKEN }}
- run: pnpm run build
env:
VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }}
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
"test:watch": "vitest --config vitest.workspace.ts",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test"
"test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test",
"test:waf": "bash scripts/waf-smoke-test.sh"
},
"dependencies": {
"@kobalte/core": "^0.13.11",
"@octokit/core": "^7.0.6",
"@octokit/plugin-paginate-rest": "^14.0.0",
"@octokit/plugin-retry": "^8.1.0",
"@octokit/plugin-throttling": "^11.0.3",
"@sentry/solid": "^10.46.0",
"@solidjs/router": "^0.16.1",
"corvu": "^0.7.2",
"idb": "^8.0.3",
Expand Down
76 changes: 76 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ pass_filenames = false
always_run = true
priority = 0

[[repos.hooks]]
id = "waf"
name = "WAF smoke tests"
language = "system"
entry = "pnpm test:waf"
pass_filenames = false
always_run = true
priority = 0

[[repos.hooks]]
id = "e2e"
name = "Playwright E2E tests"
Expand Down
111 changes: 111 additions & 0 deletions scripts/waf-smoke-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# WAF Smoke Tests — validates Cloudflare WAF rules for gh.gordoncode.dev
#
# Usage: pnpm test:waf
#
# Rules validated:
# 1. Path Allowlist — blocks all paths except known SPA routes, /assets/*, /api/*
# 2. Scanner User-Agents — challenges empty/malicious User-Agent strings
# Rate limit rule exists but is not tested here (triggers a 10-minute IP block).

set -euo pipefail

BASE="https://gh.gordoncode.dev"
PASS=0
FAIL=0

# When WAF_BYPASS_TOKEN is set (CI), send a header that a Cloudflare WAF rule
# uses to skip Bot Fight Mode for this request. Without it (local dev), requests
# pass through normally since residential IPs aren't challenged.
BYPASS=()
if [[ -n "${WAF_BYPASS_TOKEN:-}" ]]; then
BYPASS=(-H "X-CI-Bypass: ${WAF_BYPASS_TOKEN}")
fi

assert_status() {
local expected="$1" actual="$2" label="$3"
if [[ "$actual" == "$expected" ]]; then
echo " PASS [${actual}] ${label}"
PASS=$((PASS + 1))
else
echo " FAIL [${actual}] ${label} (expected ${expected})"
FAIL=$((FAIL + 1))
fi
}

fetch() {
curl -s -o /dev/null -w "%{http_code}" "${BYPASS[@]}" "$@"
}

# ============================================================
# Rule 1: Path Allowlist
# ============================================================
echo "=== Rule 1: Path Allowlist ==="
echo "--- Allowed paths (should pass) ---"

for path in "/" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy"; do
status=$(fetch "${BASE}${path}")
assert_status "200" "$status" "GET ${path}"
done

status=$(fetch "${BASE}/index.html")
assert_status "307" "$status" "GET /index.html (html_handling redirect)"

status=$(fetch "${BASE}/assets/nonexistent.js")
assert_status "200" "$status" "GET /assets/nonexistent.js"

status=$(fetch "${BASE}/api/health")
assert_status "200" "$status" "GET /api/health"

status=$(fetch -X POST "${BASE}/api/oauth/token")
assert_status "400" "$status" "POST /api/oauth/token (no body)"

status=$(fetch "${BASE}/api/nonexistent")
assert_status "404" "$status" "GET /api/nonexistent"

echo "--- Blocked paths (should be 403) ---"

for path in "/wp-admin" "/wp-login.php" "/.env" "/.env.production" \
"/.git/config" "/.git/HEAD" "/xmlrpc.php" \
"/phpmyadmin/" "/phpMyAdmin/" "/.htaccess" "/.htpasswd" \
"/cgi-bin/" "/admin/" "/wp-content/debug.log" \
"/config.php" "/backup.zip" "/actuator/health" \
"/manager/html" "/wp-config.php" "/eval-stdin.php" \
"/.aws/credentials" "/.ssh/id_rsa" "/robots.txt" \
"/sitemap.xml" "/favicon.ico" "/random/garbage/path"; do
status=$(fetch "${BASE}${path}")
assert_status "403" "$status" "GET ${path}"
done

# ============================================================
# Rule 2: Scanner User-Agents
# ============================================================
echo ""
echo "=== Rule 2: Scanner User-Agents ==="
echo "--- Normal UAs (should pass) ---"

status=$(fetch -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" "${BASE}/")
assert_status "200" "$status" "Normal browser UA"

status=$(fetch "${BASE}/")
assert_status "200" "$status" "Default curl UA"

echo "--- Malicious UAs (should be 403 — managed challenge, no JS) ---"

status=$(fetch -H "User-Agent:" "${BASE}/")
assert_status "403" "$status" "Empty User-Agent"

for ua in "sqlmap/1.7" "Nikto/2.1.6" "Nmap Scripting Engine" "masscan/1.3" "Mozilla/5.0 zgrab/0.x"; do
status=$(fetch -H "User-Agent: ${ua}" "${BASE}/")
assert_status "403" "$status" "UA: ${ua}"
done

# ============================================================
# Summary
# ============================================================
echo ""
TOTAL=$((PASS + FAIL))
echo "=== Results: ${PASS}/${TOTAL} passed, ${FAIL} failed ==="
if [[ $FAIL -gt 0 ]]; then
exit 1
fi
5 changes: 5 additions & 0 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import "./index.css";
import { render } from "solid-js/web";
import { initSentry } from "./lib/sentry";
import App from "./App";

// Initialize Sentry before rendering — captures errors from first paint.
// No-ops in dev/test (guarded by import.meta.env.DEV check).
initSentry();

render(() => <App />, document.getElementById("app")!);
104 changes: 104 additions & 0 deletions src/app/lib/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as Sentry from "@sentry/solid";
import type { ErrorEvent, Breadcrumb } from "@sentry/solid";

/** Strip OAuth credentials from any captured URL or query string. */
export function scrubUrl(url: string): string {
return url
.replace(/code=[^&\s]+/g, "code=[REDACTED]")
.replace(/state=[^&\s]+/g, "state=[REDACTED]")
.replace(/access_token=[^&\s]+/g, "access_token=[REDACTED]");
}

/** Allowed console breadcrumb prefixes — drop everything else. */
const ALLOWED_CONSOLE_PREFIXES = [
"[auth]",
"[api]",
"[poll]",
"[dashboard]",
"[settings]",
];

const SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240";

export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null {
// Strip OAuth params from captured URLs
if (event.request?.url) {
event.request.url = scrubUrl(event.request.url);
}
if (event.request?.query_string) {
event.request.query_string =
typeof event.request.query_string === "string"
? scrubUrl(event.request.query_string)
: "[REDACTED]";
}
// Remove headers and cookies entirely
delete event.request?.headers;
delete event.request?.cookies;
// Remove user identity — we never want to track users
delete event.user;
// Scrub URLs in stack trace frames
if (event.exception?.values) {
for (const ex of event.exception.values) {
if (ex.stacktrace?.frames) {
for (const frame of ex.stacktrace.frames) {
if (frame.abs_path) {
frame.abs_path = scrubUrl(frame.abs_path);
}
}
}
}
}
return event;
}

export function beforeBreadcrumbHandler(
breadcrumb: Breadcrumb,
): Breadcrumb | null {
// Scrub URLs in navigation breadcrumbs
if (breadcrumb.category === "navigation") {
if (breadcrumb.data?.from)
breadcrumb.data.from = scrubUrl(breadcrumb.data.from as string);
if (breadcrumb.data?.to)
breadcrumb.data.to = scrubUrl(breadcrumb.data.to as string);
}
// Scrub URLs in fetch/xhr breadcrumbs
if (
breadcrumb.category === "fetch" ||
breadcrumb.category === "xhr"
) {
if (breadcrumb.data?.url)
breadcrumb.data.url = scrubUrl(breadcrumb.data.url as string);
}
// Only keep our own tagged console logs — drop third-party noise
if (breadcrumb.category === "console") {
const msg = breadcrumb.message ?? "";
if (!ALLOWED_CONSOLE_PREFIXES.some((p) => msg.startsWith(p))) {
return null;
}
}
return breadcrumb;
}

export function initSentry(): void {
if (import.meta.env.DEV || !SENTRY_DSN) return;

Sentry.init({
dsn: SENTRY_DSN,
tunnel: "/api/error-reporting",
environment: import.meta.env.MODE,

// ── Privacy: absolute minimum data ──────────────────────────
sendDefaultPii: false,

// ── Disable everything except error tracking ────────────────
tracesSampleRate: 0,
profilesSampleRate: 0,

// ── Only capture errors from our own code ───────────────────
allowUrls: [/^https:\/\/gh\.gordoncode\.dev/],

// ── Scrub sensitive data before it leaves the browser ────────
beforeSend: beforeSendHandler,
beforeBreadcrumb: beforeBreadcrumbHandler,
});
}
Loading
Loading