Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions packages/xrpl/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/xrpl/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function createWebSocket(
): WebSocket | null {
const options: ClientOptions = {
agent: config.agent,
skipUTF8Validation: true,
}
if (config.headers) {
options.headers = config.headers
Expand Down Expand Up @@ -335,7 +336,10 @@ export class Connection extends EventEmitter {
*
* @param message - 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<string, unknown>
try {
Expand Down Expand Up @@ -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),
)
Expand Down
52 changes: 52 additions & 0 deletions packages/xrpl/test/client/submitAndWait.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { assert } from 'chai'

import { XrplError } from '../../src'
import { Transaction } from '../../src/models/transactions'
import rippled from '../fixtures/rippled'
Expand Down Expand Up @@ -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<string, unknown>
const node = (meta.AffectedNodes as Array<Record<string, unknown>>)[0]
const modified = node.ModifiedNode as Record<string, unknown>
const fields = modified.FinalFields as Record<string, unknown>
const contract = fields.ContractJson as Record<string, unknown>
const allowances = contract.allowances as Record<string, string>
const [key] = Object.keys(allowances)

assert.strictEqual(txPollCount, 2)
assert.isTrue(response.result.validated)
assert.strictEqual(allowances[key], '500')
assert.include(key, '�')
})
})
21 changes: 21 additions & 0 deletions packages/xrpl/test/client/subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,27 @@ describe('Client subscription', function () {
})
})

it('Emits path_find from a non-Unicode mock websocket payload', async function () {
await new Promise<void>((resolve) => {
testContext.client.on('path_find', (path) => {
assert(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('"}'),
]),

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, socket.send(Buffer.from(...)) will default to sending a binary WebSocket frame in ws (since the payload is a Buffer). That means it doesn't exercise malformed UTF-8 handling for text frames (the UTF-8 validation logic only applies to text messages). Consider sending with { binary: false } (or using the new RawMockFrame support in the mock server) so this test actually covers malformed UTF-8 text messages.

Suggested change
]),
]),
{ binary: false },

Copilot uses AI. Check for mistakes.
)
Comment on lines +132 to +138

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read-only verification: locate Buffer-based websocket sends in tests.
rg -n "mockRippled!?\\.socket\\.send\\(|\\.send\\(\\s*Buffer\\.from\\(" packages/xrpl/test -C2

Repository: XRPLF/xrpl.js

Length of output: 433


🏁 Script executed:

# Get the full test case to understand what it's testing
sed -n '121,140p' packages/xrpl/test/client/subscribe.test.ts

Repository: XRPLF/xrpl.js

Length of output: 628


🏁 Script executed:

# Check ws library version and documentation about send options
cat packages/xrpl/package.json | grep -A2 -B2 '"ws"'

Repository: XRPLF/xrpl.js

Length of output: 236


🌐 Web query:

ws npm library socket.send Buffer binary frame default behavior

💡 Result:

In the Node.js ws npm library, socket.send(data[, options][, cb]) decides whether to send a text frame (opcode 0x1) or a binary frame (opcode 0x2) via the options.binary flag, whose default is “autodetected” based on the type of data you pass. (github.com)

Practically:

  • socket.send(Buffer.from(...)) (or other binary-like types such as ArrayBuffer, TypedArray, DataView) will be sent as binary by default (autodetect). (github.com)
  • socket.send("...") will be sent as text by default (autodetect). (github.com)
  • You can override the autodetect with socket.send(data, { binary: true/false }). (github.com)

On the receiving side, the 'message' event provides isBinary so you can tell which kind of message arrived. (github.com)

Citations:


🏁 Script executed:

# Check for other socket.send calls in the test file to see patterns
grep -n "socket.send" packages/xrpl/test/client/subscribe.test.ts

Repository: XRPLF/xrpl.js

Length of output: 104


🏁 Script executed:

# Check how the client handles the 'message' event - does it care about frame type?
rg -n "on\(['\"]message['\"]|isBinary" packages/xrpl/src -C3

Repository: XRPLF/xrpl.js

Length of output: 681


🏁 Script executed:

