|
1 | 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; |
2 | 2 |
|
3 | 3 | const mockLookup = vi.hoisted(() => vi.fn()); |
| 4 | +const mockGetAllowedPrivateHosts = vi.hoisted(() => vi.fn(() => new Set<string>())); |
4 | 5 |
|
5 | 6 | vi.mock("node:dns/promises", () => ({ |
6 | 7 | default: { lookup: mockLookup }, |
7 | 8 | lookup: mockLookup, |
8 | 9 | })); |
9 | 10 |
|
| 11 | +vi.mock("~/lib/utils/ssrf", () => ({ |
| 12 | + getAllowedPrivateHosts: mockGetAllowedPrivateHosts, |
| 13 | +})); |
| 14 | + |
10 | 15 | import { assertSsrfSafeResolved, isSsrfSafe } from "./ssrf"; |
11 | 16 |
|
12 | 17 | describe("isSsrfSafe", () => { |
@@ -124,6 +129,33 @@ describe("isSsrfSafe", () => { |
124 | 129 | expect(isSsrfSafe("")).toBe(false); |
125 | 130 | }); |
126 | 131 | }); |
| 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 | + }); |
127 | 159 | }); |
128 | 160 |
|
129 | 161 | describe("assertSsrfSafeResolved", () => { |
@@ -198,4 +230,35 @@ describe("assertSsrfSafeResolved", () => { |
198 | 230 | ).rejects.toThrow("DNS resolution failed"); |
199 | 231 | }); |
200 | 232 | }); |
| 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 | + }); |
201 | 264 | }); |
0 commit comments