Skip to content

Commit 18f4c32

Browse files
committed
fix(security): addresses PR review findings with tests
- removes IP from rate-limit log, adds prune threshold guard - makes SENTRY_DSN optional in Env interface - moves returnTo consumption after auth success in OAuthCallback - adds race-safe coordinator guard to onAuthCleared + clearHotSets - extracts IGNORED_ITEMS_CAP constant, fixes FALLBACK_FG consistency - documents CSP style-src-attr requirement (Kobalte) in SECURITY.md - adds 13 tests: cross-tab auth sync, 401 propagation, label-colors CSS rules, fetchRepos cap, rate-limit window reset, multi-user upstream discovery cap
1 parent d62bc62 commit 18f4c32

File tree

13 files changed

+296
-22
lines changed

13 files changed

+296
-22
lines changed

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ This policy covers:
3535

3636
This project implements the following security measures:
3737

38-
- **CSP**: Strict Content-Security-Policy with `default-src 'none'` and no `unsafe-eval`
38+
- **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)
3939
- **CORS**: Strict origin equality matching on the Worker
4040
- **OAuth CSRF**: Cryptographically random state parameter with single-use enforcement
4141
- **Read-only API access**: Octokit hook blocks all write operations

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,14 @@ onAuthCleared(() => {
8484
const coord = _coordinator();
8585
if (coord) {
8686
coord.destroy();
87-
_setCoordinator(null);
87+
if (_coordinator() === coord) _setCoordinator(null);
8888
}
8989
const hotCoord = _hotCoordinator();
9090
if (hotCoord) {
9191
hotCoord.destroy();
92-
_setHotCoordinator(null);
92+
if (_hotCoordinator() === hotCoord) _setHotCoordinator(null);
9393
}
94+
clearHotSets();
9495
});
9596

