| import { describe, expect, it, vi } from "vitest"; |
| import { |
| applyAuthorizationHeaderForUrl, |
| isPrivateOrReservedIP, |
| isUrlAllowed, |
| resolveAndValidateIP, |
| resolveAttachmentFetchPolicy, |
| resolveAllowedHosts, |
| resolveAuthAllowedHosts, |
| resolveMediaSsrfPolicy, |
| safeFetch, |
| safeFetchWithPolicy, |
| } from "./shared.js"; |
|
|
| const publicResolve = async () => ({ address: "13.107.136.10" }); |
| const privateResolve = (ip: string) => async () => ({ address: ip }); |
| const failingResolve = async () => { |
| throw new Error("DNS failure"); |
| }; |
|
|
| function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") { |
| return vi.fn(async (url: string, init?: RequestInit) => { |
| const target = redirectMap[url]; |
| if (target && init?.redirect === "manual") { |
| return new Response(null, { |
| status: 302, |
| headers: { location: target }, |
| }); |
| } |
| return new Response(finalBody, { status: 200 }); |
| }); |
| } |
|
|
| async function expectSafeFetchStatus(params: { |
| fetchMock: ReturnType<typeof vi.fn>; |
| url: string; |
| allowHosts: string[]; |
| expectedStatus: number; |
| resolveFn?: typeof publicResolve; |
| }) { |
| const res = await safeFetch({ |
| url: params.url, |
| allowHosts: params.allowHosts, |
| fetchFn: params.fetchMock as unknown as typeof fetch, |
| resolveFn: params.resolveFn ?? publicResolve, |
| }); |
| expect(res.status).toBe(params.expectedStatus); |
| return res; |
| } |
|
|
| describe("msteams attachment allowlists", () => { |
| it("normalizes wildcard host lists", () => { |
| expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]); |
| expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]); |
| }); |
|
|
| it("resolves a normalized attachment fetch policy", () => { |
| expect( |
| resolveAttachmentFetchPolicy({ |
| allowHosts: ["sharepoint.com"], |
| authAllowHosts: ["graph.microsoft.com"], |
| }), |
| ).toEqual({ |
| allowHosts: ["sharepoint.com"], |
| authAllowHosts: ["graph.microsoft.com"], |
| }); |
| }); |
|
|
| it("requires https and host suffix match", () => { |
| const allowHosts = resolveAllowedHosts(["sharepoint.com"]); |
| expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true); |
| expect(isUrlAllowed("http://contoso.sharepoint.com/file.png", allowHosts)).toBe(false); |
| expect(isUrlAllowed("https://evil.example.com/file.png", allowHosts)).toBe(false); |
| }); |
|
|
| it("builds shared SSRF policy from suffix allowlist", () => { |
| expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({ |
| hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"], |
| }); |
| expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined(); |
| }); |
|
|
| it.each([ |
| ["999.999.999.999", true], |
| ["256.0.0.1", true], |
| ["10.0.0.256", true], |
| ["-1.0.0.1", false], |
| ["1.2.3.4.5", false], |
| ["0:0:0:0:0:0:0:1", true], |
| ] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => { |
| expect(isPrivateOrReservedIP(ip)).toBe(expected); |
| }); |
| }); |
|
|
| |
|
|
| describe("resolveAndValidateIP", () => { |
| it("accepts a hostname resolving to a public IP", async () => { |
| const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve); |
| expect(ip).toBe("13.107.136.10"); |
| }); |
|
|
| it("rejects a hostname resolving to 10.x.x.x", async () => { |
| await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow( |
| "private/reserved IP", |
| ); |
| }); |
|
|
| it("rejects a hostname resolving to 169.254.169.254", async () => { |
| await expect( |
| resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")), |
| ).rejects.toThrow("private/reserved IP"); |
| }); |
|
|
| it("rejects a hostname resolving to loopback", async () => { |
| await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow( |
| "private/reserved IP", |
| ); |
| }); |
|
|
| it("rejects a hostname resolving to IPv6 loopback", async () => { |
| await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow( |
| "private/reserved IP", |
| ); |
| }); |
|
|
| it("throws on DNS resolution failure", async () => { |
| await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow( |
| "DNS resolution failed", |
| ); |
| }); |
| }); |
|
|
| |
|
|
| describe("safeFetch", () => { |
| it("fetches a URL directly when no redirect occurs", async () => { |
| const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => { |
| return new Response("ok", { status: 200 }); |
| }); |
| await expectSafeFetchStatus({ |
| fetchMock, |
| url: "https://teams.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| expectedStatus: 200, |
| }); |
| expect(fetchMock).toHaveBeenCalledOnce(); |
| |
| expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual"); |
| }); |
|
|
| it("follows a redirect to an allowlisted host with public IP", async () => { |
| const fetchMock = mockFetchWithRedirect({ |
| "https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf", |
| }); |
| await expectSafeFetchStatus({ |
| fetchMock, |
| url: "https://teams.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| expectedStatus: 200, |
| }); |
| expect(fetchMock).toHaveBeenCalledTimes(2); |
| }); |
|
|
| it("returns the redirect response when dispatcher is provided by an outer guard", async () => { |
| const redirectedTo = "https://cdn.sharepoint.com/storage/file.pdf"; |
| const fetchMock = mockFetchWithRedirect({ |
| "https://teams.sharepoint.com/file.pdf": redirectedTo, |
| }); |
| const res = await safeFetch({ |
| url: "https://teams.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| requestInit: { dispatcher: {} } as RequestInit, |
| resolveFn: publicResolve, |
| }); |
| expect(res.status).toBe(302); |
| expect(res.headers.get("location")).toBe(redirectedTo); |
| expect(fetchMock).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("still enforces allowlist checks before returning dispatcher-mode redirects", async () => { |
| const fetchMock = mockFetchWithRedirect({ |
| "https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal", |
| }); |
| await expect( |
| safeFetch({ |
| url: "https://teams.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| requestInit: { dispatcher: {} } as RequestInit, |
| resolveFn: publicResolve, |
| }), |
| ).rejects.toThrow("blocked by allowlist"); |
| expect(fetchMock).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("blocks a redirect to a non-allowlisted host", async () => { |
| const fetchMock = mockFetchWithRedirect({ |
| "https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal", |
| }); |
| await expect( |
| safeFetch({ |
| url: "https://teams.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: publicResolve, |
| }), |
| ).rejects.toThrow("blocked by allowlist"); |
| |
| expect(fetchMock).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => { |
| let callCount = 0; |
| const rebindingResolve = async () => { |
| callCount++; |
| |
| if (callCount === 1) return { address: "13.107.136.10" }; |
| |
| return { address: "169.254.169.254" }; |
| }; |
|
|
| const fetchMock = mockFetchWithRedirect({ |
| "https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata", |
| }); |
| await expect( |
| safeFetch({ |
| url: "https://teams.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com", "trafficmanager.net"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: rebindingResolve, |
| }), |
| ).rejects.toThrow("private/reserved IP"); |
| expect(fetchMock).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("blocks when the initial URL resolves to a private IP", async () => { |
| const fetchMock = vi.fn(); |
| await expect( |
| safeFetch({ |
| url: "https://evil.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: privateResolve("10.0.0.1"), |
| }), |
| ).rejects.toThrow("Initial download URL blocked"); |
| expect(fetchMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("blocks when initial URL DNS resolution fails", async () => { |
| const fetchMock = vi.fn(); |
| await expect( |
| safeFetch({ |
| url: "https://nonexistent.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: failingResolve, |
| }), |
| ).rejects.toThrow("Initial download URL blocked"); |
| expect(fetchMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("follows multiple redirects when all are valid", async () => { |
| const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { |
| if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") { |
| return new Response(null, { |
| status: 302, |
| headers: { location: "https://b.sharepoint.com/2" }, |
| }); |
| } |
| if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") { |
| return new Response(null, { |
| status: 302, |
| headers: { location: "https://c.sharepoint.com/3" }, |
| }); |
| } |
| return new Response("final", { status: 200 }); |
| }); |
|
|
| const res = await safeFetch({ |
| url: "https://a.sharepoint.com/1", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: publicResolve, |
| }); |
| expect(res.status).toBe(200); |
| expect(fetchMock).toHaveBeenCalledTimes(3); |
| }); |
|
|
| it("throws on too many redirects", async () => { |
| let counter = 0; |
| const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { |
| if (init?.redirect === "manual") { |
| counter++; |
| return new Response(null, { |
| status: 302, |
| headers: { location: `https://loop${counter}.sharepoint.com/x` }, |
| }); |
| } |
| return new Response("ok", { status: 200 }); |
| }); |
|
|
| await expect( |
| safeFetch({ |
| url: "https://start.sharepoint.com/x", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: publicResolve, |
| }), |
| ).rejects.toThrow("Too many redirects"); |
| }); |
|
|
| it("blocks redirect to HTTP (non-HTTPS)", async () => { |
| const fetchMock = mockFetchWithRedirect({ |
| "https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file", |
| }); |
| await expect( |
| safeFetch({ |
| url: "https://teams.sharepoint.com/file", |
| allowHosts: ["sharepoint.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: publicResolve, |
| }), |
| ).rejects.toThrow("blocked by allowlist"); |
| }); |
|
|
| it("strips authorization across redirects outside auth allowlist", async () => { |
| const seenAuth: string[] = []; |
| const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { |
| const auth = new Headers(init?.headers).get("authorization") ?? ""; |
| seenAuth.push(`${url}|${auth}`); |
| if (url === "https://teams.sharepoint.com/file.pdf") { |
| return new Response(null, { |
| status: 302, |
| headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" }, |
| }); |
| } |
| return new Response("ok", { status: 200 }); |
| }); |
|
|
| const headers = new Headers({ Authorization: "Bearer secret" }); |
| const res = await safeFetch({ |
| url: "https://teams.sharepoint.com/file.pdf", |
| allowHosts: ["sharepoint.com"], |
| authorizationAllowHosts: ["graph.microsoft.com"], |
| fetchFn: fetchMock as unknown as typeof fetch, |
| requestInit: { headers }, |
| resolveFn: publicResolve, |
| }); |
| expect(res.status).toBe(200); |
| expect(seenAuth[0]).toContain("Bearer secret"); |
| expect(seenAuth[1]).toMatch(/\|$/); |
| }); |
| }); |
|
|
| describe("attachment fetch auth helpers", () => { |
| it("sets and clears authorization header by auth allowlist", () => { |
| const headers = new Headers(); |
| applyAuthorizationHeaderForUrl({ |
| headers, |
| url: "https://graph.microsoft.com/v1.0/me", |
| authAllowHosts: ["graph.microsoft.com"], |
| bearerToken: "token-1", |
| }); |
| expect(headers.get("authorization")).toBe("Bearer token-1"); |
|
|
| applyAuthorizationHeaderForUrl({ |
| headers, |
| url: "https://evil.example.com/collect", |
| authAllowHosts: ["graph.microsoft.com"], |
| bearerToken: "token-1", |
| }); |
| expect(headers.get("authorization")).toBeNull(); |
| }); |
|
|
| it("safeFetchWithPolicy forwards policy allowlists", async () => { |
| const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => { |
| return new Response("ok", { status: 200 }); |
| }); |
| const res = await safeFetchWithPolicy({ |
| url: "https://teams.sharepoint.com/file.pdf", |
| policy: resolveAttachmentFetchPolicy({ |
| allowHosts: ["sharepoint.com"], |
| authAllowHosts: ["graph.microsoft.com"], |
| }), |
| fetchFn: fetchMock as unknown as typeof fetch, |
| resolveFn: publicResolve, |
| }); |
| expect(res.status).toBe(200); |
| expect(fetchMock).toHaveBeenCalledOnce(); |
| }); |
| }); |
|
|