Skip to content

Commit d77c6f5

Browse files
fix: sanitize GitHub username inputs
1 parent 6229134 commit d77c6f5

9 files changed

Lines changed: 106 additions & 8 deletions

File tree

‎src/app/api/compare/route.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,13 @@ import {
66
resolveLocale,
77
} from "@/features/comparison/services";
88
import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api";
9+
import { cleanGitHubUsername } from "@/utils/github";
910

1011
export const runtime = "nodejs";
1112

1213
export async function GET(request: Request) {
1314
const { searchParams } = new URL(request.url);
14-
const usernames = searchParams
15-
.getAll("username")
16-
.map((username) => username.trim())
17-
.filter(Boolean);
15+
const usernames = searchParams.getAll("username").map(cleanGitHubUsername).filter(Boolean);
1816

1917
if (usernames.length !== 2) {
2018
return NextResponse.json(

‎src/app/api/user/[username]/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { NextResponse } from "next/server";
22
import { getUserProfile } from "@/features/developer/services";
33
import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api";
4+
import { cleanGitHubUsername } from "@/utils/github";
45

56
export const runtime = "nodejs";
67

78
export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) {
89
const { username } = await params;
9-
const trimmed = username?.trim();
10+
const trimmed = cleanGitHubUsername(username ?? "");
1011

1112
if (!trimmed) {
1213
return NextResponse.json(

‎src/features/comparison/components/compare-form.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
99
import { useTranslation } from "@/components/providers/language-provider";
1010
import { handleEscapeToClear, matchesHotkey, useHotkeys, useIsApplePlatform } from "@/hooks";
1111
import { cn } from "@/utils/cn";
12+
import { cleanGitHubUsername } from "@/utils/github";
1213

1314
const LANGUAGE_OPTIONS = [
1415
"TypeScript",
@@ -115,7 +116,7 @@ export function CompareForm({
115116

116117
const submitComparison = () => {
117118
if (!canSubmit) return;
118-
onSubmit(username1.trim(), username2.trim(), {
119+
onSubmit(cleanGitHubUsername(username1), cleanGitHubUsername(username2), {
119120
selectedLanguages,
120121
});
121122
};

‎src/features/comparison/tests/compare-form.test.tsx‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,19 @@ vi.mock("@/components/providers/language-provider", () => ({
1010
useTranslation: () => ({ t: (key: string) => key }),
1111
}));
1212

13-
function ControlledForm({ initial1 = "", initial2 = "" }) {
13+
function ControlledForm({
14+
initial1 = "",
15+
initial2 = "",
16+
onSubmit = () => {},
17+
}: {
18+
initial1?: string;
19+
initial2?: string;
20+
onSubmit?: (
21+
username1: string,
22+
username2: string,
23+
options: { selectedLanguages: string[] },
24+
) => void;
25+
}) {
1426
const [username1, setUsername1] = useState(initial1);
1527
const [username2, setUsername2] = useState(initial2);
1628
const [selectedLanguages, setSelectedLanguages] = useState<string[]>([]);
@@ -25,7 +37,7 @@ function ControlledForm({ initial1 = "", initial2 = "" }) {
2537
setUsername1={setUsername1}
2638
setUsername2={setUsername2}
2739
setSelectedLanguages={setSelectedLanguages}
28-
onSubmit={() => {}}
40+
onSubmit={onSubmit}
2941
/>
3042
</>
3143
);
@@ -89,4 +101,20 @@ describe("CompareForm / shortcut", () => {
89101
"/",
90102
);
91103
});
104+
105+
test("submits cleaned GitHub usernames", async () => {
106+
const user = userEvent.setup();
107+
const onSubmit = vi.fn();
108+
render(
109+
<ControlledForm
110+
initial1=" @octocat "
111+
initial2="https://github.com/torvalds/"
112+
onSubmit={onSubmit}
113+
/>,
114+
);
115+
116+
await user.click(screen.getByRole("button", { name: "form.compare" }));
117+
118+
expect(onSubmit).toHaveBeenCalledWith("octocat", "torvalds", { selectedLanguages: [] });
119+
});
92120
});

‎src/features/comparison/tests/compare.route.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,24 @@ describe("GET /api/compare", () => {
216216
expect(body.winner?.username).toBe("user-a");
217217
});
218218

219+
test("cleans GitHub handles and profile URLs before loading users", async () => {
220+
mocks.getUserData.mockResolvedValue({
221+
data: makeUser("octocat", "The Octocat"),
222+
metrics: { duration: 0, errors: [] },
223+
});
224+
mocks.calculateUserScore.mockReturnValue(makeScore(10));
225+
226+
const response = await GET(
227+
makeRequest({
228+
username: [" @octocat ", "https://github.com/torvalds/"],
229+
}),
230+
);
231+
232+
expect(response.status).toBe(200);
233+
expect(mocks.getUserData).toHaveBeenNthCalledWith(1, "octocat", expect.any(Object));
234+
expect(mocks.getUserData).toHaveBeenNthCalledWith(2, "torvalds", expect.any(Object));
235+
});
236+
219237
test("returns null percentage when the losing final score is zero", async () => {
220238
mocks.getUserData.mockResolvedValueOnce({
221239
data: makeUser("user-a", "User A"),

‎src/features/developer/tests/user.route.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,23 @@ describe("GET /api/user/[username]", () => {
150150
expect(body.location).toBe("Stockholm, Sweden");
151151
});
152152

153+
test("cleans a GitHub profile URL before loading the user", async () => {
154+
const rawUser = makeUser("octocat", "The Octocat");
155+
mocks.getUserData.mockResolvedValueOnce({
156+
data: rawUser,
157+
metrics: { duration: 100, errors: [] },
158+
});
159+
mocks.calculateUserScore.mockReturnValueOnce(makeScore(40));
160+
161+
const request = new Request("http://localhost/api/user/https%3A%2F%2Fgithub.com%2Foctocat%2F");
162+
const response = await GET(request, {
163+
params: Promise.resolve({ username: "https://github.com/octocat/" }),
164+
});
165+
166+
expect(response.status).toBe(200);
167+
expect(mocks.getUserData).toHaveBeenCalledWith("octocat", expect.any(Object));
168+
});
169+
153170
test("returns 404 when user is not found on GitHub", async () => {
154171
mocks.getUserData.mockRejectedValueOnce(
155172
new GitHubApiError({

‎src/utils/github.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, test } from "vitest";
2+
import { cleanGitHubUsername } from "./github";
3+
4+
describe("cleanGitHubUsername", () => {
5+
test.each([
6+
[" @octocat ", "octocat"],
7+
["https://github.com/octocat/", "octocat"],
8+
["github.com/torvalds", "torvalds"],
9+
["octocat/", "octocat"],
10+
["https://example.com/octocat", "https://example.com/octocat"],
11+
])("cleans %j", (input, expected) => {
12+
expect(cleanGitHubUsername(input)).toBe(expected);
13+
});
14+
});

‎src/utils/github.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export function cleanGitHubUsername(input: string): string {
2+
const trimmed = input.trim().replace(/^@+/, "").replace(/\/+$/, "");
3+
if (!trimmed) return "";
4+
5+
const url = tryParseGitHubUrl(trimmed);
6+
if (!url) return trimmed;
7+
8+
return url.pathname.split("/").filter(Boolean)[0] ?? "";
9+
}
10+
11+
function tryParseGitHubUrl(input: string): URL | null {
12+
const urlInput = input.startsWith("github.com/") ? `https://${input}` : input;
13+
14+
try {
15+
const url = new URL(urlInput);
16+
return url.hostname === "github.com" || url.hostname === "www.github.com" ? url : null;
17+
} catch {
18+
return null;
19+
}
20+
}

‎src/utils/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./cn";
2+
export * from "./github";

0 commit comments

Comments
 (0)