Skip to content

Commit 526b345

Browse files
committed
Check urls in a better way
1 parent 71ad1b2 commit 526b345

File tree

4 files changed

+92
-8
lines changed

4 files changed

+92
-8
lines changed

src/app/[...parts]/_page/Sources/SourceCard/SourceCard.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import ExternalLink from "^/components/ExternalLink";
33
import Heading from "^/components/ui/Heading";
44
import { type SourceInformation } from "^/lib/api/npm/sourceInformation";
55
import { cx } from "^/lib/cva";
6+
import { isGitHubUrl, isGitLabUrl } from "^/lib/utils/isAllowedRepositoryHost";
67
import ProvenanceCard from "./ProvenanceCard";
78
import { ProvenanceInfoIcon } from "./ProvenanceInfoIcon";
89
import { TrustedPublisherCard } from "./TrustedPublisherCard";
@@ -39,13 +40,9 @@ export default function SourceCard({
3940
>
4041
<span className="text-sm">Repo:</span>
4142
<div className="flex items-center gap-1.5">
42-
{sourceInformation.repositoryUrl.startsWith(
43-
"https://github.com",
44-
) ? (
43+
{isGitHubUrl(sourceInformation.repositoryUrl) ? (
4544
<Github className="size-3.5 shrink-0" />
46-
) : sourceInformation.repositoryUrl.startsWith(
47-
"https://gitlab.com",
48-
) ? (
45+
) : isGitLabUrl(sourceInformation.repositoryUrl) ? (
4946
<Gitlab className="size-3.5 shrink-0" />
5047
) : null}
5148
<span className="text-sm font-medium">

src/app/[...parts]/_page/Sources/SourceCompareButton/SourceCompareButton.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type SourceInformation } from "^/lib/api/npm/sourceInformation";
2+
import { isGitHubUrl, isGitLabUrl } from "^/lib/utils/isAllowedRepositoryHost";
23
import { CompareButton } from "./CompareButton";
34

45
export interface SourceCompareButtonProps {
@@ -12,7 +13,7 @@ export default function SourceCompareButton({
1213
}: SourceCompareButtonProps) {
1314
if (sourceA.repositoryUrl !== sourceB.repositoryUrl) {
1415
return null;
15-
} else if (sourceA.repositoryUrl.startsWith("https://github.com")) {
16+
} else if (isGitHubUrl(sourceA.repositoryUrl)) {
1617
return (
1718
<CompareButton
1819
commitA={sourceA.commitHash}
@@ -21,7 +22,7 @@ export default function SourceCompareButton({
2122
serviceName="GitHub.com"
2223
/>
2324
);
24-
} else if (sourceA.repositoryUrl.startsWith("https://gitlab.com")) {
25+
} else if (isGitLabUrl(sourceA.repositoryUrl)) {
2526
return (
2627
<CompareButton
2728
commitA={sourceA.commitHash}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { isGitHubUrl, isGitLabUrl } from "./isAllowedRepositoryHost";
2+
3+
describe("isGitHubUrl", () => {
4+
it("returns true for valid GitHub URL", () => {
5+
expect(isGitHubUrl("https://github.com/owner/repo")).toBe(true);
6+
});
7+
8+
it("returns true for GitHub URL with path", () => {
9+
expect(isGitHubUrl("https://github.com/owner/repo/tree/main")).toBe(
10+
true,
11+
);
12+
});
13+
14+
it("returns false for GitLab URL", () => {
15+
expect(isGitHubUrl("https://gitlab.com/owner/repo")).toBe(false);
16+
});
17+
18+
it("rejects subdomain attack: github.com.evil.com", () => {
19+
expect(isGitHubUrl("https://github.com.evil.com/repo")).toBe(false);
20+
});
21+
22+
it("rejects subdomain attack: evil-github.com", () => {
23+
expect(isGitHubUrl("https://evil-github.com/repo")).toBe(false);
24+
});
25+
26+
it("rejects path injection: evil.com/github.com", () => {
27+
expect(isGitHubUrl("https://evil.com/github.com/repo")).toBe(false);
28+
});
29+
30+
it("rejects subdomain: api.github.com", () => {
31+
expect(isGitHubUrl("https://api.github.com/repo")).toBe(false);
32+
});
33+
34+
it("rejects HTTP (non-HTTPS)", () => {
35+
expect(isGitHubUrl("http://github.com/owner/repo")).toBe(false);
36+
});
37+
38+
it("rejects invalid URL", () => {
39+
expect(isGitHubUrl("not-a-url")).toBe(false);
40+
});
41+
});
42+
43+
describe("isGitLabUrl", () => {
44+
it("returns true for valid GitLab URL", () => {
45+
expect(isGitLabUrl("https://gitlab.com/owner/repo")).toBe(true);
46+
});
47+
48+
it("returns true for GitLab URL with path", () => {
49+
expect(isGitLabUrl("https://gitlab.com/owner/repo/-/tree/main")).toBe(
50+
true,
51+
);
52+
});
53+
54+
it("returns false for GitHub URL", () => {
55+
expect(isGitLabUrl("https://github.com/owner/repo")).toBe(false);
56+
});
57+
58+
it("rejects subdomain attack: gitlab.com.evil.com", () => {
59+
expect(isGitLabUrl("https://gitlab.com.evil.com/repo")).toBe(false);
60+
});
61+
62+
it("rejects HTTP (non-HTTPS)", () => {
63+
expect(isGitLabUrl("http://gitlab.com/owner/repo")).toBe(false);
64+
});
65+
66+
it("rejects invalid URL", () => {
67+
expect(isGitLabUrl("not-a-url")).toBe(false);
68+
});
69+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/** Parses URL and checks if hostname exactly matches the expected host. */
2+
function isHostUrl(url: string, expectedHost: string): boolean {
3+
try {
4+
const { protocol, hostname } = new URL(url);
5+
return protocol === "https:" && hostname.toLowerCase() === expectedHost;
6+
} catch {
7+
return false;
8+
}
9+
}
10+
11+
export function isGitHubUrl(url: string): boolean {
12+
return isHostUrl(url, "github.com");
13+
}
14+
15+
export function isGitLabUrl(url: string): boolean {
16+
return isHostUrl(url, "gitlab.com");
17+
}

0 commit comments

Comments
 (0)