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
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
10 changes: 7 additions & 3 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 @@ -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<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.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('"}'),
]),

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