From 1ed72e752997986344c722a12e63b3c90fde37e6 Mon Sep 17 00:00:00 2001 From: Harshita Nagpal Date: Sat, 23 May 2026 15:09:34 +0530 Subject: [PATCH 1/2] fix(security): validate and sanitize github usernames in API routes --- src/app/api/badge/commits/route.ts | 16 +++--- src/app/api/badge/streak-shield/route.ts | 18 +++--- src/app/api/metrics/compare/route.ts | 67 +++++++++++++--------- src/app/api/metrics/contributions/route.ts | 20 ++++++- src/lib/validate-github-username.ts | 20 +++++++ test/validate-github-username.test.js | 58 +++++++++++++++++++ 6 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 src/lib/validate-github-username.ts create mode 100644 test/validate-github-username.test.js diff --git a/src/app/api/badge/commits/route.ts b/src/app/api/badge/commits/route.ts index 61feda02..de7b7763 100644 --- a/src/app/api/badge/commits/route.ts +++ b/src/app/api/badge/commits/route.ts @@ -4,11 +4,11 @@ import { checkBadgeRateLimit, getBadgeClientIp, } from "@/lib/badge-rate-limit"; +import { normalizeGitHubUsername } from "@/lib/validate-github-username"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; -const GITHUB_USERNAME_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; async function fetchGitHubWithToken( url: string, @@ -33,14 +33,16 @@ async function fetchCommitsThisMonth( since.setDate(1); const sinceStr = since.toISOString().slice(0, 10); - const url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=1`; - const searchRes = await fetchGitHubWithToken(url, token); + const url = new URL(`${GITHUB_API}/search/commits`); + url.searchParams.set("q", `author:${username} author-date:>=${sinceStr}`); + url.searchParams.set("per_page", "1"); + const searchRes = await fetchGitHubWithToken(url.toString(), token); if (!searchRes.ok) { const errorBody = await searchRes.text(); console.error(`GitHub API error fetching commits for ${username}:`, { status: searchRes.status, - url, + url: url.toString(), body: errorBody, }); return 0; @@ -72,11 +74,11 @@ export async function GET(req: NextRequest) { } try { - const username = req.nextUrl.searchParams.get("user"); + const username = normalizeGitHubUsername(req.nextUrl.searchParams.get("user")); - if (!username || !GITHUB_USERNAME_RE.test(username)) { + if (!username) { return NextResponse.json( - { error: "Invalid username" }, + { error: "Invalid GitHub username" }, { status: 400 } ); } diff --git a/src/app/api/badge/streak-shield/route.ts b/src/app/api/badge/streak-shield/route.ts index cd4e9fdc..69f693d1 100644 --- a/src/app/api/badge/streak-shield/route.ts +++ b/src/app/api/badge/streak-shield/route.ts @@ -5,11 +5,11 @@ import { getBadgeClientIp, } from "@/lib/badge-rate-limit"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { normalizeGitHubUsername } from "@/lib/validate-github-username"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; -const GITHUB_USERNAME_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; interface StreakData { current: number; @@ -42,16 +42,20 @@ async function fetchStreak( since.setDate(since.getDate() - 90); const sinceStr = since.toISOString().slice(0, 10); - const url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`; + const url = new URL(`${GITHUB_API}/search/commits`); + url.searchParams.set("q", `author:${username} author-date:>=${sinceStr}`); + url.searchParams.set("per_page", "100"); + url.searchParams.set("sort", "author-date"); + url.searchParams.set("order", "desc"); - const searchRes = await fetchGitHubWithToken(url, token); + const searchRes = await fetchGitHubWithToken(url.toString(), token); if (!searchRes.ok) { const errorBody = await searchRes.text(); const isRateLimited = searchRes.status === 403; console.error(`GitHub API error fetching streak for ${username}:`, { status: searchRes.status, - url, + url: url.toString(), body: errorBody, rateLimited: isRateLimited, }); @@ -139,11 +143,11 @@ export async function GET(req: NextRequest) { } try { - const username = req.nextUrl.searchParams.get("user"); + const username = normalizeGitHubUsername(req.nextUrl.searchParams.get("user")); - if (!username || !GITHUB_USERNAME_RE.test(username)) { + if (!username) { return NextResponse.json( - { error: "Invalid username" }, + { error: "Invalid GitHub username" }, { status: 400 } ); } diff --git a/src/app/api/metrics/compare/route.ts b/src/app/api/metrics/compare/route.ts index b802a353..58782bca 100644 --- a/src/app/api/metrics/compare/route.ts +++ b/src/app/api/metrics/compare/route.ts @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { normalizeGitHubUsername } from "@/lib/validate-github-username"; export const dynamic = "force-dynamic"; @@ -13,26 +14,29 @@ export async function GET(req: NextRequest) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - let username = req.nextUrl.searchParams.get("username"); - if (!username) { + const usernameParam = req.nextUrl.searchParams.get("username"); + if (!usernameParam) { return Response.json({ error: "Username required" }, { status: 400 }); } - username = username.trim(); + let username = usernameParam.trim(); if (username.length === 0) { return Response.json({ error: "Username required" }, { status: 400 }); } - if (username.length > 39 || !/^[a-zA-Z0-9-_]+$/.test(username)) { - return Response.json({ error: "Invalid username format" }, { status: 400 }); - } - if (username === "me") { username = session.githubLogin as string; } + const normalizedUsername = normalizeGitHubUsername(username); + if (!normalizedUsername) { + return Response.json({ error: "Invalid GitHub username" }, { status: 400 }); + } + + const encodedUsername = encodeURIComponent(normalizedUsername); + // 1. Verify user exists - const userRes = await fetch(`${GITHUB_API}/users/${username}`, { + const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, { headers: { Authorization: `Bearer ${session.accessToken}` }, next: { revalidate: 3600 }, }); @@ -51,16 +55,22 @@ export async function GET(req: NextRequest) { since30.setDate(since30.getDate() - 30); const since30Str = since30.toISOString().slice(0, 10); - const commitsRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${since90Str}&per_page=100&sort=author-date&order=desc`, - { - headers: { - Authorization: `Bearer ${session.accessToken}`, - Accept: "application/vnd.github+json", - }, - next: { revalidate: 3600 }, - } + const commitsUrl = new URL(`${GITHUB_API}/search/commits`); + commitsUrl.searchParams.set( + "q", + `author:${normalizedUsername} author-date:>=${since90Str}` ); + commitsUrl.searchParams.set("per_page", "100"); + commitsUrl.searchParams.set("sort", "author-date"); + commitsUrl.searchParams.set("order", "desc"); + + const commitsRes = await fetch(commitsUrl.toString(), { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: "application/vnd.github+json", + }, + next: { revalidate: 3600 }, + }); let streak = 0; let commits30d = 0; @@ -103,7 +113,11 @@ export async function GET(req: NextRequest) { } // 3. Top Language from repos - const reposRes = await fetch(`${GITHUB_API}/users/${username}/repos?per_page=100&sort=pushed`, { + const reposUrl = new URL(`${GITHUB_API}/users/${encodedUsername}/repos`); + reposUrl.searchParams.set("per_page", "100"); + reposUrl.searchParams.set("sort", "pushed"); + + const reposRes = await fetch(reposUrl.toString(), { headers: { Authorization: `Bearer ${session.accessToken}` }, next: { revalidate: 3600 }, }); @@ -121,13 +135,14 @@ export async function GET(req: NextRequest) { } // 4. PRs - const prsRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:${username}&per_page=1`, - { - headers: { Authorization: `Bearer ${session.accessToken}` }, - next: { revalidate: 3600 }, - } - ); + const prsUrl = new URL(`${GITHUB_API}/search/issues`); + prsUrl.searchParams.set("q", `type:pr author:${normalizedUsername}`); + prsUrl.searchParams.set("per_page", "1"); + + const prsRes = await fetch(prsUrl.toString(), { + headers: { Authorization: `Bearer ${session.accessToken}` }, + next: { revalidate: 3600 }, + }); let prs = 0; if (prsRes.ok) { const prsData = await prsRes.json(); @@ -135,7 +150,7 @@ export async function GET(req: NextRequest) { } return Response.json({ - username, + username: normalizedUsername, streak, commits30d, topLanguage, diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 7b451d5c..923b772f 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -15,6 +15,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { normalizeGitHubUsername } from "@/lib/validate-github-username"; export const dynamic = "force-dynamic"; @@ -94,8 +95,18 @@ async function fetchContributionsForAccount( // Authenticated GitHub Search rate limits are low (~30 req/min). We handle 429/403 // responses gracefully by returning partial results rather than failing the endpoint. while (page <= 10) { + const searchUrl = new URL(`${GITHUB_API}/search/commits`); + searchUrl.searchParams.set( + "q", + `author:${githubLogin} author-date:>=${sinceStr}` + ); + searchUrl.searchParams.set("per_page", "100"); + searchUrl.searchParams.set("page", String(page)); + searchUrl.searchParams.set("sort", "author-date"); + searchUrl.searchParams.set("order", "desc"); + const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, + searchUrl.toString(), { headers: { Authorization: `Bearer ${token}`, @@ -298,11 +309,16 @@ export async function GET(req: NextRequest) { } const accountId = req.nextUrl.searchParams.get("accountId"); - const username = req.nextUrl.searchParams.get("username")?.trim(); + const usernameParam = req.nextUrl.searchParams.get("username"); + const username = usernameParam ? normalizeGitHubUsername(usernameParam) : null; const bypass = isMetricsCacheBypassed(req); const gitlabToken = typeof session.gitlabToken === "string" ? session.gitlabToken : undefined; + if (usernameParam && !username) { + return Response.json({ error: "Invalid GitHub username" }, { status: 400 }); + } + // Compare mode path: explicitly fetch contributions for a target username. if (username) { try { diff --git a/src/lib/validate-github-username.ts b/src/lib/validate-github-username.ts new file mode 100644 index 00000000..495da286 --- /dev/null +++ b/src/lib/validate-github-username.ts @@ -0,0 +1,20 @@ +const GITHUB_USERNAME_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; + +export function isValidGitHubUsername(username: string): boolean { + return GITHUB_USERNAME_RE.test(username); +} + +export function normalizeGitHubUsername( + value: string | null | undefined +): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + return isValidGitHubUsername(trimmed) ? trimmed : null; +} diff --git a/test/validate-github-username.test.js b/test/validate-github-username.test.js new file mode 100644 index 00000000..b6a3582f --- /dev/null +++ b/test/validate-github-username.test.js @@ -0,0 +1,58 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const ts = require("typescript"); + +function loadValidatorModule() { + const sourcePath = path.join( + __dirname, + "..", + "src", + "lib", + "validate-github-username.ts" + ); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devtrack-github-user-")); + const outPath = path.join(outDir, "validate-github-username.cjs"); + const source = fs.readFileSync(sourcePath, "utf8"); + const output = ts.transpileModule(source, { + compilerOptions: { + esModuleInterop: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }).outputText; + + fs.writeFileSync(outPath, output); + return require(outPath); +} + +test("isValidGitHubUsername accepts valid GitHub usernames", () => { + const { isValidGitHubUsername } = loadValidatorModule(); + + assert.equal(isValidGitHubUsername("octocat"), true); + assert.equal(isValidGitHubUsername("dev-track"), true); + assert.equal(isValidGitHubUsername("A1b2C3"), true); + assert.equal(isValidGitHubUsername("a".repeat(39)), true); +}); + +test("isValidGitHubUsername rejects path and query injection attempts", () => { + const { isValidGitHubUsername } = loadValidatorModule(); + + assert.equal(isValidGitHubUsername("../search/repositories?q=test"), false); + assert.equal(isValidGitHubUsername("someuser+org:private-org"), false); + assert.equal(isValidGitHubUsername("-leadinghyphen"), false); + assert.equal(isValidGitHubUsername("trailinghyphen-"), false); + assert.equal(isValidGitHubUsername("a".repeat(40)), false); +}); + +test("normalizeGitHubUsername trims and validates input", () => { + const { normalizeGitHubUsername } = loadValidatorModule(); + + assert.equal(normalizeGitHubUsername(" octocat "), "octocat"); + assert.equal(normalizeGitHubUsername(""), null); + assert.equal(normalizeGitHubUsername(" "), null); + assert.equal(normalizeGitHubUsername(null), null); + assert.equal(normalizeGitHubUsername("bad/user"), null); +}); From 3d185cac57e2f7882f1e4850d3334a5e5dcbd1e2 Mon Sep 17 00:00:00 2001 From: Harshita Nagpal Date: Sat, 23 May 2026 17:33:42 +0530 Subject: [PATCH 2/2] fix(security): disable compare route fetch caching --- src/app/api/metrics/compare/route.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/metrics/compare/route.ts b/src/app/api/metrics/compare/route.ts index 58782bca..fbcd933f 100644 --- a/src/app/api/metrics/compare/route.ts +++ b/src/app/api/metrics/compare/route.ts @@ -38,7 +38,7 @@ export async function GET(req: NextRequest) { // 1. Verify user exists const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, { headers: { Authorization: `Bearer ${session.accessToken}` }, - next: { revalidate: 3600 }, + cache: "no-store", }); if (!userRes.ok) { @@ -69,7 +69,7 @@ export async function GET(req: NextRequest) { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json", }, - next: { revalidate: 3600 }, + cache: "no-store", }); let streak = 0; @@ -119,7 +119,7 @@ export async function GET(req: NextRequest) { const reposRes = await fetch(reposUrl.toString(), { headers: { Authorization: `Bearer ${session.accessToken}` }, - next: { revalidate: 3600 }, + cache: "no-store", }); if (reposRes.ok) { @@ -141,7 +141,7 @@ export async function GET(req: NextRequest) { const prsRes = await fetch(prsUrl.toString(), { headers: { Authorization: `Bearer ${session.accessToken}` }, - next: { revalidate: 3600 }, + cache: "no-store", }); let prs = 0; if (prsRes.ok) {