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}>
+
+ Tracking 100+ repos may cause GitHub API rate limit issues. Consider reducing your selection.
+
+
);
}
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("