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
26 changes: 20 additions & 6 deletions wallet-gateway/remote/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { Env } from './env.js'
import { SigningWorker } from './signing/signing-worker.js'
import { apiKeyAuth } from './middleware/apiKeyAuth.js'
import { securityHeaders } from './middleware/securityHeaders.js'
import { errorHandler } from './middleware/errorHandler.js'

let isReady = false
let signingWorker: SigningWorker | undefined
Expand Down Expand Up @@ -375,9 +376,7 @@ export async function initialize(opts: CliOptions, logger: Logger) {
],
}

app.use(
'/api/*splat',
express.json(),
const apiMiddleware = [
preAuthRateLimit,
apiKeyAuth(
store,
Expand All @@ -390,8 +389,11 @@ export async function initialize(opts: CliOptions, logger: Logger) {
store,
allowedPaths,
logger.child({ component: 'SessionHandler' })
)
)
),
]

app.use(config.server.userPath, ...apiMiddleware)
app.use(config.server.dappPath, ...apiMiddleware)

logger.info({ ...config.server, port }, 'Server configuration')

Expand Down Expand Up @@ -440,8 +442,20 @@ export async function initialize(opts: CliOptions, logger: Logger) {
config.server.admin
)

const { userPath, dappPath } = config.server
const isApiPath = (path: string) =>
path === userPath ||
path === dappPath ||
path.startsWith(`${userPath}/`) ||
path.startsWith(`${dappPath}/`)

// register web handler
web(app, server, userApiUrl, dappApiUrl)
web(app, server, userApiUrl, dappApiUrl, isApiPath)

app.use(
errorHandler(logger.child({ component: 'ErrorHandler' }), isApiPath)
)

isReady = true

