Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

205 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests that plugin HTTP functions strip credential headers on cross-origin redirects.
*
* Both createHttpAccess and createUnrestrictedHttpAccess manually follow redirects.
* When a redirect crosses origins, Authorization/Cookie/Proxy-Authorization headers
* must be stripped to prevent credential leakage to untrusted hosts.
*/
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { setDefaultDnsResolver } from "../../../src/import/ssrf.js";
import { createHttpAccess, createUnrestrictedHttpAccess } from "../../../src/plugins/context.js";
// Intercept globalThis.fetch so we can simulate redirect chains
const mockFetch = vi.fn<typeof globalThis.fetch>();
vi.stubGlobal("fetch", mockFetch);
// Bypass DoH so the fetch mock only sees the calls these tests model.
// Returns a fixed public IP so resolveAndValidateExternalUrl passes.
const STUB_RESOLVER = async () => ["93.184.216.34"];
let previousResolver: ReturnType<typeof setDefaultDnsResolver> | undefined;
beforeAll(() => {
previousResolver = setDefaultDnsResolver(STUB_RESOLVER);
});
afterAll(() => {
setDefaultDnsResolver(previousResolver ?? null);
});
afterEach(() => {
mockFetch.mockReset();
});
/** Build a minimal redirect response */
function redirectResponse(location: string, status = 302): Response {
return new Response(null, {
status,
headers: { Location: location },
});
}
/** Build a 200 response */
function okResponse(body = "ok"): Response {
return new Response(body, { status: 200 });
}
/** Extract the headers passed to the Nth fetch call */
function headersOfCall(callIndex: number): Headers {
const init = mockFetch.mock.calls[callIndex]?.[1] as RequestInit | undefined;
return new Headers(init?.headers);
}
// =============================================================================
// createHttpAccess host-restricted
// =============================================================================
describe("createHttpAccess host allowlist matching", () => {
const pluginId = "test-plugin";
it('allows any hostname when allowedHosts contains standalone "*"', async () => {
mockFetch.mockResolvedValue(okResponse());
const http = createHttpAccess(pluginId, ["*"]);
await expect(http.fetch("https://api.example.com/v1")).resolves.toBeInstanceOf(Response);
await expect(http.fetch("https://random.host.io/path")).resolves.toBeInstanceOf(Response);
});
it('allows requests when "*" is mixed with explicit hosts', async () => {
mockFetch.mockResolvedValue(okResponse());
const http = createHttpAccess(pluginId, ["*", "api.example.com"]);
await expect(http.fetch("https://another.example.net/ok")).resolves.toBeInstanceOf(Response);
});
it('still supports "*.domain" wildcard matching', async () => {
mockFetch.mockResolvedValue(okResponse());
const http = createHttpAccess(pluginId, ["*.example.com"]);
await expect(http.fetch("https://api.example.com/v1")).resolves.toBeInstanceOf(Response);
await expect(http.fetch("https://evil.com")).rejects.toThrow(
'is not allowed to fetch from host "evil.com"',
);
});
});
describe("createHttpAccess credential stripping", () => {
const pluginId = "test-plugin";
const allowedHosts = ["a.example.com", "b.example.com"];
it("preserves credentials on same-origin redirect", async () => {
mockFetch
.mockResolvedValueOnce(redirectResponse("https://a.example.com/page2"))
.mockResolvedValueOnce(okResponse());
const http = createHttpAccess(pluginId, allowedHosts);
await http.fetch("https://a.example.com/page1", {
headers: { Authorization: "Bearer secret", Cookie: "session=abc" },
});
// Second call should still have credentials (same origin)
const h = headersOfCall(1);
expect(h.get("authorization")).toBe("Bearer secret");
expect(h.get("cookie")).toBe("session=abc");
});
it("strips credentials on cross-origin redirect", async () => {
mockFetch
.mockResolvedValueOnce(redirectResponse("https://b.example.com/landing"))
.mockResolvedValueOnce(okResponse());
const http = createHttpAccess(pluginId, allowedHosts);
await http.fetch("https://a.example.com/start", {
headers: {
Authorization: "Bearer secret",
Cookie: "session=abc",
"Proxy-Authorization": "Basic creds",
"X-Custom": "keep-me",
},
});
const h = headersOfCall(1);
expect(h.get("authorization")).toBeNull();
expect(h.get("cookie")).toBeNull();
expect(h.get("proxy-authorization")).toBeNull();
// Non-credential headers survive
expect(h.get("x-custom")).toBe("keep-me");
});
it("strips credentials only once even with multiple same-origin hops after cross-origin", async () => {
// a.example.com -> b.example.com -> b.example.com/final
mockFetch
.mockResolvedValueOnce(redirectResponse("https://b.example.com/step1"))
.mockResolvedValueOnce(redirectResponse("https://b.example.com/step2"))
.mockResolvedValueOnce(okResponse());
const http = createHttpAccess(pluginId, allowedHosts);
await http.fetch("https://a.example.com/start", {
headers: { Authorization: "Bearer secret" },
});
// Call 0: original (has auth)
expect(headersOfCall(0).get("authorization")).toBe("Bearer secret");
// Call 1: after cross-origin hop (stripped)
expect(headersOfCall(1).get("authorization")).toBeNull();
// Call 2: same-origin hop on b (still stripped -- not re-added)
expect(headersOfCall(2).get("authorization")).toBeNull();
});
});
// =============================================================================
// createUnrestrictedHttpAccess SSRF-protected but no host list
// =============================================================================
describe("createUnrestrictedHttpAccess credential stripping", () => {
const pluginId = "unrestricted-plugin";
it("preserves credentials on same-origin redirect", async () => {
mockFetch
.mockResolvedValueOnce(redirectResponse("https://api.example.com/v2"))
.mockResolvedValueOnce(okResponse());
const http = createUnrestrictedHttpAccess(pluginId);
await http.fetch("https://api.example.com/v1", {
headers: { Authorization: "Bearer token" },
});
expect(headersOfCall(1).get("authorization")).toBe("Bearer token");
});
it("strips credentials on cross-origin redirect", async () => {
mockFetch
.mockResolvedValueOnce(redirectResponse("https://evil.example.com/steal"))
.mockResolvedValueOnce(okResponse());
const http = createUnrestrictedHttpAccess(pluginId);
await http.fetch("https://api.example.com/start", {
headers: {
Authorization: "Bearer token",
Cookie: "session=xyz",
"Proxy-Authorization": "Basic pw",
Accept: "application/json",
},
});
const h = headersOfCall(1);
expect(h.get("authorization")).toBeNull();
expect(h.get("cookie")).toBeNull();
expect(h.get("proxy-authorization")).toBeNull();
expect(h.get("accept")).toBe("application/json");
});
it("handles redirect with no init gracefully", async () => {
mockFetch
.mockResolvedValueOnce(redirectResponse("https://other.example.com/"))
.mockResolvedValueOnce(okResponse());
const http = createUnrestrictedHttpAccess(pluginId);
// No init at all -- should not throw
await http.fetch("https://api.example.com/bare");
expect(headersOfCall(1).get("authorization")).toBeNull();
});
});