9697
async function pollFetch(): Promise<DashboardData> {

src/app/lib/label-colors.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { labelTextColor } from "./format";
22

33
// Dynamic label color registry using adoptedStyleSheets.
4-
// This avoids inline style attributes, allowing removal of
5-
// style-src-attr 'unsafe-inline' from the CSP.
4+
// This avoids inline style attributes for labels — however
5+
// style-src-attr 'unsafe-inline' is still required in the CSP
6+
// because Kobalte sets inline styles for popper positioning,
7+
// collapsible height, and presence animations.
68
let _sheet: CSSStyleSheet | null = null;
79

810
function getSheet(): CSSStyleSheet {
@@ -15,7 +17,7 @@ function getSheet(): CSSStyleSheet {
1517

1618
const registered = new Set<string>();
1719
const FALLBACK_BG = "e5e7eb";
18-
const FALLBACK_FG = "#374151";
20+
const FALLBACK_FG = "374151";
1921

2022
/**
2123
* Registers a label color in the adopted stylesheet and returns
@@ -28,7 +30,7 @@ export function labelColorClass(hex: string): string {
2830

2931
if (!registered.has(safeHex)) {
3032
const bg = `#${safeHex}`;
31-
const fg = isValid ? labelTextColor(safeHex) : FALLBACK_FG;
33+
const fg = isValid ? labelTextColor(safeHex) : `#${FALLBACK_FG}`;
3234
getSheet().insertRule(`.lb-${safeHex} { background-color: ${bg}; color: ${fg}; }`);
3335
registered.add(safeHex);
3436
}

src/app/pages/OAuthCallback.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,6 @@ export default function OAuthCallback() {
3636
return;
3737
}
3838

39-
// Read and clear returnTo only after all pre-exchange checks pass
40-
const returnTo = sessionStorage.getItem(OAUTH_RETURN_TO_KEY);
41-
sessionStorage.removeItem(OAUTH_RETURN_TO_KEY);
42-
4339
try {
4440
const resp = await fetch("/api/oauth/token", {
4541
method: "POST",
@@ -64,6 +60,9 @@ export default function OAuthCallback() {
6460
return;
6561
}
6662

63+
// Read and clear returnTo only after successful auth (preserves it for retry on failure)
64+
const returnTo = sessionStorage.getItem(OAUTH_RETURN_TO_KEY);
65+
sessionStorage.removeItem(OAUTH_RETURN_TO_KEY);
6766
navigate(sanitizeReturnTo(returnTo), { replace: true });
6867
} catch {
6968
setError("A network error occurred. Please try again.");

src/app/stores/view.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createEffect, onCleanup, untrack } from "solid-js";
44
import { pushNotification } from "../lib/errors";
55

66
export const VIEW_STORAGE_KEY = "github-tracker:view";
7+
const IGNORED_ITEMS_CAP = 500;
78

89
const IssueFiltersSchema = z.object({
910
role: z.enum(["all", "author", "assignee"]).default("all"),
@@ -55,7 +56,7 @@ export const ViewStateSchema = z.object({
5556
ignoredAt: z.number(),
5657
})
5758
)
58-
.max(500)
59+
.max(IGNORED_ITEMS_CAP)
5960
.default([]),
6061
globalFilter: z
6162
.object({
@@ -148,7 +149,7 @@ export function ignoreItem(item: IgnoredItem): void {
148149
const already = draft.ignoredItems.some((i) => i.id === item.id);
149150
if (!already) {
150151
// FIFO eviction: remove oldest if at cap
151-
if (draft.ignoredItems.length >= 500) {
152+
if (draft.ignoredItems.length >= IGNORED_ITEMS_CAP) {
152153
draft.ignoredItems.shift();
153154
}
154155
draft.ignoredItems.push(item);

src/worker/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export interface Env {
33
GITHUB_CLIENT_ID: string;
44
GITHUB_CLIENT_SECRET: string;
55
ALLOWED_ORIGIN: string;
6-
SENTRY_DSN: string; // e.g. "https://key@o123456.ingest.sentry.io/7890123"
6+
SENTRY_DSN?: string; // e.g. "https://key@o123456.ingest.sentry.io/7890123"
77
SENTRY_SECURITY_TOKEN?: string; // Optional: Sentry security token for Allowed Domains validation
88
}
99

@@ -81,8 +81,11 @@ function checkTokenRateLimit(ip: string): boolean {
8181
return true;
8282
}
8383

84-
// Periodic cleanup to prevent unbounded map growth
84+
// Periodic cleanup to prevent unbounded map growth.
85+
// Only runs when the map exceeds a threshold to avoid O(N) scan on every request.
86+
const PRUNE_THRESHOLD = 100;
8587
function pruneTokenRateMap(): void {
88+
if (_tokenRateMap.size < PRUNE_THRESHOLD) return;
8689
const now = Date.now();
8790
for (const [ip, entry] of _tokenRateMap) {
8891
if (now >= entry.resetAt) _tokenRateMap.delete(ip);
@@ -134,8 +137,9 @@ function parseSentryDsn(dsn: string): ParsedDsn | null {
134137

135138
/** Get cached parsed DSN, re-parsing only when the DSN string changes. */
136139
function getOrCacheDsn(env: Env): ParsedDsn | null {
137-
if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) {
138-
_dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) };
140+
const dsn = env.SENTRY_DSN ?? "";
141+
if (!_dsnCache || _dsnCache.dsn !== dsn) {
142+
_dsnCache = { dsn, parsed: parseSentryDsn(dsn) };
139143
}
140144
return _dsnCache.parsed;
141145
}
@@ -355,7 +359,7 @@ async function handleTokenExchange(
355359
pruneTokenRateMap();
356360
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
357361
if (!checkTokenRateLimit(ip)) {
358-
log("warn", "token_exchange_rate_limited", { ip }, request);
362+
log("warn", "token_exchange_rate_limited", {}, request);
359363
return new Response(JSON.stringify({ error: "rate_limited" }), {
360364
status: 429,
361365
headers: {

tests/components/OAuthCallback.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,17 +314,17 @@ describe("OAuthCallback", () => {
314314
expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
315315
});
316316

317-
it("OAUTH_RETURN_TO_KEY is removed from sessionStorage after reading", async () => {
317+
it("OAUTH_RETURN_TO_KEY is preserved while token exchange is in flight", async () => {
318318
sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings");
319319
setupValidState();
320320
setWindowSearch({ code: "fakecode", state: "teststate" });
321321
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
322322

323323
renderCallback();
324324

325-
await waitFor(() => {
326-
expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
327-
});
325+
// returnTo is not consumed until auth completes — preserved for retry on failure
326+
await new Promise((r) => setTimeout(r, 50));
327+
expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBe("/settings");
328328
});
329329

330330
it("OAUTH_RETURN_TO_KEY is preserved when CSRF check fails (only consumed on success)", async () => {

tests/lib/label-colors.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,20 @@ describe("labelColorClass", () => {
3232
expect(first).toBe(second);
3333
expect(first).toBe("lb-ff0000");
3434
});
35+
36+
it("inserts CSS rule with correct background and foreground colors", () => {
37+
labelColorClass("000000");
38+
const sheet = document.adoptedStyleSheets[document.adoptedStyleSheets.length - 1];
39+
const rule = sheet.cssRules[0].cssText;
40+
expect(rule).toContain(".lb-000000");
41+
expect(rule).toContain("background-color");
42+
expect(rule).toContain("color");
43+
});
44+
45+
it("does not insert duplicate rules for same hex", () => {
46+
labelColorClass("ff0000");
47+
labelColorClass("ff0000");
48+
const sheet = document.adoptedStyleSheets[document.adoptedStyleSheets.length - 1];
49+
expect(sheet.cssRules.length).toBe(1);
50+
});
3551
});

tests/services/api-users.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,39 @@ describe("discoverUpstreamRepos", () => {
328328
expect(result.length).toBeLessThanOrEqual(100);
329329
});
330330

331+
it("stops processing users when cap is reached across multiple tracked users", async () => {
332+
// User A discovers 70 repos; user B would discover 70 more (overlapping names
333+
// avoided by using distinct prefixes). The shared repoNames Set is capped at 100,
334+
// so user B can only contribute at most 30 before the cap is hit.
335+
const userARepos = Array.from({ length: 70 }, (_, i) => `user-a/repo-${i.toString().padStart(3, "0")}`);
336+
const userBRepos = Array.from({ length: 70 }, (_, i) => `user-b/repo-${i.toString().padStart(3, "0")}`);
337+
338+
const octokit = makeOctokit(
339+
async () => ({}),
340+
async (_query: string, vars: unknown) => {
341+
const v = vars as { q: string };
342+
if (v.q.includes("involves:primary") && v.q.includes("is:issue")) {
343+
return makeSearchPage(userARepos);
344+
}
345+
if (v.q.includes("involves:trackeduser") && v.q.includes("is:issue")) {
346+
return makeSearchPage(userBRepos);
347+
}
348+
return makeSearchPage([]);
349+
}
350+
);
351+
352+
const trackedUsers = [makeTrackedUser("trackeduser")];
353+
const result = await discoverUpstreamRepos(octokit as never, "primary", new Set(), trackedUsers);
354+
355+
// Total must not exceed the CAP of 100
356+
expect(result.length).toBeLessThanOrEqual(100);
357+
// User A's repos should all be present (70 < 100)
358+
const names = result.map((r) => r.fullName);
359+
expect(names.filter((n) => n.startsWith("user-a/")).length).toBe(70);
360+
// User B's repos are partially included (capped at 30)
361+
expect(names.filter((n) => n.startsWith("user-b/")).length).toBeLessThanOrEqual(30);
362+
});
363+
331364
it("returns results sorted alphabetically by fullName", async () => {
332365
const octokit = makeOctokit(
333366
async () => ({}),

tests/services/api.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,42 @@ describe("fetchRepos", () => {
168168
"No GitHub client available"
169169
);
170170
});
171+
172+
it("stops pagination at 1000 repos and emits warning notification", async () => {
173+
vi.mocked(pushNotification).mockClear();
174+
175+
async function* mockPaginator() {
176+
for (let page = 0; page < 20; page++) {
177+
yield {
178+
data: Array.from({ length: 100 }, (_, i) => ({
179+
owner: { login: "org" },
180+
name: `repo-${page * 100 + i}`,
181+
full_name: `org/repo-${page * 100 + i}`,
182+
pushed_at: "2026-01-01T00:00:00Z",
183+
})),
184+
};
185+
}
186+
}
187+
188+
const octokit = makeBasicOctokit();
189+
octokit.paginate.iterator.mockImplementation((() => mockPaginator()) as never);
190+
191+
const result = await fetchRepos(octokit as never, "org", "org");
192+
193+
expect(result).toHaveLength(1000);
194+
expect(pushNotification).toHaveBeenCalledWith(
195+
"api",
196+
expect.stringContaining("1000+"),
197+
"warning"
198+
);
199+
});
200+
201+
// VALID_REPO_NAME is not exported. It is exercised indirectly via buildRepoQualifiers
202+
// (called inside fetchIssues/fetchPullRequests). Invalid repo names are silently
203+
// filtered from the GraphQL query qualifiers. Direct boundary tests for the regex
204+
// itself would require exporting the constant; the existing fetchIssues tests
205+
// already cover the valid-name path and the VALID_TRACKED_LOGIN boundary tests
206+
// cover the analogous login regex. No additional test added here.
171207
});
172208

173209
// ── fetchIssues (GraphQL search) ─────────────────────────────────────────────

0 commit comments

Comments
 (0)