-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
4dd32d5
commit f0dd6c2
Showing
6 changed files
with
256 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
--- | ||
"@comet/cms-api": minor | ||
--- | ||
|
||
Add `ExceptionFilter` to replace `ExceptionInterceptor` | ||
|
||
The main motivation for this change was that the `ExceptionInterceptor` didn't capture exceptions thrown in guards. This could lead to information leaks, e.g., details about the database schema or the underlying code. This is considered a security risk. | ||
|
||
The `ExceptionFilter` also catches error within guards. The error format remains unchanged. | ||
|
||
Switching from the `ExceptionInterceptor` to the `ExceptionFilter` must be done in the project: | ||
|
||
```diff | ||
// main.ts | ||
|
||
- app.useGlobalInterceptors(new ExceptionInterceptor(config.debug)); | ||
+ app.useGlobalFilters(new ExceptionFilter(config.debug)); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
167 changes: 167 additions & 0 deletions
167
packages/api/cms-api/src/common/errors/exception.filter.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
import { ArgumentsHost, BadRequestException, HttpException, InternalServerErrorException } from "@nestjs/common"; | ||
|
||
import { CometEntityNotFoundException } from "./entity-not-found.exception"; | ||
import { ExceptionFilter } from "./exception.filter"; | ||
import { CometValidationException } from "./validation.exception"; | ||
import Mock = jest.Mock; | ||
|
||
const graphQLHost = { | ||
getType: () => "graphql", | ||
} as ArgumentsHost; | ||
|
||
jest.spyOn(console, "error").mockImplementation(() => { | ||
// noop | ||
}); | ||
|
||
describe("ExceptionFilter", () => { | ||
describe("catch", () => { | ||
describe("graphql", () => { | ||
it("returns BadRequestException for CometException", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
const returnError = exceptionFilter.catch(new CometEntityNotFoundException("Not found"), graphQLHost); | ||
|
||
expect(returnError).toBeInstanceOf(BadRequestException); | ||
expect((returnError as BadRequestException).getResponse()).toEqual({ | ||
statusCode: 400, | ||
message: "Not found", | ||
error: "CometEntityNotFoundException", | ||
validationErrors: [], | ||
}); | ||
}); | ||
|
||
it("returns BadRequestException for CometValidationException", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
const returnError = exceptionFilter.catch(new CometValidationException("Invalid", [{ property: "prop1" }]), graphQLHost); | ||
|
||
expect(returnError).toBeInstanceOf(BadRequestException); | ||
expect((returnError as BadRequestException).getResponse()).toEqual({ | ||
statusCode: 400, | ||
message: "Invalid", | ||
error: "CometValidationException", | ||
validationErrors: [ | ||
{ | ||
property: "prop1", | ||
}, | ||
], | ||
}); | ||
}); | ||
|
||
it("returns HttpException for HttpException", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
const returnError = exceptionFilter.catch(new HttpException("Unauthorized", 401), graphQLHost); | ||
|
||
expect(returnError).toBeInstanceOf(HttpException); | ||
expect((returnError as HttpException).getResponse()).toBe("Unauthorized"); | ||
}); | ||
|
||
it("returns InternalServerErrorException for normal Error in non-debug mode", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
const returnError = exceptionFilter.catch(new Error("Other error"), graphQLHost); | ||
|
||
expect(returnError).toBeInstanceOf(InternalServerErrorException); | ||
expect((returnError as InternalServerErrorException).getResponse()).toEqual({ | ||
message: "Internal Server Error", | ||
statusCode: 500, | ||
}); | ||
}); | ||
|
||
it("returns real Error for normal Error in debug mode", () => { | ||
const exceptionFilter = new ExceptionFilter(true); | ||
|
||
const returnError = exceptionFilter.catch(new Error("Other error"), graphQLHost); | ||
|
||
expect(returnError).toBeInstanceOf(Error); | ||
expect((returnError as Error).message).toBe("Other error"); | ||
}); | ||
}); | ||
|
||
describe("http", () => { | ||
let httpHost: ArgumentsHost; | ||
let statusMock: Mock; | ||
let jsonMock: Mock; | ||
|
||
beforeEach(() => { | ||
statusMock = jest.fn(); | ||
jsonMock = jest.fn(); | ||
|
||
httpHost = { | ||
getType: () => "http", | ||
switchToHttp: jest.fn(() => ({ | ||
getResponse: jest.fn(() => ({ | ||
status: statusMock.mockReturnThis(), | ||
json: jsonMock, | ||
})), | ||
})), | ||
} as unknown as ArgumentsHost; | ||
}); | ||
|
||
it("response status is 400 and json is correct for CometException", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
exceptionFilter.catch(new CometEntityNotFoundException("Not found"), httpHost); | ||
|
||
const responseMock = httpHost.switchToHttp().getResponse(); | ||
expect(responseMock.status).toHaveBeenCalledWith(400); | ||
expect(responseMock.json).toHaveBeenCalledWith({ | ||
statusCode: 400, | ||
message: "Not found", | ||
error: "CometEntityNotFoundException", | ||
validationErrors: [], | ||
}); | ||
}); | ||
|
||
it("response status is 400 and json is correct for CometValidationException", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
exceptionFilter.catch(new CometValidationException("Invalid", [{ property: "prop1" }]), httpHost); | ||
|
||
const responseMock = httpHost.switchToHttp().getResponse(); | ||
expect(responseMock.status).toHaveBeenCalledWith(400); | ||
expect(responseMock.json).toHaveBeenCalledWith({ | ||
statusCode: 400, | ||
message: "Invalid", | ||
error: "CometValidationException", | ||
validationErrors: [ | ||
{ | ||
property: "prop1", | ||
}, | ||
], | ||
}); | ||
}); | ||
|
||
it("response status is 401 and message is correct for HttpException", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
exceptionFilter.catch(new HttpException("Unauthorized", 401), httpHost); | ||
|
||
const responseMock = httpHost.switchToHttp().getResponse(); | ||
expect(responseMock.status).toHaveBeenCalledWith(401); | ||
expect(responseMock.json).toHaveBeenCalledWith("Unauthorized"); | ||
}); | ||
|
||
it("response status is 500 and json is correct for normal Error in non-debug mode", () => { | ||
const exceptionFilter = new ExceptionFilter(false); | ||
|
||
exceptionFilter.catch(new Error("Other error"), httpHost); | ||
|
||
const responseMock = httpHost.switchToHttp().getResponse(); | ||
expect(responseMock.status).toHaveBeenCalledWith(500); | ||
expect(responseMock.json).toHaveBeenCalledWith({ message: "Internal Server Error", statusCode: 500 }); | ||
}); | ||
|
||
it("response status is 500 and json contains real error for normal Error in debug mode", () => { | ||
const exceptionFilter = new ExceptionFilter(true); | ||
|
||
exceptionFilter.catch(new Error("Other error"), httpHost); | ||
|
||
const responseMock = httpHost.switchToHttp().getResponse(); | ||
expect(responseMock.status).toHaveBeenCalledWith(500); | ||
expect(responseMock.json).toHaveBeenCalledWith({ message: "Other error", statusCode: 500 }); | ||
}); | ||
}); | ||
}); | ||
}); |
65 changes: 65 additions & 0 deletions
65
packages/api/cms-api/src/common/errors/exception.filter.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { | ||
ArgumentsHost, | ||
BadRequestException, | ||
Catch, | ||
ExceptionFilter as NestExceptionFilter, | ||
HttpException, | ||
InternalServerErrorException, | ||
Logger, | ||
} from "@nestjs/common"; | ||
import { ErrorHttpStatusCode } from "@nestjs/common/utils/http-error-by-code.util"; | ||
import { Response } from "express"; | ||
|
||
import { CometException } from "./comet.exception"; | ||
import { CometValidationException } from "./validation.exception"; | ||
|
||
@Catch() | ||
export class ExceptionFilter implements NestExceptionFilter { | ||
protected readonly logger = new Logger(ExceptionFilter.name); | ||
|
||
constructor(private readonly debug: boolean) {} | ||
|
||
catch(exception: Error, host: ArgumentsHost) { | ||
let statusCode: ErrorHttpStatusCode; | ||
let returnedError: Error; | ||
|
||
if (exception instanceof CometException) { | ||
const errorObject: Record<string, unknown> = { | ||
statusCode: 400, | ||
message: exception.message, | ||
error: exception.constructor.name, | ||
validationErrors: [], | ||
}; | ||
|
||
if (exception instanceof CometValidationException) { | ||
errorObject.validationErrors = exception.errors; | ||
} | ||
|
||
statusCode = 400; | ||
returnedError = new BadRequestException(errorObject); | ||
} else if (exception instanceof HttpException) { | ||
statusCode = exception.getStatus(); | ||
returnedError = exception; | ||
} else { | ||
returnedError = this.debug ? exception : new InternalServerErrorException(); | ||
statusCode = "getStatus" in returnedError && typeof returnedError.getStatus === "function" ? returnedError.getStatus() : 500; | ||
this.logger.error(exception, exception.stack); // Log for debugging | ||
} | ||
|
||
const ctxType = host.getType<"http" | "graphql">(); // Check if it's an HTTP or GraphQL request | ||
|
||
if (ctxType === "graphql") { | ||
return returnedError; | ||
} else { | ||
const ctx = host.switchToHttp(); | ||
const response = ctx.getResponse<Response>(); | ||
response | ||
.status(statusCode) | ||
.json( | ||
"getResponse" in returnedError && typeof returnedError.getResponse === "function" | ||
? returnedError.getResponse() | ||
: { statusCode, message: returnedError.message }, | ||
); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters