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
13 changes: 13 additions & 0 deletions src/jrpc/errors/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@

type ErrorValueKey = keyof typeof errorValues;

export function isValidNumber(value: unknown): value is number {
try {
if (typeof value === "number" && Number.isInteger(value)) {
return true;
}

const parsedValue = Number(value.toString());
return Number.isInteger(parsedValue);
} catch {
return false;
}
}
Copy link

Choose a reason for hiding this comment

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

Empty string produces NaN error code instead of fallback

Medium Severity

The isValidNumber function uses Number() for validation, which treats empty strings and whitespace-only strings as 0. However, the calling code in constructFallbackError uses parseInt() for the actual conversion, which returns NaN for these same inputs. When code is an empty string, isValidNumber("") returns true (since Number("") is 0), but parseInt("") returns NaN, resulting in NaN being used as the error code instead of the fallback value.

Additional Locations (1)

Fix in Cursor Fix in Web


/**
* Returns whether the given code is valid.
* A code is valid if it is an integer.
Expand Down Expand Up @@ -210,9 +223,9 @@
*
* @param error - The error to serialize.
* @param options - Options bag.
* @param options.fallbackError - The error to return if the given error is

Check warning on line 226 in src/jrpc/errors/utils.ts

View workflow job for this annotation

GitHub Actions / test (22.x, ubuntu-latest)

tsdoc-param-tag-with-invalid-name: The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
* not compatible. Should be a JSON serializable value.
* @param options.shouldIncludeStack - Whether to include the error's stack

Check warning on line 228 in src/jrpc/errors/utils.ts

View workflow job for this annotation

GitHub Actions / test (22.x, ubuntu-latest)

tsdoc-param-tag-with-invalid-name: The @param block should be followed by a valid parameter name: The identifier cannot non-word characters
* on the returned object.
* @returns The serialized error.
*/
Expand Down
5 changes: 3 additions & 2 deletions src/jrpc/jrpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Duplex } from "readable-stream";

import { errorCodes } from "./errors";
import { AsyncJRPCMiddleware, ConsoleLike, IdMap, JRPCMiddleware, JRPCRequest, JRPCResponse, Json, ReturnHandlerCallback } from "./interfaces";
import { SafeEventEmitter } from "./safeEventEmitter";
import { SerializableError } from "./serializableError";
Expand All @@ -21,7 +22,7 @@ export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware<unknown,
try {
// json-rpc-engine will terminate the request when it notices this error
if (typeof req.method !== "string" || !req.method) {
res.error = new SerializableError({ code: -32603, message: "invalid method" });
res.error = new SerializableError({ code: errorCodes.rpc.invalidRequest, message: "invalid method" });
end();
return;
}
Expand All @@ -35,7 +36,7 @@ export function createErrorMiddleware(log: ConsoleLike): JRPCMiddleware<unknown,
});
} catch (error: unknown) {
log.error(`Auth - RPC Error thrown: ${(error as Error).message}`, error);
res.error = new SerializableError({ code: -32603, message: (error as Error).message });
res.error = new SerializableError({ code: errorCodes.rpc.internal, message: (error as Error).message });
end();
}
};
Expand Down
35 changes: 26 additions & 9 deletions src/jrpc/jrpcEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Duplex } from "readable-stream";

