Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A breaking change will get clearly marked in this log.

### Fixed
* X-App-Name and X-App-Version headers are now included when using `CallBuilder.stream()` ([#1317](https://github.com/stellar/js-stellar-sdk/pull/1317)).

* `CallBuilder` now correctly uses the configured server URL for all requests, including pagination and linked resources. Previously, URLs returned by Horizon in `_links` would bypass reverse proxies ([#1318](https://github.com/stellar/js-stellar-sdk/pull/1318)).

## [v14.4.3](https://github.com/stellar/js-stellar-sdk/compare/v14.4.2...v14.4.3)

Expand Down
10 changes: 3 additions & 7 deletions src/horizon/call_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,13 +398,9 @@ export class CallBuilder<
private async _sendNormalRequest(initialUrl: URI) {
let url = initialUrl;

if (url.authority() === "") {
url = url.authority(this.url.authority());
}

if (url.protocol() === "") {
url = url.protocol(this.url.protocol());
}
// Always use the configured server's authority and protocol.
// Horizon returns absolute URLs in _links that would bypass reverse proxies.
url = url.authority(this.url.authority()).protocol(this.url.protocol());

return this.httpClient
.get(url.toString())
Expand Down
85 changes: 85 additions & 0 deletions test/integration/client_headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,91 @@ describe("integration tests: client headers", () => {
.call();
});

it("uses configured server URL for pagination links (reverse proxy support)", async () => {
let server: http.Server;
let requestCount = 0;

const requestHandler = (
_request: http.IncomingMessage,
response: http.ServerResponse,
) => {
requestCount++;

if (requestCount === 1) {
// First request: return a response with _links pointing to a DIFFERENT host
// This simulates what Horizon does - it returns its own hostname in links
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
_embedded: {
records: [{ id: "1", paging_token: "token1" }],
},
_links: {
// These links point to a different host (horizon.stellar.org)
// The SDK should rewrite these to use localhost:${port}
next: {
href: `https://horizon.stellar.org/operations?cursor=token1`,
},
prev: {
href: `https://horizon.stellar.org/operations?cursor=token0`,
},
},
}),
);
} else if (requestCount === 2) {
// Second request (pagination): verify it came to our server, not horizon.stellar.org
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
_embedded: {
records: [{ id: "2", paging_token: "token2" }],
},
_links: {
next: {
href: `https://horizon.stellar.org/operations?cursor=token2`,
},
prev: {
href: `https://horizon.stellar.org/operations?cursor=token1`,
},
},
}),
);
server.close();
}
};

server = http.createServer(requestHandler);

await new Promise<void>((resolve, reject) => {
server.listen(port, (err?: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
});

const horizonServer = new Horizon.Server(`http://localhost:${port}`, {
allowHttp: true,
});

// First request
const firstPage = await horizonServer.operations().call();
expect(firstPage.records).toHaveLength(1);
expect(firstPage.records[0]!.id).toBe("1");

// Second request via .next() - this should go to localhost, not horizon.stellar.org
// If the fix works, requestCount will be 2. If not, this will timeout/fail
// because the request would go to horizon.stellar.org instead of our mock server
const secondPage = await firstPage.next();
expect(secondPage.records).toHaveLength(1);
expect(secondPage.records[0]!.id).toBe("2");

// Verify both requests came to our server
expect(requestCount).toBe(2);
});

it("sends appName and appVersion via headers for HTTP requests", async () => {
let server: http.Server;

Expand Down
7 changes: 4 additions & 3 deletions test/unit/server/horizon/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ describe("server.js non-transaction tests", () => {
}
if (
url.match(
/^https:\/\/horizon.stellar.org\/transactions\/c585b8764b28be678c482f8b6e87e76e4b5f28043c53f4dcb7b724b4b2efebc1\/operations/,
/^https:\/\/horizon-live\.stellar\.org:1337\/transactions\/c585b8764b28be678c482f8b6e87e76e4b5f28043c53f4dcb7b724b4b2efebc1\/operations/,
)
) {
return Promise.resolve({ data: { operations: [] } });
Expand Down Expand Up @@ -830,6 +830,7 @@ describe("server.js non-transaction tests", () => {
describe("with options", () => {
it("requests the correct endpoint", async () => {
mockGet.mockImplementation((url: string) => {
console.log("URL called:", url);
if (
url.includes(
"https://horizon-live.stellar.org:1337/ledgers/7952722/transactions?cursor=b&limit=1&order=asc",
Expand All @@ -839,7 +840,7 @@ describe("server.js non-transaction tests", () => {
}
if (
url.match(
/^https:\/\/horizon.stellar.org\/transactions\/c585b8764b28be678c482f8b6e87e76e4b5f28043c53f4dcb7b724b4b2efebc1\/operations\?limit=1/,
/^https:\/\/horizon-live\.stellar\.org:1337\/transactions\/c585b8764b28be678c482f8b6e87e76e4b5f28043c53f4dcb7b724b4b2efebc1\/operations\?limit=1/,
)
) {
return Promise.resolve({ data: { operations: [] } });
Expand Down Expand Up @@ -940,7 +941,7 @@ describe("server.js non-transaction tests", () => {
mockGet.mockImplementation((url: string) => {
if (
url.includes(
"https://horizon-live.stellar.org:1337/transactions/6bbd8cbd90498a26210a21ec599702bead8f908f412455da300318aba36831b0",
"https://horizon-live.stellar\.org:1337/transactions/6bbd8cbd90498a26210a21ec599702bead8f908f412455da300318aba36831b0",
)
) {
return Promise.resolve({ data: singleTranssactionResponse });
Expand Down
Loading