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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Default owner for everything
* @wgordon17
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Verify CSP inline script hash
run: bash scripts/verify-csp-hash.sh
- run: pnpm run typecheck
- run: pnpm test
- name: Verify CSP hash
run: node scripts/verify-csp-hash.mjs
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run E2E tests
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
- run: pnpm test
- name: Verify CSP hash
run: node scripts/verify-csp-hash.mjs
- name: Verify CSP inline script hash
run: bash scripts/verify-csp-hash.sh
- name: WAF smoke tests
run: pnpm test:waf
- run: pnpm run build
Expand Down
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ignore-scripts=true
44 changes: 44 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Security Policy

## Reporting a Vulnerability

If you discover a security vulnerability in this project, please report it responsibly.

**Do not open a public GitHub issue for security vulnerabilities.**

Instead, please email **security@gordoncode.dev** with:

- A description of the vulnerability
- Steps to reproduce
- Any relevant logs or screenshots
- Your assessment of severity (if applicable)

You should receive an acknowledgment within 48 hours. I will work with you to understand and address the issue before any public disclosure.

## Scope

This policy covers:

- The github-tracker web application (`gh.gordoncode.dev`)
- The Cloudflare Worker backend (`src/worker/`)
- The OAuth authentication flow
- Client-side data handling and storage

## Out of Scope

- Vulnerabilities in third-party dependencies (report these to the upstream project)
- Issues requiring physical access to a user's device
- Social engineering attacks
- Denial of service attacks against GitHub's API (rate limits are GitHub's domain)

## Security Controls

This project implements the following security measures:

- **CSP**: Strict Content-Security-Policy with `default-src 'none'`, no `unsafe-eval`, no `unsafe-inline` for scripts (`style-src-attr 'unsafe-inline'` required by Kobalte UI library)
- **CORS**: Strict origin equality matching on the Worker
- **OAuth CSRF**: Cryptographically random state parameter with single-use enforcement
- **Read-only API access**: Octokit hook blocks all write operations
- **Input validation**: GraphQL query parameters validated against allowlisted patterns
- **Sentry PII scrubbing**: Error reports strip auth tokens, headers, cookies, and user identity
- **SHA-pinned CI**: All GitHub Actions pinned to full commit SHAs
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@testing-library/user-event": "14.6.1",
"daisyui": "5.5.19",
"fake-indexeddb": "6.2.5",
"happy-dom": "^20.8.9",
"happy-dom": "20.8.9",
"tailwindcss": "4.2.2",
"typescript": "5.9.3",
"vite": "8.0.1",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

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

11 changes: 10 additions & 1 deletion prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ hooks = [
[[repos]]
repo = "local"

[[repos.hooks]]
id = "lockfile-sync"
name = "Lockfile in sync with package.json"
language = "system"
entry = "pnpm install --frozen-lockfile"
pass_filenames = false
files = "(package\\.json|pnpm-lock\\.yaml)$"
priority = 0

[[repos.hooks]]
id = "typecheck"
name = "TypeScript typecheck"
Expand All @@ -35,7 +44,7 @@ priority = 0
id = "csp-hash"
name = "CSP hash verification"
language = "system"
entry = "node scripts/verify-csp-hash.mjs"
entry = "bash scripts/verify-csp-hash.sh"
pass_filenames = false
always_run = true
priority = 0
Expand Down
52 changes: 0 additions & 52 deletions scripts/verify-csp-hash.mjs

This file was deleted.

19 changes: 19 additions & 0 deletions scripts/verify-csp-hash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT=$(sed -n 's/.*<script>\([^<]*\)<\/script>.*/\1/p' index.html)
if [[ -z "$SCRIPT" ]]; then
echo "No inline <script>...</script> found in index.html"
[[ -n "${GITHUB_ACTIONS:-}" ]] && echo "::error::No inline <script>...</script> 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"
177 changes: 98 additions & 79 deletions scripts/waf-smoke-test.sh
Original file line number Diff line number Diff line change
@@ -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
#
Expand All @@ -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
Loading