import { log } from "../utils/logger";
import { errorCodes, JsonRpcError } from "./errors";
import { getMessageFromCode, serializeJrpcError } from "./errors/utils";
import { getMessageFromCode, isValidNumber, serializeJrpcError } from "./errors/utils";
import {
JRPCEngineEndCallback,
JRPCEngineNextCallback,
Expand All @@ -25,11 +25,11 @@ export type JrpcEngineEvents = {
function constructFallbackError(error: Error): JRPCError {
const {
message = "",
code = -32603,
code = errorCodes.rpc.internal,
stack = "Stack trace is not available.",
data = "",
} = error as { message?: string; code?: number; stack?: string; data?: string };
const codeNumber = parseInt(code?.toString() || "-32603");
const codeNumber = isValidNumber(code) ? parseInt(code.toString()) : errorCodes.rpc.internal;
return {
message: message || error?.toString() || getMessageFromCode(codeNumber),
code: codeNumber,
Expand Down Expand Up @@ -116,7 +116,7 @@ export class JRPCEngine extends SafeEventEmitter<JrpcEngineEvents> {
} else {
if (returnHandler) {
if (typeof returnHandler !== "function") {
end(new SerializableError({ code: -32603, message: "JRPCEngine: 'next' return handlers must be functions" }));
end(new SerializableError({ code: errorCodes.rpc.internal, message: "JRPCEngine: 'next' return handlers must be functions" }));
}
returnHandlers.push(returnHandler);
}
Expand Down Expand Up @@ -152,10 +152,10 @@ export class JRPCEngine extends SafeEventEmitter<JrpcEngineEvents> {
*/
private static _checkForCompletion(_req: JRPCRequest<unknown>, res: JRPCResponse<unknown>, isComplete: boolean): void {
if (!("result" in res) && !("error" in res)) {
throw new SerializableError({ code: -32603, message: "Response has no error or result for request" });
throw new SerializableError({ code: errorCodes.rpc.internal, message: "Response has no error or result for request" });
}
if (!isComplete) {
throw new SerializableError({ code: -32603, message: "Nothing ended request" });
throw new SerializableError({ code: errorCodes.rpc.internal, message: "Nothing ended request" });
}
}

Expand Down Expand Up @@ -268,6 +268,17 @@ export class JRPCEngine extends SafeEventEmitter<JrpcEngineEvents> {
): Promise<JRPCResponse<unknown>[] | void> {
// The order here is important
try {
if (reqs.length === 0) {
const error = new SerializableError({
code: errorCodes.rpc.invalidRequest,
message: "Request batch must contain plain objects. Received an empty array",
});
const response: JRPCResponse<unknown>[] = [{ id: undefined, jsonrpc: "2.0" as const, error }];
if (cb) {
return cb(error, response);
}
return response;
}
// 2. Wait for all requests to finish, or throw on some kind of fatal
// error
const responses = await Promise.all(
Expand Down Expand Up @@ -312,12 +323,18 @@ export class JRPCEngine extends SafeEventEmitter<JrpcEngineEvents> {
*/
private async _handle(callerReq: JRPCRequest<unknown>, cb: (error: unknown, response: JRPCResponse<unknown>) => void): Promise<void> {
if (!callerReq || Array.isArray(callerReq) || typeof callerReq !== "object") {
const error = new SerializableError({ code: -32603, message: "request must be plain object" });
const error = new SerializableError({
code: errorCodes.rpc.invalidRequest,
message: `Requests must be plain objects. Received: ${typeof callerReq}`,
});
return cb(error, { id: undefined, jsonrpc: "2.0", error });
}

if (typeof callerReq.method !== "string") {
const error = new SerializableError({ code: -32603, message: "method must be string" });
if (typeof callerReq.method !== "string" || !callerReq.method) {
const error = new SerializableError({
code: errorCodes.rpc.invalidRequest,
message: `Must specify a string method. Received: ${typeof callerReq.method}`,
});
return cb(error, { id: callerReq.id, jsonrpc: "2.0", error });
}

Expand Down
33 changes: 33 additions & 0 deletions test/jrpcEngine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";

import { errorCodes } from "../src/jrpc/errors";
import { JRPCEngine } from "../src/jrpc/jrpcEngine";

describe("JRPCEngine request validation", () => {
it("returns invalidRequest for non-object requests", async () => {
const engine = new JRPCEngine();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await engine.handle(123 as any);
expect(response.error?.code).toBe(errorCodes.rpc.invalidRequest);
expect(response.id).toBeUndefined();
expect(response.jsonrpc).toBe("2.0");
});

it("returns invalidRequest for non-string method", async () => {
const engine = new JRPCEngine();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await engine.handle({ id: 1, jsonrpc: "2.0", method: 123 as any });
expect(response.error?.code).toBe(errorCodes.rpc.invalidRequest);
expect(response.id).toBe(1);
expect(response.jsonrpc).toBe("2.0");
});

it("returns invalidRequest for empty batch requests", async () => {
const engine = new JRPCEngine();
const responses = await engine.handle([]);
expect(responses).toHaveLength(1);
expect(responses[0].error?.code).toBe(errorCodes.rpc.invalidRequest);
expect(responses[0].id).toBeUndefined();
expect(responses[0].jsonrpc).toBe("2.0");
});
});
Loading