Skip to content

Commit 167d113

Browse files
authored
fix(ssrf): respect ALLOWED_PRIVATE_HOSTS in isSsrfSafe and assertSsrfSafeResolved (#187)
Both functions in utils/ssrf.ts blocked private/internal hosts without checking the operator allowlist, causing self-hosted Gitea (and similar) connections to fail even with ALLOWED_PRIVATE_HOSTS configured.
1 parent 0520994 commit 167d113

2 files changed

Lines changed: 87 additions & 7 deletions

File tree

testplanit/utils/ssrf.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const mockLookup = vi.hoisted(() => vi.fn());
4+
const mockGetAllowedPrivateHosts = vi.hoisted(() => vi.fn(() => new Set<string>()));
45

56
vi.mock("node:dns/promises", () => ({
67
default: { lookup: mockLookup },
78
lookup: mockLookup,
89
}));
910

11+
vi.mock("~/lib/utils/ssrf", () => ({
12+
getAllowedPrivateHosts: mockGetAllowedPrivateHosts,
13+
}));
14+
1015
import { assertSsrfSafeResolved, isSsrfSafe } from "./ssrf";
1116

1217
describe("isSsrfSafe", () => {
@@ -124,6 +129,33 @@ describe("isSsrfSafe", () => {
124129
expect(isSsrfSafe("")).toBe(false);
125130
});
126131
});
132+
133+
describe("respects ALLOWED_PRIVATE_HOSTS", () => {
134+
it("allows private IP when hostname is in allowlist", () => {
135+
mockGetAllowedPrivateHosts.mockReturnValueOnce(new Set(["192.168.1.100"]));
136+
expect(isSsrfSafe("http://192.168.1.100:3000/api")).toBe(true);
137+
});
138+
139+
it("allows localhost when in allowlist", () => {
140+
mockGetAllowedPrivateHosts.mockReturnValueOnce(new Set(["localhost"]));
141+
expect(isSsrfSafe("http://localhost:3000/api")).toBe(true);
142+
});
143+
144+
it("still blocks private IP not in allowlist", () => {
145+
mockGetAllowedPrivateHosts.mockReturnValueOnce(new Set(["other.host"]));
146+
expect(isSsrfSafe("http://192.168.1.100:3000/api")).toBe(false);
147+
});
148+
149+
it("accepts explicit allowedHosts parameter over env", () => {
150+
const explicit = new Set(["10.0.0.5"]);
151+
expect(isSsrfSafe("https://10.0.0.5/api", explicit)).toBe(true);
152+
});
153+
154+
it("still blocks non-HTTP protocols even if host is allowed", () => {
155+
mockGetAllowedPrivateHosts.mockReturnValueOnce(new Set(["localhost"]));
156+
expect(isSsrfSafe("ftp://localhost/file")).toBe(false);
157+
});
158+
});
127159
});
128160

129161
describe("assertSsrfSafeResolved", () => {
@@ -198,4 +230,35 @@ describe("assertSsrfSafeResolved", () => {
198230
).rejects.toThrow("DNS resolution failed");
199231
});
200232
});
233+
234+
describe("respects ALLOWED_PRIVATE_HOSTS", () => {
235+
it("skips DNS check when hostname is in allowlist", async () => {
236+
mockGetAllowedPrivateHosts.mockReturnValueOnce(new Set(["gitea.local"]));
237+
238+
await expect(
239+
assertSsrfSafeResolved("http://gitea.local:3000/api")
240+
).resolves.not.toThrow();
241+
242+
expect(mockLookup).not.toHaveBeenCalled();
243+
});
244+
245+
it("still blocks hostname not in allowlist that resolves to private IP", async () => {
246+
mockGetAllowedPrivateHosts.mockReturnValueOnce(new Set(["other.host"]));
247+
mockLookup.mockResolvedValueOnce({ address: "192.168.1.100", family: 4 });
248+
249+
await expect(
250+
assertSsrfSafeResolved("https://gitea.local/api")
251+
).rejects.toThrow("hostname resolves to a private or internal address");
252+
});
253+
254+
it("accepts explicit allowedHosts parameter", async () => {
255+
const explicit = new Set(["internal.gitea.corp"]);
256+
257+
await expect(
258+
assertSsrfSafeResolved("https://internal.gitea.corp/api", explicit)
259+
).resolves.not.toThrow();
260+
261+
expect(mockLookup).not.toHaveBeenCalled();
262+
});
263+
});
201264
});

testplanit/utils/ssrf.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { lookup } from "node:dns/promises";
2+
import { getAllowedPrivateHosts } from "~/lib/utils/ssrf";
23

34
// Private IP ranges that must be blocked to prevent SSRF attacks
45
const PRIVATE_RANGES: RegExp[] = [
@@ -32,23 +33,30 @@ function isPrivateIp(ip: string): boolean {
3233
* Use this before making any HTTP request to a user-supplied URL
3334
* (e.g., GitLab self-hosted baseUrl, Azure DevOps organizationUrl).
3435
*/
35-
export function isSsrfSafe(url: string): boolean {
36+
export function isSsrfSafe(
37+
url: string,
38+
allowedHosts?: Set<string>
39+
): boolean {
3640
try {
3741
const parsed = new URL(url);
3842
// Strip brackets from IPv6 addresses (URL.hostname returns "[::1]" for IPv6)
3943
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
4044

45+
// Only allow http/https
46+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
47+
return false;
48+
}
49+
50+
// If this hostname is in the operator allowlist, skip private-IP checks
51+
const allowed = allowedHosts ?? getAllowedPrivateHosts();
52+
if (allowed.has(hostname.toLowerCase())) return true;
53+
4154
// Block localhost by name
4255
if (hostname === "localhost") return false;
4356

4457
// Block if hostname is a private/loopback IP
4558
if (isPrivateIp(hostname)) return false;
4659

47-
// Only allow http/https
48-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
49-
return false;
50-
}
51-
5260
return true;
5361
} catch {
5462
// Invalid URL
@@ -64,10 +72,19 @@ export function isSsrfSafe(url: string): boolean {
6472
* Call this immediately before fetch() to minimize the TOCTOU window.
6573
* Throws if the resolved address is private or the hostname cannot be resolved.
6674
*/
67-
export async function assertSsrfSafeResolved(url: string): Promise<void> {
75+
export async function assertSsrfSafeResolved(
76+
url: string,
77+
allowedHosts?: Set<string>
78+
): Promise<void> {
6879
const parsed = new URL(url);
6980
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
7081

82+
// If this hostname is in the operator allowlist, skip all private-IP checks
83+
const allowed = allowedHosts ?? getAllowedPrivateHosts();
84+
if (allowed.has(hostname.toLowerCase())) {
85+
return;
86+
}
87+
7188
// Skip DNS lookup for raw IP addresses — already checked by isSsrfSafe()
7289
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":")) {
7390
return;

0 commit comments

Comments
 (0)