diff --git a/src/app/api/badge/commits/route.ts b/src/app/api/badge/commits/route.ts index 8e57cccc..425b5636 100644 --- a/src/app/api/badge/commits/route.ts +++ b/src/app/api/badge/commits/route.ts @@ -5,11 +5,11 @@ import { getBadgeClientIp, } from "@/lib/badge-rate-limit"; import { logError } from "@/lib/error-handler"; +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, @@ -34,14 +34,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; @@ -73,11 +75,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 b7f7d918..aa6a62a5 100644 --- a/src/app/api/badge/streak-shield/route.ts +++ b/src/app/api/badge/streak-shield/route.ts @@ -6,11 +6,11 @@ import { } from "@/lib/badge-rate-limit"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; import { logError } from "@/lib/error-handler"; +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; @@ -43,16 +43,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, }); @@ -140,11 +144,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 01173219..fbcd933f 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}` }, cache: "no-store", }); @@ -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", - }, - cache: "no-store", - } + 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", + }, + cache: "no-store", + }); 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}` }, cache: "no-store", }); @@ -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}` }, - cache: "no-store", - } - ); + 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}` }, + cache: "no-store", + }); 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); +});