Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cactus-common): coerceUnknownToError() now uses HTML sanitize #2915

Merged
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import stringify from "fast-safe-stringify";
import sanitizeHtml from "sanitize-html";
import { ErrorFromUnknownThrowable } from "./error-from-unknown-throwable";
import { ErrorFromSymbol } from "./error-from-symbol";

Expand Down Expand Up @@ -26,10 +27,11 @@ export function coerceUnknownToError(x: unknown): Error {
} else if (x instanceof Error) {
return x;
outSH marked this conversation as resolved.
Show resolved Hide resolved
} else {
const xAsJson = stringify(x, (_, value) =>
const xAsJsonUnsafe = stringify(x, (_, value) =>
typeof value === "bigint" ? value.toString() + "n" : value,
);
return new ErrorFromUnknownThrowable(xAsJson);
const xAsJsonSanitized = sanitizeHtml(xAsJsonUnsafe);
return new ErrorFromUnknownThrowable(xAsJsonSanitized);
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cactus-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@
"express-jwt-authz": "2.4.1",
"express-openapi-validator": "5.0.4",
"http-errors": "2.0.0",
"http-errors-enhanced": "1.1.2",
"run-time-error-cjs": "1.4.0",
"safe-stable-stringify": "2.4.3",
"typescript-optional": "2.0.1"
},
"devDependencies": {
"@types/express": "4.17.19",
"@types/http-errors": "2.0.2",
"node-mocks-http": "1.14.0",
"uuid": "8.3.2"
},
"engines": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {
Logger,
createRuntimeErrorWithCause,
safeStringifyException,
} from "@hyperledger/cactus-common";
import type { Response } from "express";
import createHttpError from "http-errors";

/**
* An interface describing the object contaiing the contextual information needed by the
* An interface describing the object containing the contextual information needed by the
* `#handleRestEndpointException()` method to perform its duties.
*
* @param ctx - An object containing options for handling the REST endpoint exception.
Expand Down Expand Up @@ -35,39 +36,46 @@ export interface IHandleRestEndpointExceptionOptions {
*
* @param ctx - An object containing options for handling the REST endpoint exception.
*/
export function handleRestEndpointException(
export async function handleRestEndpointException(
ctx: Readonly<IHandleRestEndpointExceptionOptions>,
): void {
): Promise<void> {
const errorAsSanitizedJson = safeStringifyException(ctx.error);

const { identifierByCodes, INTERNAL_SERVER_ERROR } = await import(
"http-errors-enhanced"
);

if (createHttpError.isHttpError(ctx.error)) {
ctx.res.status(ctx.error.statusCode);

// Log either an error or a debug message depending on what the statusCode is
// For 5xx errors we treat it as a production bug that needs to be fixed on
// our side and for everything else we treat it a user error and debug log it.
if (ctx.error.statusCode >= 500) {
ctx.log.debug(ctx.errorMsg, ctx.error);
if (ctx.error.statusCode >= INTERNAL_SERVER_ERROR) {
ctx.log.debug(ctx.errorMsg, errorAsSanitizedJson);
} else {
ctx.log.error(ctx.errorMsg, ctx.error);
ctx.log.error(ctx.errorMsg, errorAsSanitizedJson);
}

// If the `expose` property is set to true it implies that we can safely
// expose the contents of the exception to the calling client.
if (ctx.error.expose) {
ctx.res.json({
message: ctx.error.message,
error: ctx.error,
message: identifierByCodes[ctx.error.statusCode],
error: errorAsSanitizedJson,
});
}
} else {
// If the exception was not an http-error then we assume it was an internal
// error (e.g. same behavior as if we had received an HTTP 500 statusCode)
ctx.log.error(ctx.errorMsg, ctx.error);
ctx.log.error(ctx.errorMsg, errorAsSanitizedJson);

const rex = createRuntimeErrorWithCause(ctx.errorMsg, ctx.error);
const sanitizedJsonRex = safeStringifyException(rex);

ctx.res.status(500).json({
message: "Internal Server Error",
error: rex,
ctx.res.status(INTERNAL_SERVER_ERROR).json({
message: identifierByCodes[INTERNAL_SERVER_ERROR],
error: sanitizedJsonRex,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import "jest-extended";
import createHttpError from "http-errors";
import { createResponse } from "node-mocks-http";

import { safeStringifyException } from "@hyperledger/cactus-common";

import {
handleRestEndpointException,
IHandleRestEndpointExceptionOptions,
} from "../../../main/typescript/public-api"; // replace with the correct path to your module

import { LoggerProvider } from "@hyperledger/cactus-common";

const log = LoggerProvider.getOrCreate({
label: "handle-rest-endpoint-exception.test.ts",
level: "DEBUG",
});

describe("handleRestEndpointException", () => {
afterEach(() => {
jest.clearAllMocks();
});

it("should handle HttpError with statusCode >= 500", async () => {
const mockResponse = createResponse();

const mockOptions: IHandleRestEndpointExceptionOptions = {
errorMsg: "Test error message",
log: jest.mocked(log), // Provide a mock logger if needed
error: new Error("Test error"), // Provide appropriate error objects for testing
res: mockResponse,
};

const mockHttpError = createHttpError(500, "Test HTTP error", {
expose: true,
});

const errorAsSanitizedJson = safeStringifyException(mockHttpError);
const spyLogDebug = jest.spyOn(mockOptions.log, "debug");
const spyStatus = jest.spyOn(mockResponse, "status");
const spyJson = jest.spyOn(mockResponse, "json");

await handleRestEndpointException({ ...mockOptions, error: mockHttpError });

expect(spyStatus).toHaveBeenCalledWith(500);

expect(spyLogDebug).toHaveBeenCalledWith(
mockOptions.errorMsg,
errorAsSanitizedJson,
);

expect(spyJson).toHaveBeenCalledWith({
message: "InternalServerError",
error: errorAsSanitizedJson,
});
});

it("should handle HttpError with statusCode < 500", async () => {
const mockResponse = createResponse();

const mockOptions: IHandleRestEndpointExceptionOptions = {
errorMsg: "Test error message",
log: jest.mocked(log), // Provide a mock logger if needed
error: new Error("Test error"), // Provide appropriate error objects for testing
res: mockResponse,
};

const mockHttpError = createHttpError(404, "Test HTTP error", {
expose: true,
});

const errorAsSanitizedJson = safeStringifyException(mockHttpError);
const spyLogError = jest.spyOn(mockOptions.log, "error");
const spyStatus = jest.spyOn(mockResponse, "status");
const spyJson = jest.spyOn(mockResponse, "json");
await handleRestEndpointException({ ...mockOptions, error: mockHttpError });

expect(spyStatus).toHaveBeenCalledWith(404);
expect(spyLogError).toHaveBeenCalledWith(
mockOptions.errorMsg,
errorAsSanitizedJson,
);

expect(spyJson).toHaveBeenCalledWith({
message: "NotFound",
error: errorAsSanitizedJson,
});
});

it("should handle non-HttpError", async () => {
const mockResponse = createResponse();

const mockError = new Error("An unexpected exception. Ha!");

const mockOptions: IHandleRestEndpointExceptionOptions = {
errorMsg: "Test error message",
log: jest.mocked(log), // Provide a mock logger if needed
error: mockError,
res: mockResponse,
};

const mockErrorJson = safeStringifyException(mockError);
const spyLoggerFn = jest.spyOn(mockOptions.log, "error");
const spyStatus = jest.spyOn(mockResponse, "status");
const spyJson = jest.spyOn(mockResponse, "json");

await handleRestEndpointException({ ...mockOptions, error: mockError });

expect(spyStatus).toHaveBeenCalledWith(500);

expect(spyLoggerFn).toHaveBeenCalledWith(
mockOptions.errorMsg,
mockErrorJson,
);

expect(spyJson).toHaveBeenCalledOnce();

const mostRecentCall = spyJson.mock.lastCall;
expect(mostRecentCall).toBeTruthy();

expect(mostRecentCall?.[0].message).toBeString();
expect(mostRecentCall?.[0].message).toEqual("InternalServerError");
expect(mostRecentCall?.[0].error).toMatch(
/RuntimeError: Test error message(.*)An unexpected exception. Ha!/,
);
});

it("should escape malicious payloads in exception messages", async () => {
const mockResponse = createResponse();

const dummyXssPayload = `<script>stealAndUploadPrivateKeys();</script>`;
const mockError = new Error(dummyXssPayload);

const mockOptions: IHandleRestEndpointExceptionOptions = {
errorMsg: "Test error message",
log: jest.mocked(log), // Provide a mock logger if needed
error: mockError,
res: mockResponse,
};

const mockErrorJson = safeStringifyException(mockError);
const spyLoggerFn = jest.spyOn(mockOptions.log, "error");
const spyStatus = jest.spyOn(mockResponse, "status");
const spyJson = jest.spyOn(mockResponse, "json");

await handleRestEndpointException({ ...mockOptions, error: mockError });

expect(spyStatus).toHaveBeenCalledWith(500);

expect(spyLoggerFn).toHaveBeenCalledWith(
mockOptions.errorMsg,
mockErrorJson,
);

expect(spyJson).toHaveBeenCalledOnce();

const mostRecentCall = spyJson.mock.lastCall;
expect(mostRecentCall).toBeTruthy();

expect(mostRecentCall?.[0].message).toBeString();
expect(mostRecentCall?.[0].message).toEqual("InternalServerError");
expect(mostRecentCall?.[0].error).toMatch(
/RuntimeError: Test error message(.*)/,
);
expect(mostRecentCall?.[0].error).not.toMatch(
/.*stealAndUploadPrivateKeys.*/,
);
});

it("should escape malicious payloads in strings thrown", async () => {
const mockResponse = createResponse();

const dummyXssPayload = `<script>stealAndUploadPrivateKeys();</script>`;
const mockError = dummyXssPayload;

const mockOptions: IHandleRestEndpointExceptionOptions = {
errorMsg: "Test error message",
log: jest.mocked(log), // Provide a mock logger if needed
error: mockError,
res: mockResponse,
};

const mockErrorJson = safeStringifyException(mockError);
const spyLoggerFn = jest.spyOn(mockOptions.log, "error");
const spyStatus = jest.spyOn(mockResponse, "status");
const spyJson = jest.spyOn(mockResponse, "json");

await handleRestEndpointException({ ...mockOptions, error: mockError });

expect(spyStatus).toHaveBeenCalledWith(500);

expect(spyLoggerFn).toHaveBeenCalledWith(
mockOptions.errorMsg,
mockErrorJson,
);

expect(spyJson).toHaveBeenCalledOnce();

const mostRecentCall = spyJson.mock.lastCall;
expect(mostRecentCall).toBeTruthy();

expect(mostRecentCall?.[0].message).toBeString();
expect(mostRecentCall?.[0].message).toEqual("InternalServerError");
expect(mostRecentCall?.[0].error).toMatch(
/RuntimeError: Test error message(.*)/,
);
expect(mostRecentCall?.[0].error).not.toMatch(
/.*stealAndUploadPrivateKeys.*/,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class DeleteKeychainEntryV1Endpoint implements IWebServiceEndpoint {
}

public async handleRequest(req: Request, res: Response): Promise<void> {
const { log } = this;
const fnTag = `${this.className}#handleRequest()`;
const reqTag = `${this.getVerbLowerCase()} - ${this.getPath()}`;
this.log.debug(reqTag);
Expand All @@ -96,7 +97,7 @@ export class DeleteKeychainEntryV1Endpoint implements IWebServiceEndpoint {
res.json(resBody);
} catch (ex) {
const errorMsg = `${reqTag} ${fnTag} Failed to deploy contract:`;
handleRestEndpointException({ errorMsg, log: this.log, error: ex, res });
await handleRestEndpointException({ errorMsg, log, error: ex, res });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class GetKeychainEntryV1Endpoint implements IWebServiceEndpoint {
}

public async handleRequest(req: Request, res: Response): Promise<void> {
const { log } = this;
const fnTag = `${this.className}#handleRequest()`;
const reqTag = `${this.getVerbLowerCase()} - ${this.getPath()}`;
this.log.debug(reqTag);
Expand All @@ -96,7 +97,7 @@ export class GetKeychainEntryV1Endpoint implements IWebServiceEndpoint {
res.json({ key, value });
} catch (ex) {
const errorMsg = `${reqTag} ${fnTag} Failed to deploy contract:`;
handleRestEndpointException({ errorMsg, log: this.log, error: ex, res });
await handleRestEndpointException({ errorMsg, log, error: ex, res });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class HasKeychainEntryV1Endpoint implements IWebServiceEndpoint {
}

public async handleRequest(req: Request, res: Response): Promise<void> {
const { log } = this;
const fnTag = `${this.className}#handleRequest()`;
const reqTag = `${this.getVerbLowerCase()} - ${this.getPath()}`;
this.log.debug(reqTag);
Expand All @@ -103,7 +104,7 @@ export class HasKeychainEntryV1Endpoint implements IWebServiceEndpoint {
res.json(resBody);
} catch (ex) {
const errorMsg = `${reqTag} ${fnTag} Failed to deploy contract:`;
handleRestEndpointException({ errorMsg, log: this.log, error: ex, res });
await handleRestEndpointException({ errorMsg, log, error: ex, res });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class SetKeychainEntryV1Endpoint implements IWebServiceEndpoint {
}

public async handleRequest(req: Request, res: Response): Promise<void> {
const { log } = this;
const fnTag = `${this.className}#handleRequest()`;
const reqTag = `${this.getVerbLowerCase()} - ${this.getPath()}`;
this.log.debug(reqTag);
Expand All @@ -96,7 +97,7 @@ export class SetKeychainEntryV1Endpoint implements IWebServiceEndpoint {
res.json(resBody);
} catch (ex) {
const errorMsg = `${reqTag} ${fnTag} Failed to deploy contract:`;
handleRestEndpointException({ errorMsg, log: this.log, error: ex, res });
await handleRestEndpointException({ errorMsg, log, error: ex, res });
}
}
}
Loading
Loading