Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/server/src/services/provider-http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,44 @@ describe("ProviderHttpClient", () => {
);
});

it.each(["https://other.example.com/events", "//other.example.com/events"])(
"rejects cross-origin endpoint %s before sending credentials",
async (endpoint) => {
const fetchImpl = vi.fn();
const client = new ProviderHttpClient({
auth: { token: "secret-token", type: "bearer" },
baseUrl: "https://api.example.com/v1",
fetchImpl: fetchImpl as unknown as typeof fetch,
providerName: "Example",
});

await expect(client.get(endpoint)).rejects.toThrow(
"Provider endpoint must use the configured origin"
);
expect(fetchImpl).not.toHaveBeenCalled();
}
);

it("allows absolute endpoints on the configured origin", async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response("{}"));
const client = new ProviderHttpClient({
auth: { token: "secret-token", type: "bearer" },
baseUrl: "https://api.example.com/v1",
fetchImpl: fetchImpl as unknown as typeof fetch,
providerName: "Example",
});

await client.get("https://api.example.com/v1/events");
expect(fetchImpl).toHaveBeenCalledWith(
new URL("https://api.example.com/v1/events"),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer secret-token",
}),
})
);
});

it("preserves caller header casing in fetch init", async () => {
const fetchImpl = vi
.fn()
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/services/provider-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ export class ProviderHttpClient {
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Provider endpoint must use http or https");
}
// Never send the provider token to an endpoint outside the configured API.
// This also rejects protocol-relative paths such as //other.example/path.
if (url.origin !== new URL(this.#baseUrl).origin) {
throw new Error("Provider endpoint must use the configured origin");
}

for (const key of url.searchParams.keys()) {
if (this.#blockedParams.has(key.toLowerCase())) {
Expand Down