diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index 2e7bb9feb6..eb02bc4d3f 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -7,6 +7,9 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ### Added * Add new fields to `ServerDefinitionsResponse`: `ACCOUNT_SET_FLAGS`, `LEDGER_ENTRY_FLAGS`, `LEDGER_ENTRY_FORMATS`, `TRANSACTION_FLAGS`, and `TRANSACTION_FORMATS`, reflecting new sections returned by `server_definitions` in rippled. +### Fixed +* Handle malformed UTF-8 in WebSocket text messages by enabling `skipUTF8Validation` and converting `Buffer` payloads to strings + ## 4.6.0 (2026-02-12) ### Added diff --git a/packages/xrpl/src/client/connection.ts b/packages/xrpl/src/client/connection.ts index 54ad7a288f..42b8a989c6 100644 --- a/packages/xrpl/src/client/connection.ts +++ b/packages/xrpl/src/client/connection.ts @@ -64,6 +64,7 @@ function createWebSocket( ): WebSocket | null { const options: ClientOptions = { agent: config.agent, + skipUTF8Validation: true, } if (config.headers) { options.headers = config.headers @@ -333,9 +334,12 @@ export class Connection extends EventEmitter { /** * Handler for when messages are received from the server. * - * @param message - The message received from the server. + * @param rawMessage - The message received from the server. */ - private onMessage(message): void { + private onMessage(rawMessage: string | Buffer): void { + const message = + typeof rawMessage === 'string' ? rawMessage : rawMessage.toString('utf8') + this.trace('receive', message) let data: Record try { @@ -388,7 +392,7 @@ export class Connection extends EventEmitter { this.ws.removeAllListeners() clearTimeout(connectionTimeoutID) // Add new, long-term connected listeners for messages and errors - this.ws.on('message', (message: string) => this.onMessage(message)) + this.ws.on('message', (message: string | Buffer) => this.onMessage(message)) this.ws.on('error', (error) => this.emit('error', 'websocket', error.message, error), ) diff --git a/packages/xrpl/test/client/submitAndWait.test.ts b/packages/xrpl/test/client/submitAndWait.test.ts index 8138c64f2e..2c54922393 100644 --- a/packages/xrpl/test/client/submitAndWait.test.ts +++ b/packages/xrpl/test/client/submitAndWait.test.ts @@ -1,3 +1,5 @@ +import { assert } from 'chai' + import { XrplError } from '../../src' import { Transaction } from '../../src/models/transactions' import rippled from '../fixtures/rippled' @@ -41,4 +43,54 @@ describe('client.submitAndWait', function () { 'Transaction failed, temMALFORMED: Malformed transaction.', ) }) + + it('handles malformed UTF-8 text while polling tx', async function () { + const signedTx = { + ...signedTransaction, + LastLedgerSequence: 9999999, + } + let txPollCount = 0 + + testContext.mockRippled!.addResponse('submit', rippled.submit.success) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + testContext.mockRippled!.addResponse('tx', (request) => { + txPollCount += 1 + + if (txPollCount === 1) { + return { + type: 'response', + status: 'error', + error: 'txnNotFound', + request, + } + } + + // Build a response with a malformed UTF-8 byte (0xff) embedded in a JSON key + const prefix = `{"type":"response","status":"success","id":${JSON.stringify(request.id)},"result":{"validated":true,"tx_json":${JSON.stringify(signedTx)},"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"ContractJson":{"allowances":{"` + const suffix = `":"500"}}},"LedgerEntryType":"ContractData"}}]}}}` + + return { + payload: Buffer.concat([ + Buffer.from(prefix), + Buffer.from([0xff]), + Buffer.from(suffix), + ]), + binary: false, + } + }) + + const response = await testContext.client.submitAndWait(signedTx) + const meta = response.result.meta as unknown as Record + const node = (meta.AffectedNodes as Array>)[0] + const modified = node.ModifiedNode as Record + const fields = modified.FinalFields as Record + const contract = fields.ContractJson as Record + const allowances = contract.allowances as Record + const [key] = Object.keys(allowances) + + assert.strictEqual(txPollCount, 2) + assert.isTrue(response.result.validated) + assert.strictEqual(allowances[key], '500') + assert.include(key, '�') + }) }) diff --git a/packages/xrpl/test/client/subscribe.test.ts b/packages/xrpl/test/client/subscribe.test.ts index 0b989b6731..8c5a4dc411 100644 --- a/packages/xrpl/test/client/subscribe.test.ts +++ b/packages/xrpl/test/client/subscribe.test.ts @@ -118,6 +118,27 @@ describe('Client subscription', function () { }) }) + it('Emits path_find from a non-Unicode mock websocket payload', async function () { + await new Promise((resolve) => { + testContext.client.on('path_find', (path) => { + assert.strictEqual(path.type, 'path_find') + assert.strictEqual( + (path as unknown as { message: string }).message, + '�', + ) + resolve() + }) + + testContext.mockRippled!.socket.send( + Buffer.from([ + ...Buffer.from('{"type":"path_find","message":"'), + 0xff, + ...Buffer.from('"}'), + ]), + ) + }) + }) + it('Emits validationReceived', async function () { await new Promise((resolve) => { testContext.client.on('validationReceived', (path) => { diff --git a/packages/xrpl/test/connection.test.ts b/packages/xrpl/test/connection.test.ts index c23b55a135..e4cf8fdce9 100644 --- a/packages/xrpl/test/connection.test.ts +++ b/packages/xrpl/test/connection.test.ts @@ -833,6 +833,32 @@ describe('Connection', function () { TIMEOUT, ) + it( + 'handles malformed UTF-8 text websocket messages', + async () => { + clientContext.mockRippled!.addResponse('server_info', (request) => ({ + payload: Buffer.from([ + ...Buffer.from( + `{"type":"response","status":"success","id":${JSON.stringify(request.id)},"result":{"message":"`, + ), + 0xff, + ...Buffer.from('"}}'), + ]), + binary: false, + })) + + const response = await clientContext.client.request({ + command: 'server_info', + }) + + assert.strictEqual( + (response.result as unknown as { message: string }).message, + '�', + ) + }, + TIMEOUT, + ) + it( 'propagates RippledError data', async () => { diff --git a/packages/xrpl/test/createMockRippled.ts b/packages/xrpl/test/createMockRippled.ts index 954bcc2b1a..303882f3e3 100644 --- a/packages/xrpl/test/createMockRippled.ts +++ b/packages/xrpl/test/createMockRippled.ts @@ -10,9 +10,37 @@ import type { import { destroyServer, getFreePort } from './testUtils' +interface RawMockFrame { + payload: string | Buffer + binary?: boolean +} + +type MockResponse = + | BaseResponse + | ErrorResponse + | Record + | RawMockFrame + +function isRawMockFrame(response: MockResponse): response is RawMockFrame { + return typeof response === 'object' && 'payload' in response +} + +function sendResponse( + conn: WebSocket, + request: { id: number | string }, + response: MockResponse, +): void { + if (isRawMockFrame(response)) { + conn.send(response.payload, { binary: response.binary }) + return + } + + conn.send(createResponse(request, response)) +} + export function createResponse( request: { id: number | string }, - response: Record, + response: BaseResponse | ErrorResponse | Record, ): string { if (!('type' in response) && !('error' in response)) { throw new XrplError( @@ -24,7 +52,7 @@ export function createResponse( return JSON.stringify({ ...response, id: request.id }) } -function ping(conn, request): void { +function ping(conn: WebSocket, request: { id: number | string }): void { setTimeout(() => { conn.send( createResponse(request, { @@ -49,18 +77,14 @@ export interface PortResponse extends BaseResponse { export type MockedWebSocketServer = WebSocketServer & EventEmitter & { - responses: Record + responses: Record MockResponse)> suppressOutput: boolean socket: WebSocket addResponse: ( command: string, - response: - | BaseResponse - | ErrorResponse - | ((r: Request) => Response | ErrorResponse | Record) - | Record, + response: MockResponse | ((r: Request) => MockResponse), ) => void - getResponse: (request: Request) => Record + getResponse: (request: Request) => MockResponse testCommand: ( conn: WebSocket, request: { @@ -108,7 +132,7 @@ export default function createMockRippled(port: number): MockedWebSocketServer { } else if (request.command === 'test_command') { mock.testCommand(conn, request) } else if (request.command in mock.responses) { - conn.send(createResponse(request, mock.getResponse(request))) + sendResponse(conn, request, mock.getResponse(request)) } else { throw new XrplError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- We know it's there @@ -148,6 +172,7 @@ export default function createMockRippled(port: number): MockedWebSocketServer { } if ( typeof response === 'object' && + !isRawMockFrame(response) && !('type' in response) && !('error' in response) ) { @@ -160,15 +185,15 @@ export default function createMockRippled(port: number): MockedWebSocketServer { mock.responses[command] = response } - mock.getResponse = (request): Record => { + mock.getResponse = (request): MockResponse => { if (!(request.command in mock.responses)) { throw new XrplError(`No handler for ${request.command}`) } const functionOrObject = mock.responses[request.command] if (typeof functionOrObject === 'function') { - return functionOrObject(request) as Record + return functionOrObject(request) } - return functionOrObject as Record + return functionOrObject } mock.testCommand = function testCommand(conn, request): void {