From 224a91f2f59656efbf58906998119f42f769db6d Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 1 Apr 2026 21:33:02 -0400 Subject: [PATCH 1/8] fix(security): hardens auth, XSS, rate limiting, and repo posture Auth lifecycle: - clears dashboard cache on token expiry (prevents data leak to next user) - propagates 401 from allSettled to trigger re-auth (prevents zombie polling) - adds cross-tab auth sync via storage event listener - moves OAuth returnTo read to after CSRF check passes XSS / injection: - validates pr.htmlUrl with isSafeGitHubUrl before passing to StatusDot/SizeBadge - replaces inline style label colors with adoptedStyleSheets registry - removes style-src-attr unsafe-inline from CSP (zero unsafe-* directives) - refactors UserAvatarBadge inline style to Tailwind class Rate limiting / DoS: - caps fetchRepos pagination at 1000 repos with warning notification - serializes discoverUpstreamRepos to prevent TOCTOU race on repo cap - adds length bounds to VALID_REPO_NAME regex (consistent with Zod schema) - adds soft warning at 100+ repos in RepoSelector - adds in-memory rate limiter to Worker token exchange endpoint - adds HSTS to Worker SECURITY_HEADERS (matches static asset headers) Stores / cache: - adds .max(500) to ignoredItems with FIFO eviction - adds pruneClosedIgnoredItems (30-day TTL) called on app startup - wraps IndexedDB QuotaExceededError retry in try/catch (logs instead of crash) Repo posture: - adds SECURITY.md with responsible disclosure policy - adds CODEOWNERS - adds .npmrc with ignore-scripts=true - pins happy-dom to exact version (removes caret range) - adds CSP inline script hash verification to CI - adds Sentry security token support to Worker tunnel - documents Worker _dsnCache safety rationale - fixes poll coordinator mount/unmount race --- .github/CODEOWNERS | 2 + .github/workflows/ci.yml | 12 +++- .npmrc | 1 + SECURITY.md | 44 ++++++++++++++ package.json | 2 +- public/_headers | 2 +- src/app/App.tsx | 3 +- .../components/dashboard/DashboardPage.tsx | 10 ++-- src/app/components/dashboard/ItemRow.tsx | 23 +++----- .../components/dashboard/PullRequestsTab.tsx | 5 +- .../components/onboarding/RepoSelector.tsx | 9 ++- src/app/components/shared/UserAvatarBadge.tsx | 3 +- src/app/lib/label-colors.ts | 29 +++++++++ src/app/pages/OAuthCallback.tsx | 8 +-- src/app/services/api.ts | 22 ++++++- src/app/services/poll.ts | 10 ++-- src/app/stores/auth.ts | 13 +++- src/app/stores/cache.ts | 8 ++- src/app/stores/view.ts | 16 +++++ src/worker/index.ts | 59 ++++++++++++++++++- tests/components/OAuthCallback.test.tsx | 5 +- .../shared/UserAvatarBadge.test.tsx | 4 +- tests/stores/auth.test.ts | 4 +- tests/stores/cache.test.ts | 11 ++-- tests/worker/oauth.test.ts | 7 ++- 25 files changed, 258 insertions(+), 54 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .npmrc create mode 100644 SECURITY.md create mode 100644 src/app/lib/label-colors.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..a6339c0f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default owner for everything +* @wgordon17 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c14fcbe3..10bcb75f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,18 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify CSP inline script hash + run: | + SCRIPT=$(sed -n 's/.* found in index.html" + [[ -n "${GITHUB_ACTIONS:-}" ]] && echo "::error::No inline found in index.html" + exit 1 +fi +HASH=$(printf '%s' "$SCRIPT" | openssl dgst -sha256 -binary | base64) + +if ! grep -qF "sha256-$HASH" public/_headers; then + echo "CSP hash mismatch! Inline script hash 'sha256-$HASH' not found in public/_headers" + echo "Update the sha256 hash in public/_headers to match the inline script in index.html" + [[ -n "${GITHUB_ACTIONS:-}" ]] && echo "::error::CSP hash mismatch! Inline script hash 'sha256-$HASH' not found in public/_headers" + exit 1 +fi + +echo "CSP hash verified: sha256-$HASH" From 3b69689bbb60527b9880588934428ebdbe8fb6e3 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 2 Apr 2026 13:32:46 -0400 Subject: [PATCH 8/8] perf(waf): parallelizes smoke tests with GNU parallel Replaces sequential curl loops (81s) with GNU parallel (-j10, ~2s). Test specs become a pipe-delimited data array parsed by exported functions. Adds --max-time 10 per curl, --timeout 15 per job, command -v guard, and empty-output infrastructure failure detection. --- scripts/waf-smoke-test.sh | 177 +++++++++++++++++++++----------------- 1 file changed, 98 insertions(+), 79 deletions(-) diff --git a/scripts/waf-smoke-test.sh b/scripts/waf-smoke-test.sh index 7017caf0..1524b50f 100755 --- a/scripts/waf-smoke-test.sh +++ b/scripts/waf-smoke-test.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # WAF Smoke Tests — validates Cloudflare WAF rules for gh.gordoncode.dev +# Requires: GNU parallel (brew install parallel / apt install parallel) # # Usage: pnpm test:waf # @@ -10,94 +11,112 @@ set -euo pipefail +if ! command -v parallel &>/dev/null; then + printf 'Error: GNU parallel is required (brew install parallel / apt install parallel)\n' >&2 + exit 1 +fi + BASE="https://gh.gordoncode.dev" -PASS=0 -FAIL=0 -assert_status() { - local expected="$1" actual="$2" label="$3" +# --- Test runner (exported for GNU parallel) --- +run_test() { + local expected="$1" label="$2" + shift 2 + local actual + actual=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "$@") if [[ "$actual" == "$expected" ]]; then - echo " PASS [${actual}] ${label}" - PASS=$((PASS + 1)) + printf ' PASS [%s] %s\n' "$actual" "$label" else - echo " FAIL [${actual}] ${label} (expected ${expected})" - FAIL=$((FAIL + 1)) + printf ' FAIL [%s] %s (expected %s)\n' "$actual" "$label" "$expected" + return 1 fi } - -fetch() { - curl -s -o /dev/null -w "%{http_code}" "$@" +export -f run_test + +# --- Test spec parser (exported for GNU parallel) --- +# Splits pipe-delimited spec into: expected_status | label | curl_args... +run_spec() { + local expected label + IFS='|' read -ra parts <<< "$1" + expected="${parts[0]}" + label="${parts[1]}" + run_test "$expected" "$label" "${parts[@]:2}" } +export -f run_spec + +# --- Test specs: expected_status | label | curl args ... --- +# Pipe-delimited. Fields after label are passed directly to curl. +TESTS=( + # Rule 1: Path Allowlist — allowed paths + "200|GET /|${BASE}/" + "200|GET /login|${BASE}/login" + "200|GET /oauth/callback|${BASE}/oauth/callback" + "200|GET /onboarding|${BASE}/onboarding" + "200|GET /dashboard|${BASE}/dashboard" + "200|GET /settings|${BASE}/settings" + "200|GET /privacy|${BASE}/privacy" + "307|GET /index.html (html_handling redirect)|${BASE}/index.html" + "200|GET /assets/nonexistent.js|${BASE}/assets/nonexistent.js" + "200|GET /api/health|${BASE}/api/health" + "400|POST /api/oauth/token (no body)|-X|POST|${BASE}/api/oauth/token" + "404|GET /api/nonexistent|${BASE}/api/nonexistent" + # Rule 1: Path Allowlist — blocked paths + "403|GET /wp-admin|${BASE}/wp-admin" + "403|GET /wp-login.php|${BASE}/wp-login.php" + "403|GET /.env|${BASE}/.env" + "403|GET /.env.production|${BASE}/.env.production" + "403|GET /.git/config|${BASE}/.git/config" + "403|GET /.git/HEAD|${BASE}/.git/HEAD" + "403|GET /xmlrpc.php|${BASE}/xmlrpc.php" + "403|GET /phpmyadmin/|${BASE}/phpmyadmin/" + "403|GET /phpMyAdmin/|${BASE}/phpMyAdmin/" + "403|GET /.htaccess|${BASE}/.htaccess" + "403|GET /.htpasswd|${BASE}/.htpasswd" + "403|GET /cgi-bin/|${BASE}/cgi-bin/" + "403|GET /admin/|${BASE}/admin/" + "403|GET /wp-content/debug.log|${BASE}/wp-content/debug.log" + "403|GET /config.php|${BASE}/config.php" + "403|GET /backup.zip|${BASE}/backup.zip" + "403|GET /actuator/health|${BASE}/actuator/health" + "403|GET /manager/html|${BASE}/manager/html" + "403|GET /wp-config.php|${BASE}/wp-config.php" + "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|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}/" + "200|Default curl UA|${BASE}/" + # Rule 2: Scanner User-Agents — malicious UAs + "403|Empty User-Agent|-H|User-Agent:|${BASE}/" + "403|UA: sqlmap/1.7|-H|User-Agent: sqlmap/1.7|${BASE}/" + "403|UA: Nikto/2.1.6|-H|User-Agent: Nikto/2.1.6|${BASE}/" + "403|UA: Nmap Scripting Engine|-H|User-Agent: Nmap Scripting Engine|${BASE}/" + "403|UA: masscan/1.3|-H|User-Agent: masscan/1.3|${BASE}/" + "403|UA: Mozilla/5.0 zgrab/0.x|-H|User-Agent: Mozilla/5.0 zgrab/0.x|${BASE}/" +) + +# --- Run in parallel (::: passes array elements directly, avoiding stdin quoting issues) --- +TOTAL=${#TESTS[@]} + +OUTPUT=$(parallel --will-cite -k -j10 --timeout 15 run_spec ::: "${TESTS[@]}") || true + +# Detect infrastructure failure (parallel crashed, no tests ran) +if [[ -z "$OUTPUT" ]]; then + printf 'Error: test harness produced no output — parallel may have failed\n' >&2 + exit 2 +fi -# ============================================================ -# 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 +# Count results from output +PASS=$(grep -c "^ PASS" <<< "$OUTPUT" || true) +FAIL=$((TOTAL - PASS)) -# ============================================================ -# Summary -# ============================================================ -echo "" -TOTAL=$((PASS + FAIL)) -echo "=== Results: ${PASS}/${TOTAL} passed, ${FAIL} failed ===" +# Print results (preserving order from parallel -k) +printf '%s\n' "$OUTPUT" +printf '\n=== Results: %d/%d passed, %d failed ===\n' "$PASS" "$TOTAL" "$FAIL" if [[ $FAIL -gt 0 ]]; then exit 1 fi