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..3e98e966 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9399b6fe..d8505edb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..97b895e2 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +ignore-scripts=true diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..d3aa6014 --- /dev/null +++ b/SECURITY.md @@ -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 diff --git a/package.json b/package.json index 36abcd90..55ab11ac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fc77624..517be871 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: specifier: 6.2.5 version: 6.2.5 happy-dom: - specifier: ^20.8.9 + specifier: 20.8.9 version: 20.8.9 tailwindcss: specifier: 4.2.2 diff --git a/prek.toml b/prek.toml index d368da69..6d37fc7e 100644 --- a/prek.toml +++ b/prek.toml @@ -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" @@ -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 diff --git a/scripts/verify-csp-hash.mjs b/scripts/verify-csp-hash.mjs deleted file mode 100644 index feadc909..00000000 --- a/scripts/verify-csp-hash.mjs +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -/** - * Verifies that the SHA-256 hash in public/_headers matches the inline - * 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" 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 diff --git a/src/app/App.tsx b/src/app/App.tsx index f6f5aa10..6d63ee16 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,7 +2,7 @@ import { createSignal, createEffect, onMount, Show, ErrorBoundary, Suspense, laz import { Router, Route, Navigate, useNavigate } from "@solidjs/router"; import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth"; import { config, initConfigPersistence, resolveTheme } from "./stores/config"; -import { initViewPersistence } from "./stores/view"; +import { initViewPersistence, pruneStaleIgnoredItems } from "./stores/view"; import { evictStaleEntries } from "./stores/cache"; import { initClientWatcher } from "./services/github"; import LoginPage from "./pages/LoginPage"; @@ -161,6 +161,7 @@ export default function App() { initConfigPersistence(); initViewPersistence(); initClientWatcher(); + pruneStaleIgnoredItems(); evictStaleEntries(24 * 60 * 60 * 1000).catch(() => { // Non-fatal — stale eviction failure is acceptable }); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 3c6500a1..fe932933 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -84,13 +84,14 @@ onAuthCleared(() => { const coord = _coordinator(); if (coord) { coord.destroy(); - _setCoordinator(null); + if (_coordinator() === coord) _setCoordinator(null); } const hotCoord = _hotCoordinator(); if (hotCoord) { hotCoord.destroy(); - _setHotCoordinator(null); + if (_hotCoordinator() === hotCoord) _setHotCoordinator(null); } + clearHotSets(); }); async function pollFetch(): Promise { @@ -312,10 +313,12 @@ export default function DashboardPage() { const clockInterval = setInterval(() => setClockTick((t) => t + 1), 60_000); onCleanup(() => { - _coordinator()?.destroy(); - _setCoordinator(null); - _hotCoordinator()?.destroy(); - _setHotCoordinator(null); + const coord = _coordinator(); + const hotCoord = _hotCoordinator(); + coord?.destroy(); + if (_coordinator() === coord) _setCoordinator(null); + hotCoord?.destroy(); + if (_hotCoordinator() === hotCoord) _setHotCoordinator(null); clearHotSets(); clearInterval(clockInterval); }); diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 911e368c..9ab23107 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -1,7 +1,8 @@ import { createMemo, For, JSX, Show } from "solid-js"; import { isSafeGitHubUrl } from "../../lib/url"; -import { relativeTime, shortRelativeTime, labelTextColor, formatCount } from "../../lib/format"; +import { relativeTime, shortRelativeTime, formatCount } from "../../lib/format"; import { expandEmoji } from "../../lib/emoji"; +import { labelColorClass } from "../../lib/label-colors"; export interface ItemRowProps { repo: string; @@ -102,19 +103,13 @@ export default function ItemRow(props: ItemRowProps) { 0}>
- {(label) => { - const isValidHex = /^[0-9a-fA-F]{6}$/.test(label.color); - const bg = isValidHex ? `#${label.color}` : "#e5e7eb"; - const fg = isValidHex ? labelTextColor(label.color) : "#374151"; - return ( - - {expandEmoji(label.name)} - - ); - }} + {(label) => ( + + {expandEmoji(label.name)} + + )}
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 08fc066f..f1ae9bb5 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -3,6 +3,7 @@ import { config, type TrackedUser } from "../../stores/config"; import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest, RepoRef } from "../../services/api"; import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format"; +import { isSafeGitHubUrl } from "../../lib/url"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import ItemRow from "./ItemRow"; import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge"; @@ -542,8 +543,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { - - + + + + {/* Rate limit warning for large selections */} + 100}> + + ); } diff --git a/src/app/components/shared/UserAvatarBadge.tsx b/src/app/components/shared/UserAvatarBadge.tsx index 8aa0e3f0..280619e1 100644 --- a/src/app/components/shared/UserAvatarBadge.tsx +++ b/src/app/components/shared/UserAvatarBadge.tsx @@ -31,8 +31,7 @@ export default function UserAvatarBadge(props: UserAvatarBadgeProps) { {(u, i) => (
0 ? { "margin-left": "-6px" } : {}} + class={`avatar${i() > 0 ? " -ml-1.5" : ""}`} >
(); +const FALLBACK_BG = "e5e7eb"; +const FALLBACK_FG = "374151"; + +/** + * Registers a label color in the adopted stylesheet and returns + * the CSS class name. Hex values are validated before use — + * only [0-9a-fA-F]{6} is accepted. + */ +export function labelColorClass(hex: string): string { + const isValid = /^[0-9a-fA-F]{6}$/.test(hex); + const safeHex = isValid ? hex.toLowerCase() : FALLBACK_BG; + + if (!registered.has(safeHex)) { + const bg = `#${safeHex}`; + const fg = isValid ? labelTextColor(safeHex) : `#${FALLBACK_FG}`; + getSheet().insertRule(`.lb-${safeHex} { background-color: ${bg}; color: ${fg}; }`); + registered.add(safeHex); + } + return `lb-${safeHex}`; +} + +/** Reset internal state — exposed for testing only. */ +export function _resetLabelColors(): void { + registered.clear(); + if (_sheet) { + while (_sheet.cssRules.length > 0) _sheet.deleteRule(0); + } +} diff --git a/src/app/pages/OAuthCallback.tsx b/src/app/pages/OAuthCallback.tsx index 09135405..335207ac 100644 --- a/src/app/pages/OAuthCallback.tsx +++ b/src/app/pages/OAuthCallback.tsx @@ -24,10 +24,6 @@ export default function OAuthCallback() { const storedState = sessionStorage.getItem(OAUTH_STATE_KEY); sessionStorage.removeItem(OAUTH_STATE_KEY); - // Read and clear returnTo before CSRF check — always consumed, even on failure - const returnTo = sessionStorage.getItem(OAUTH_RETURN_TO_KEY); - sessionStorage.removeItem(OAUTH_RETURN_TO_KEY); - // Validate state before anything else (CSRF protection) if (!stateFromUrl || !storedState || stateFromUrl !== storedState) { setError("Invalid OAuth state. Please try signing in again."); @@ -64,6 +60,9 @@ export default function OAuthCallback() { return; } + // Read and clear returnTo only after successful auth (preserves it for retry on failure) + const returnTo = sessionStorage.getItem(OAUTH_RETURN_TO_KEY); + sessionStorage.removeItem(OAUTH_RETURN_TO_KEY); navigate(sanitizeReturnTo(returnTo), { replace: true }); } catch { setError("A network error occurred. Please try again."); diff --git a/src/app/services/api.ts b/src/app/services/api.ts index a0726018..82714ba0 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -205,7 +205,7 @@ function extractSearchPartialData(err: unknown): T | null { return null; } -const VALID_REPO_NAME = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const VALID_REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/; // Allows alphanumeric/hyphen base (1-39 chars) with optional literal [bot] suffix for GitHub // App bot accounts. Case-sensitive [bot] is intentional — GitHub always uses lowercase. const VALID_TRACKED_LOGIN = /^[A-Za-z0-9-]{1,39}(\[bot\])?$/; @@ -1882,6 +1882,7 @@ export async function fetchRepos( if (!octokit) throw new Error("No GitHub client available"); const repos: RepoEntry[] = []; + const REPO_CAP = 1000; if (type === "org") { for await (const response of octokit.paginate.iterator(`GET /orgs/{org}/repos`, { @@ -1893,6 +1894,7 @@ export async function fetchRepos( for (const repo of response.data as RawRepo[]) { repos.push({ owner: repo.owner.login, name: repo.name, fullName: repo.full_name, pushedAt: repo.pushed_at ?? null }); } + if (repos.length >= REPO_CAP) break; } } else { for await (const response of octokit.paginate.iterator(`GET /user/repos`, { @@ -1904,9 +1906,18 @@ export async function fetchRepos( for (const repo of response.data as RawRepo[]) { repos.push({ owner: repo.owner.login, name: repo.name, fullName: repo.full_name, pushedAt: repo.pushed_at ?? null }); } + if (repos.length >= REPO_CAP) break; } } + if (repos.length >= REPO_CAP) { + pushNotification( + "api", + `${orgOrUser} has 1000+ repos — showing the most recently active`, + "warning", + ); + } + return repos; } @@ -2264,7 +2275,14 @@ export async function discoverUpstreamRepos( } if (logins.length === 0) return []; - await Promise.allSettled(logins.map((login) => discoverForUser(login))); + // Process users sequentially so the repoNames.size cap check is atomic + // across iterations (prevents TOCTOU race from parallel writes to the shared Set). + // Issues + PRs searches for each user still run in parallel — they write to + // the same Set within a single iteration, which is safe. + for (const login of logins) { + if (repoNames.size >= CAP) break; + await discoverForUser(login); + } if (errors.length > 0) { pushNotification( diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 02ff9d42..79926454 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -267,11 +267,13 @@ export async function fetchAllData( ]; for (const [result, label] of settled) { if (result.status === "rejected") { - const reason = result.reason; - const statusCode = typeof reason === "object" && reason !== null && typeof (reason as Record).status === "number" - ? (reason as Record).status as number + const err = result.reason; + // Propagate 401 to outer handler for re-auth (don't absorb as generic error) + if (err?.status === 401 || err?.response?.status === 401) throw err; + const statusCode = typeof err === "object" && err !== null && typeof (err as Record).status === "number" + ? (err as Record).status as number : null; - const message = reason instanceof Error ? reason.message : String(reason); + const message = err instanceof Error ? err.message : String(err); topLevelErrors.push({ repo: label, statusCode, message, retryable: statusCode === null || (statusCode !== null && statusCode >= 500) }); } } diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index 98b1be7d..ba3faf07 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -105,9 +105,10 @@ export function clearAuth(): void { * navigation handles teardown. Use clearAuth() if not navigating. */ export function expireToken(): void { localStorage.removeItem(AUTH_STORAGE_KEY); + localStorage.removeItem(DASHBOARD_STORAGE_KEY); _setToken(null); setUser(null); - console.info("[auth] token expired (user data preserved)"); + console.info("[auth] token expired (dashboard cache cleared)"); } const VALIDATE_HEADERS = { @@ -174,3 +175,16 @@ export async function validateToken(): Promise { return false; } } + +// Cross-tab auth sync: if another tab clears the token, this tab should also clear. +// Uses expireToken() (not clearAuth()) to avoid wiping config/view that may still be valid. +if (typeof window !== "undefined") { + window.addEventListener("storage", (e: StorageEvent) => { + if (e.key === AUTH_STORAGE_KEY && e.newValue === null && _token()) { + // Re-check: a rapid sign-out/sign-in may have already replaced the token + if (localStorage.getItem(AUTH_STORAGE_KEY) !== null) return; + expireToken(); + window.location.replace("/login"); + } + }); +} diff --git a/src/app/stores/cache.ts b/src/app/stores/cache.ts index d1b46495..c2ced352 100644 --- a/src/app/stores/cache.ts +++ b/src/app/stores/cache.ts @@ -65,8 +65,12 @@ export async function setCacheEntry( ) { // Emergency eviction: delete oldest 50% of entries, then retry once await evictOldestPercent(50); - const db = await getDb(); - await db.put("cache", entry); + try { + const db = await getDb(); + await db.put("cache", entry); + } catch { + console.warn("[cache] Still over quota after emergency eviction — entry dropped"); + } } else { throw err; } diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index cad1a4de..92fd6694 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -4,6 +4,7 @@ import { createEffect, onCleanup, untrack } from "solid-js"; import { pushNotification } from "../lib/errors"; export const VIEW_STORAGE_KEY = "github-tracker:view"; +const IGNORED_ITEMS_CAP = 500; const IssueFiltersSchema = z.object({ role: z.enum(["all", "author", "assignee"]).default("all"), @@ -55,6 +56,7 @@ export const ViewStateSchema = z.object({ ignoredAt: z.number(), }) ) + .max(IGNORED_ITEMS_CAP) .default([]), globalFilter: z .object({ @@ -146,6 +148,10 @@ export function ignoreItem(item: IgnoredItem): void { produce((draft) => { const already = draft.ignoredItems.some((i) => i.id === item.id); if (!already) { + // FIFO eviction: remove oldest if at cap + if (draft.ignoredItems.length >= IGNORED_ITEMS_CAP) { + draft.ignoredItems.shift(); + } draft.ignoredItems.push(item); } }) @@ -160,6 +166,17 @@ export function unignoreItem(id: string): void { ); } +export function pruneStaleIgnoredItems(): void { + const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + setViewState( + produce((draft) => { + draft.ignoredItems = draft.ignoredItems.filter( + (i) => i.ignoredAt > thirtyDaysAgo + ); + }) + ); +} + export function setSortPreference( tabId: string, field: string, diff --git a/src/worker/index.ts b/src/worker/index.ts index 2eed9bae..105fff0a 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -3,7 +3,8 @@ export interface Env { GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; ALLOWED_ORIGIN: string; - SENTRY_DSN: string; // e.g. "https://key@o123456.ingest.sentry.io/7890123" + SENTRY_DSN?: string; // e.g. "https://key@o123456.ingest.sentry.io/7890123" + SENTRY_SECURITY_TOKEN?: string; // Optional: Sentry security token for Allowed Domains validation } // Predefined error strings only (SDR-006) @@ -53,11 +54,44 @@ function errorResponse( } const SECURITY_HEADERS: Record = { + "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "X-Frame-Options": "DENY", }; +// Simple in-memory rate limiter for token exchange endpoint. +// Not durable across isolate restarts, but catches burst abuse. +// Note: CF-Connecting-IP is set by Cloudflare's proxy layer; if the workers.dev +// route is enabled, an attacker could spoof this header. Disable the workers.dev +// route in the Cloudflare dashboard for production use. +const TOKEN_RATE_LIMIT = 10; // max requests per window +const TOKEN_RATE_WINDOW_MS = 60_000; // 1 minute +const _tokenRateMap = new Map(); + +function checkTokenRateLimit(ip: string): boolean { + const now = Date.now(); + const entry = _tokenRateMap.get(ip); + if (!entry || now >= entry.resetAt) { + _tokenRateMap.set(ip, { count: 1, resetAt: now + TOKEN_RATE_WINDOW_MS }); + return true; + } + entry.count++; + if (entry.count > TOKEN_RATE_LIMIT) return false; + return true; +} + +// Periodic cleanup to prevent unbounded map growth. +// Only runs when the map exceeds a threshold to avoid O(N) scan on every request. +const PRUNE_THRESHOLD = 100; +function pruneTokenRateMap(): void { + if (_tokenRateMap.size < PRUNE_THRESHOLD) return; + const now = Date.now(); + for (const [ip, entry] of _tokenRateMap) { + if (now >= entry.resetAt) _tokenRateMap.delete(ip); + } +} + // CORS: strict equality only (SDR-004) function getCorsHeaders( requestOrigin: string | null, @@ -82,6 +116,10 @@ const SENTRY_ENVELOPE_MAX_BYTES = 256 * 1024; // 256 KB — Sentry rejects >200K interface ParsedDsn { host: string; projectId: string; publicKey: string } +// Module-level cache is safe here: value derived entirely from env.SENTRY_DSN +// (a deployment constant, never user input). Shared across requests in the same +// Worker isolate, which is intentional for performance. Do NOT follow this pattern +// for request-scoped or user-controlled data. let _dsnCache: { dsn: string; parsed: ParsedDsn | null } | undefined; /** Parse host, project ID, and public key from a Sentry DSN URL. Returns null if invalid. */ @@ -99,8 +137,9 @@ function parseSentryDsn(dsn: string): ParsedDsn | null { /** Get cached parsed DSN, re-parsing only when the DSN string changes. */ function getOrCacheDsn(env: Env): ParsedDsn | null { - if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) { - _dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) }; + const dsn = env.SENTRY_DSN ?? ""; + if (!_dsnCache || _dsnCache.dsn !== dsn) { + _dsnCache = { dsn, parsed: parseSentryDsn(dsn) }; } return _dsnCache.parsed; } @@ -171,9 +210,15 @@ async function handleSentryTunnel( // Forward to Sentry ingest endpoint const sentryUrl = `https://${allowedDsn.host}/api/${allowedDsn.projectId}/envelope/`; try { + const sentryHeaders: Record = { + "Content-Type": "application/x-sentry-envelope", + }; + if (env.SENTRY_SECURITY_TOKEN) { + sentryHeaders["X-Sentry-Token"] = env.SENTRY_SECURITY_TOKEN; + } const sentryResp = await fetch(sentryUrl, { method: "POST", - headers: { "Content-Type": "application/x-sentry-envelope" }, + headers: sentryHeaders, body, }); @@ -279,7 +324,10 @@ async function handleCspReport(request: Request, env: Env): Promise { scrubbedPayloads.map((payload) => fetch(sentryUrl, { method: "POST", - headers: { "Content-Type": "application/csp-report" }, + headers: { + "Content-Type": "application/csp-report", + ...(env.SENTRY_SECURITY_TOKEN ? { "X-Sentry-Token": env.SENTRY_SECURITY_TOKEN } : {}), + }, body: JSON.stringify(payload), }).catch(() => null) ) @@ -308,6 +356,20 @@ async function handleTokenExchange( return errorResponse("method_not_allowed", 405, cors); } + pruneTokenRateMap(); + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + if (!checkTokenRateLimit(ip)) { + log("warn", "token_exchange_rate_limited", {}, request); + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { + "Content-Type": "application/json", + ...cors, + ...SECURITY_HEADERS, + }, + }); + } + log("info", "token_exchange_started", {}, request); const contentType = request.headers.get("Content-Type") ?? ""; diff --git a/tests/components/OAuthCallback.test.tsx b/tests/components/OAuthCallback.test.tsx index b6223d6c..56854fb5 100644 --- a/tests/components/OAuthCallback.test.tsx +++ b/tests/components/OAuthCallback.test.tsx @@ -314,7 +314,7 @@ describe("OAuthCallback", () => { expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull(); }); - it("OAUTH_RETURN_TO_KEY is removed from sessionStorage after reading", async () => { + it("OAUTH_RETURN_TO_KEY is preserved while token exchange is in flight", async () => { sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings"); setupValidState(); setWindowSearch({ code: "fakecode", state: "teststate" }); @@ -322,12 +322,12 @@ describe("OAuthCallback", () => { renderCallback(); - await waitFor(() => { - expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull(); - }); + // returnTo is not consumed until auth completes — preserved for retry on failure + await new Promise((r) => setTimeout(r, 50)); + expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBe("/settings"); }); - it("OAUTH_RETURN_TO_KEY is cleared even when CSRF check fails (stale key protection)", async () => { + it("OAUTH_RETURN_TO_KEY is preserved when CSRF check fails (only consumed on success)", async () => { sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings"); sessionStorage.setItem(OAUTH_STATE_KEY, "expected-state"); setWindowSearch({ code: "fakecode", state: "wrong-state" }); @@ -337,7 +337,49 @@ describe("OAuthCallback", () => { await waitFor(() => { screen.getByText(/Invalid OAuth state/i); }); - expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull(); + // returnTo is preserved for the user's next legitimate auth attempt + expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBe("/settings"); + }); + + it("OAUTH_RETURN_TO_KEY is preserved when validateToken returns false", async () => { + sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings"); + setupValidState(); + setWindowSearch({ code: "fakecode", state: "teststate" }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ access_token: "tok123" }), + }) + ); + vi.mocked(authStore.validateToken).mockResolvedValue(false); + + renderCallback(); + + await waitFor(() => { + screen.getByText(/Could not verify token/i); + }); + expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBe("/settings"); + }); + + it("OAUTH_RETURN_TO_KEY is preserved when token exchange fails", async () => { + sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings"); + setupValidState(); + setWindowSearch({ code: "fakecode", state: "teststate" }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: "bad_verification_code" }), + }) + ); + + renderCallback(); + + await waitFor(() => { + screen.getByText(/Failed to complete sign in/i); + }); + expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBe("/settings"); }); it("navigates to / when OAUTH_RETURN_TO_KEY is not set", async () => { diff --git a/tests/components/shared/UserAvatarBadge.test.tsx b/tests/components/shared/UserAvatarBadge.test.tsx index 49f408ff..4e7239fb 100644 --- a/tests/components/shared/UserAvatarBadge.test.tsx +++ b/tests/components/shared/UserAvatarBadge.test.tsx @@ -95,7 +95,7 @@ describe("UserAvatarBadge", () => { // Second avatar wrapper should have a negative margin-left style const avatarWrappers = container.querySelectorAll(".avatar"); expect(avatarWrappers.length).toBe(2); - // First has no negative margin; second does - expect((avatarWrappers[1] as HTMLElement).style.marginLeft).toBe("-6px"); + // First has no negative margin class; second does + expect(avatarWrappers[1].classList.contains("-ml-1.5")).toBe(true); }); }); diff --git a/tests/lib/label-colors.test.ts b/tests/lib/label-colors.test.ts new file mode 100644 index 00000000..666ef49f --- /dev/null +++ b/tests/lib/label-colors.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { labelColorClass, _resetLabelColors } from "../../src/app/lib/label-colors"; + +describe("labelColorClass", () => { + beforeEach(() => { + _resetLabelColors(); + }); + + it("returns lb- class for valid 6-char hex", () => { + expect(labelColorClass("abc123")).toBe("lb-abc123"); + }); + + it("lowercases hex for consistent class names", () => { + expect(labelColorClass("ABC123")).toBe("lb-abc123"); + }); + + it("returns fallback class for invalid hex", () => { + expect(labelColorClass("gggggg")).toBe("lb-e5e7eb"); + expect(labelColorClass("")).toBe("lb-e5e7eb"); + expect(labelColorClass("abc")).toBe("lb-e5e7eb"); + expect(labelColorClass("abc1234")).toBe("lb-e5e7eb"); + }); + + it("returns fallback class for injection attempts", () => { + expect(labelColorClass("abc123; color: red")).toBe("lb-e5e7eb"); + expect(labelColorClass("