logger.info(
Expand Down
138 changes: 138 additions & 0 deletions wallet-gateway/remote/src/middleware/errorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { pino } from 'pino'
import { sink } from 'pino-test'
import { providerErrors, rpcErrors } from '@canton-network/core-rpc-errors'
import { errorHandler } from './errorHandler.js'

describe('errorHandler', () => {
const logger = pino({ level: 'silent' }, sink())
const isApiPath = (path: string) => path.startsWith('/api/')

let next: NextFunction
let status: ReturnType<typeof vi.fn>
let json: ReturnType<typeof vi.fn>

beforeEach(() => {
next = vi.fn() as NextFunction
status = vi.fn().mockReturnThis()
json = vi.fn()
})

function makeReq(partial: Partial<Request> = {}): Request {
return {
path: '/api/v0/user',
body: { id: 1 },
...partial,
} as Request
}

function makeRes(headersSent = false): Response {
return { status, json, headersSent } as unknown as Response
}

it('maps a JsonRpcError to its HTTP status and keeps the message', () => {
const err = providerErrors.unauthorized({
message: 'User is not connected',
})

errorHandler(logger, isApiPath)(err, makeReq(), makeRes(), next)

expect(status).toHaveBeenCalledWith(401)
expect(json).toHaveBeenCalledWith({
jsonrpc: '2.0',
id: 1,
error: {
code: providerErrors.unauthorized().code,
message: 'User is not connected',
},
})
})

it('replaces an unexpected error with a generic JSON-RPC 500 on API paths', () => {
const err = new Error('connect ECONNREFUSED 127.0.0.1:5432')

errorHandler(logger, isApiPath)(err, makeReq(), makeRes(), next)

expect(status).toHaveBeenCalledWith(500)
expect(json).toHaveBeenCalledWith({
jsonrpc: '2.0',
id: 1,
error: {
code: rpcErrors.internal().code,
message: 'Something went wrong',
},
})
})

it('never sends the stack trace to the client', () => {
const err = new Error('internal detail')

errorHandler(logger, isApiPath)(err, makeReq(), makeRes(), next)

const body = JSON.stringify(json.mock.calls[0][0])
expect(body).not.toContain('internal detail')
expect(body).not.toContain('at ') // stack trace
})

it('keeps the 413 from express.json() for err.status', () => {
const err = Object.assign(new Error('request too large'), {
status: 413,
})

errorHandler(logger, isApiPath)(err, makeReq(), makeRes(), next)

expect(status).toHaveBeenCalledWith(413)
expect(json).toHaveBeenCalledWith({ error: 'Payload Too Large' })
})

it('keeps the 413 from express.json() for err.statusCode', () => {
const err = Object.assign(new Error('request too large'), {
statusCode: 413,
})

errorHandler(logger, isApiPath)(err, makeReq(), makeRes(), next)

expect(status).toHaveBeenCalledWith(413)
expect(json).toHaveBeenCalledWith({ error: 'Payload Too Large' })
})

it('leaves an error without a 413 status as a generic 500', () => {
const err = Object.assign(new Error('not a 413 error'), {
status: 404,
})

errorHandler(logger, isApiPath)(err, makeReq(), makeRes(), next)

expect(status).toHaveBeenCalledWith(500)
})

it('returns a generic error body for non-API paths', () => {
const req = makeReq({ path: '/login' })

errorHandler(logger, isApiPath)(
new Error('internal detail'),
req,
makeRes(),
next
)

expect(status).toHaveBeenCalledWith(500)
expect(json).toHaveBeenCalledWith({ error: 'Internal Server Error' })
})

it('delegates to express when the response has already started', () => {
const err = new Error(
'error that some middleware started res on, but still passed error down'
)

errorHandler(logger, isApiPath)(err, makeReq(), makeRes(true), next)

expect(next).toHaveBeenCalledWith(err)
expect(status).not.toHaveBeenCalled()
expect(json).not.toHaveBeenCalled()
})
})
78 changes: 78 additions & 0 deletions wallet-gateway/remote/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { NextFunction, Request, Response } from 'express'
import { Logger } from 'pino'
import {
JsonRpcError,
rpcErrors,
toHttpErrorCode,
} from '@canton-network/core-rpc-errors'
import { jsonRpcResponse } from '@canton-network/core-rpc-transport'

const isPayloadTooLargeError = (err: unknown): boolean => {
if (typeof err !== 'object' || err === null) {
return false
}

const { status, statusCode } = err as {
status?: unknown
statusCode?: unknown
}

return status === 413 || statusCode === 413
}

// Catches unhandled errors and prevents internal details like stack trace from reaching end user
export function errorHandler(
logger: Logger,
isApiPath: (path: string) => boolean
) {
return (
err: unknown,
req: Request,
res: Response,
next: NextFunction
): void => {
// Full error with stack goes to logs only.
logger.error({ err }, 'Unhandled request error')

// If the response has already started, we can't safely send an error response.
if (res.headersSent) {
next(err)
return
}

if (isPayloadTooLargeError(err)) {
res.status(413).json({ error: 'Payload Too Large' })
return
}

// jsonRpcHandler already maps controllers errors via handleRpcError.
// This only runs for errors that escape earlier middlewares (e.g. auth/session checks).
if (isApiPath(req.path)) {
const id = req.body?.id ?? null

if (err instanceof JsonRpcError) {
res.status(toHttpErrorCode(err.code)).json(
jsonRpcResponse(id, {
error: { code: err.code, message: err.message },
})
)
return
}

res.status(500).json(
jsonRpcResponse(id, {
error: {
code: rpcErrors.internal().code,
message: 'Something went wrong',
},
})
)
return
}

res.status(500).json({ error: 'Internal Server Error' })
}
}
44 changes: 37 additions & 7 deletions wallet-gateway/remote/src/middleware/jsonRpcHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,31 @@ describe('handleRpcError', () => {
expect(body).toEqual({
jsonrpc: '2.0',
id: 99,
error: err,
error: { code: err.code, message: 'bad' },
})
expect(errorLog).not.toHaveBeenCalled()
})

it('uses generic method-specific message for non-JsonRpcError then replaces with Error.message', () => {
it('keeps the JsonRpcError message once the response is serialised', () => {
const err = rpcErrors.invalidParams({ message: 'error description' })
const [, body] = handleRpcError(err, 99)

expect(JSON.parse(JSON.stringify(body))).toMatchObject({
error: { code: err.code, message: 'error description' },
})
})

it('forwards data that was deliberately attached to a JsonRpcError', () => {
const err = rpcErrors.invalidParams({
message: 'bad',
data: { field: 'value' },
})
const [, body] = handleRpcError(err, 1)

expect(errorPayload(body)).toMatchObject({ data: { field: 'value' } })
})

it('forwards the Error message but not the error object itself', () => {
const [status, body] = handleRpcError(
new Error('some error'),
'id',
Expand All @@ -244,12 +263,24 @@ describe('handleRpcError', () => {
expect(body).toEqual({
jsonrpc: '2.0',
id: 'id',
error: expect.objectContaining({
error: {
code: rpcErrors.internal().code,
message: 'some error',
data: expect.any(Error),
}),
},
})
})

it('does not leak enumerable properties of runtime errors', () => {
const dbError = Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
address: '127.0.0.1',
port: 5432,
})

const [, body] = handleRpcError(dbError, 1, 'listWallets')

expect(errorPayload(body)).not.toHaveProperty('data')
expect(JSON.stringify(body)).not.toContain('5432')
})

it('uses generic message when method name is omitted', () => {
Expand Down Expand Up @@ -307,10 +338,9 @@ describe('handleRpcError', () => {
const [status, body] = handleRpcError({ foo: 'bar' }, 2, 'wrongMethod')

expect(status).toBe(500)
expect(errorPayload(body)).toMatchObject({
expect(errorPayload(body)).toEqual({
code: rpcErrors.internal().code,
message: 'Something went wrong while calling wrongMethod',
data: { foo: 'bar' },
})
})
})
11 changes: 7 additions & 4 deletions wallet-gateway/remote/src/middleware/jsonRpcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ interface JsonRpcHttpOptions<T> {
* Handles JSON-RPC errors and maps them to HTTP responses.
* @param error The error that occurred.
* @param id The JSON-RPC request ID.
* @param logger The logger instance.
* @param method The name of the JSON-RPC method being called.
* @returns A tuple containing the HTTP status code and the JSON-RPC response.
*/
Expand All @@ -42,12 +41,15 @@ export const handleRpcError = (
error: {
...rpcErrors.internal(),
message: genericMessage,
data: error,
},
}

if (error instanceof JsonRpcError) {
response.error = error
response.error = {
code: error.code,
message: error.message,
data: error.data,
}
const httpCode = toHttpErrorCode(error.code)
return [httpCode, jsonRpcResponse(id, response)]
}
Expand Down Expand Up @@ -164,8 +166,9 @@ export const jsonRpcHandler =
method
)

// Full error with callstack in logs, sanitized version in response
logger.error(
{ response },
{ err: error, response },
'RPC response: error with response'
)
res.status(status).json(response)
Expand Down
Loading
Loading