From ebf93f9f22baa637ff6c7f6dc3fb907e1a0c7412 Mon Sep 17 00:00:00 2001 From: Vexx Date: Wed, 20 May 2026 23:42:31 +0530 Subject: [PATCH 1/3] fix: add strict input validation to POST /api/goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap req.json() in try/catch — returns 400 on malformed JSON - Validate body is a non-null object before destructuring - title: must be non-empty string, trimmed, max 100 characters - target: must be integer in [1, 10000] — blocks 0, negatives, floats, NaN, overflow - unit: clamped to 30 chars silently - recurrence: unknown values default to 'none' explicitly - All bounds defined as named constants Fixes division-by-zero (target:0 → Infinity% progress bar) and negative target making 0-progress goals show as 100% complete. Closes #454 --- .claude/settings.local.json | 21 -------------- src/app/api/goals/route.ts | 58 +++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 37 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index bf296420..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(npm --version)", - "Bash(psql --version)", - "Bash(gh pr:*)", - "Bash(gh issue:*)", - "Bash(/api/auth/callback/github)", - "Bash(gh label:*)", - "Bash(gh repo:*)", - "Bash(metricsController.ts)", - "Bash(git pull:*)", - "Bash(git stash:*)", - "Bash(git push:*)", - "Bash(gh release:*)", - "Bash(git rm:*)" - ] - } -} diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 0a8eb0cd..aa198624 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -19,6 +19,12 @@ interface Goal { type Recurrence = "none" | "weekly" | "monthly"; +const VALID_RECURRENCES = ["none", "weekly", "monthly"] as const; +const MAX_TITLE_LEN = 100; +const MAX_UNIT_LEN = 30; +const MIN_TARGET = 1; +const MAX_TARGET = 10_000; + function getPeriodStart(recurrence: Recurrence): string { const now = new Date(); if (recurrence === "weekly") { @@ -106,21 +112,41 @@ export async function POST(req: Request) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const body = (await req.json()) as { - title?: string; - target?: number; - unit?: string; - recurrence?: Recurrence; - }; + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } - if (!body.title || !body.target) { - return Response.json({ error: "title and target required" }, { status: 400 }); + if (typeof body !== "object" || body === null) { + return Response.json({ error: "Invalid request body" }, { status: 400 }); } - const recurrence: Recurrence = body.recurrence ?? "none"; - if (!["none", "weekly", "monthly"].includes(recurrence)) { - return Response.json({ error: "Invalid recurrence value" }, { status: 400 }); + const { title, target, unit, recurrence } = body as Record; + + if (typeof title !== "string" || title.trim().length === 0) { + return Response.json({ error: "title must be a non-empty string" }, { status: 400 }); } + if (title.length > MAX_TITLE_LEN) { + return Response.json({ error: `title must be ${MAX_TITLE_LEN} characters or fewer` }, { status: 400 }); + } + if ( + typeof target !== "number" || + !Number.isInteger(target) || + target < MIN_TARGET || + target > MAX_TARGET + ) { + return Response.json( + { error: `target must be an integer between ${MIN_TARGET} and ${MAX_TARGET}` }, + { status: 400 } + ); + } + + const safeUnit = typeof unit === "string" ? unit.slice(0, MAX_UNIT_LEN) : "commits"; + const safeRecurrence: Recurrence = VALID_RECURRENCES.includes(recurrence as Recurrence) + ? (recurrence as Recurrence) + : "none"; const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); @@ -129,11 +155,11 @@ export async function POST(req: Request) { .from("goals") .insert({ user_id: user.id, - title: body.title, - target: body.target, - unit: body.unit ?? "commits", - recurrence, - period_start: getPeriodStart(recurrence), + title: title.trim(), + target, + unit: safeUnit, + recurrence: safeRecurrence, + period_start: getPeriodStart(safeRecurrence), current: 0, }) .select() From 423d561ae8b24bbd35d346b4a55cbc778e9b0948 Mon Sep 17 00:00:00 2001 From: Vexx Date: Wed, 20 May 2026 23:43:46 +0530 Subject: [PATCH 2/3] chore: ignore .claude/ directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 236cc8a3..d7a7de4b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,7 @@ Thumbs.db .idea/ *.swp +# Claude Code local settings +.claude/ + desktop.ini From 980611d8caf405f9bafa999b0815e2a0705b54bf Mon Sep 17 00:00:00 2001 From: Vexx Date: Thu, 21 May 2026 21:50:54 +0530 Subject: [PATCH 3/3] fix: add type guard for unknown body and wrap fetchContributions in useCallback --- src/app/api/goals/route.ts | 4 ++-- src/components/CommitTimeChart.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 5d24b2e5..aa198624 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -119,8 +119,8 @@ export async function POST(req: Request) { return Response.json({ error: "Invalid JSON" }, { status: 400 }); } - if (!body.title || typeof body.target !== "number" || body.target <= 0) { - return Response.json({ error: "title and positive target required" }, { status: 400 }); + if (typeof body !== "object" || body === null) { + return Response.json({ error: "Invalid request body" }, { status: 400 }); } const { title, target, unit, recurrence } = body as Record; diff --git a/src/components/CommitTimeChart.tsx b/src/components/CommitTimeChart.tsx index 5076ccaa..5275470f 100644 --- a/src/components/CommitTimeChart.tsx +++ b/src/components/CommitTimeChart.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { BarChart, Bar, @@ -27,7 +27,7 @@ export default function CommitTimeChart() { const [days, setDays] = useState(30); const [peakTime, setPeakTime] = useState(null); - const fetchContributions = () => { + const fetchContributions = useCallback(() => { setLoading(true); setError(null); fetch(`/api/metrics/contributions?days=${days}`) @@ -81,11 +81,11 @@ export default function CommitTimeChart() { setError("We couldn't load your time-of-day data right now."), ) .finally(() => setLoading(false)); - }; + }, [days]); useEffect(() => { fetchContributions(); - }, [days]); + }, [fetchContributions]); return (