# Check what onMessage does with the message - does it parse differently based on type?
sed -n '395,450p' packages/xrpl/src/client/connection.ts

Repository: XRPLF/xrpl.js

Length of output: 2215


🏁 Script executed:

# Search for the onMessage method implementation
rg -n "onMessage\s*\(" packages/xrpl/src/client/connection.ts -A15

Repository: XRPLF/xrpl.js

Length of output: 1377


Send this malformed payload explicitly as a text frame.

At line 132, socket.send(Buffer) in the ws library defaults to a binary frame. Real rippled sends JSON responses as text frames, so pass { binary: false } to match that behavior.

💡 Suggested patch
-      testContext.mockRippled!.socket.send(
-        Buffer.from([
-          ...Buffer.from('{"type":"path_find","message":"'),
-          0xff,
-          ...Buffer.from('"}'),
-        ]),
-      )
+      testContext.mockRippled!.socket.send(
+        Buffer.from([
+          ...Buffer.from('{"type":"path_find","message":"'),
+          0xff,
+          ...Buffer.from('"}'),
+        ]),
+        { binary: false },
+      )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
testContext.mockRippled!.socket.send(
Buffer.from([
...Buffer.from('{"type":"path_find","message":"'),
0xff,
...Buffer.from('"}'),
]),
)
testContext.mockRippled!.socket.send(
Buffer.from([
...Buffer.from('{"type":"path_find","message":"'),
0xff,
...Buffer.from('"}'),
]),
{ binary: false },
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/xrpl/test/client/subscribe.test.ts` around lines 132 - 138, The test
is sending the malformed payload as a binary WebSocket frame
(testContext.mockRippled!.socket.send(Buffer...)) but real rippled sends JSON as
text frames; update the send call to send a text frame by passing the options
object { binary: false } (or send the JSON string directly) to
testContext.mockRippled!.socket.send so the mocked response is delivered as a
text frame matching real rippled behavior.

})
})

it('Emits validationReceived', async function () {
await new Promise<void>((resolve) => {
testContext.client.on('validationReceived', (path) => {
Expand Down
26 changes: 26 additions & 0 deletions packages/xrpl/test/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
51 changes: 38 additions & 13 deletions packages/xrpl/test/createMockRippled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,37 @@ import type {

import { destroyServer, getFreePort } from './testUtils'

interface RawMockFrame {
payload: string | Buffer
binary?: boolean
}

type MockResponse =
| BaseResponse
| ErrorResponse
| Record<string, unknown>
| 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<string, unknown>,
response: BaseResponse | ErrorResponse | Record<string, unknown>,
): string {
if (!('type' in response) && !('error' in response)) {
throw new XrplError(
Expand All @@ -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, {
Expand All @@ -49,18 +77,14 @@ export interface PortResponse extends BaseResponse {

export type MockedWebSocketServer = WebSocketServer &
EventEmitter & {
responses: Record<string, unknown>
responses: Record<string, MockResponse | ((r: Request) => MockResponse)>
suppressOutput: boolean
socket: WebSocket
addResponse: (
command: string,
response:
| BaseResponse
| ErrorResponse
| ((r: Request) => Response | ErrorResponse | Record<string, unknown>)
| Record<string, unknown>,
response: MockResponse | ((r: Request) => MockResponse),
) => void
getResponse: (request: Request) => Record<string, unknown>
getResponse: (request: Request) => MockResponse
testCommand: (
conn: WebSocket,
request: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -148,6 +172,7 @@ export default function createMockRippled(port: number): MockedWebSocketServer {
}
if (
typeof response === 'object' &&
!isRawMockFrame(response) &&
!('type' in response) &&
!('error' in response)
) {
Expand All @@ -160,15 +185,15 @@ export default function createMockRippled(port: number): MockedWebSocketServer {
mock.responses[command] = response
}

mock.getResponse = (request): Record<string, unknown> => {
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<string, unknown>
return functionOrObject(request)
}
return functionOrObject as Record<string, unknown>
return functionOrObject
}

mock.testCommand = function testCommand(conn, request): void {
Expand Down
Loading