-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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(rest): pass a better detailed error message into the response body #1598
Conversation
packages/rest/src/writer.ts
Outdated
@@ -6,6 +6,7 @@ | |||
import {OperationRetval, Response} from './types'; | |||
import {HttpError} from 'http-errors'; | |||
import {Readable} from 'stream'; | |||
const buildResponseData = require('strong-error-handler/lib/data-builder'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a bit hacky. Would it be better to have strong-error-handler
module to export the data-builder?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be. I'll make a PR on strong-error-handler to export the function
packages/rest/src/writer.ts
Outdated
const errObj: Partial<HttpError> = { | ||
statusCode, | ||
}; | ||
const errObj = buildResponseData(e, {}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we still keep the type? Partial<HttpError>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think yes. Let me verify if the given data does in fact fit the HttpError
type
Cross-posting from #1434:
I am proposing to rework our error handling to delegate all work to strong-error-handler module. As I am envisioning this part of LB4, we should:
The last step requires a small refactoring in strong-error-handler, I think we need to extract the following block of code into a new function that should be available to strong-error-handler consumers. if (res._header) {
debug('Response was already sent, closing the underlying connection');
return req.socket.destroy();
}
// this will alter the err object, to handle when res.statusCode is an error
if (!err.status && !err.statusCode && res.statusCode >= 400)
err.statusCode = res.statusCode;
var data = buildResponseData(err, options);
debug('Response status %s data %j', data.statusCode, data);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.statusCode = data.statusCode;
var sendResponse = negotiateContentProducer(req, warn, options);
sendResponse(res, data);
function warn(msg) {
res.header('X-Warning', msg);
debug(msg);
} Assuming the new function is called export function writeErrorToResponse(
{request, response}: HandlerContext,
error: Error,
options: ErrorHandlerOptions,
) {
strongErrorHandler.writeError(error, request, response, options);
} I think we can remove Thoughts? |
packages/rest/src/writer.ts
Outdated
@@ -6,6 +6,7 @@ | |||
import {OperationRetval, Response} from './types'; | |||
import {HttpError} from 'http-errors'; | |||
import {Readable} from 'stream'; | |||
const buildResponseData = require('strong-error-handler').buildResponseData; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a .d.ts
file to strong-error-handler so that we can use it in a type-safe way here.
import {buildResponseData} from 'strong-error-handler';
packages/rest/src/writer.ts
Outdated
@@ -59,17 +60,16 @@ export function writeResultToResponse( | |||
*/ | |||
export function writeErrorToResponse(response: Response, error: Error) { | |||
const e = <HttpError>error; | |||
const statusCode = (response.statusCode = e.statusCode || e.status || 500); | |||
if (e.headers) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By moving to strong-error-handler, we will loose this feature. However, I believe were not using error.headers
field so far (LB 3.x, 4.x), thus it should not be a problem. We can always add support for e.headers
later in strong-error-handler if needed.
packages/rest/src/writer.ts
Outdated
}; | ||
const errObj: Partial<HttpError> = buildResponseData(e, {}); | ||
response.statusCode = errObj.statusCode!; | ||
|
||
if (e.expose) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
strong-error-handler is intentionally using a different approach. Instead of asking the code throwing an error to decide whether the error is safe or not to be shown to the client, strong-error-handler asks the app developer/environment configuration to decide whether it's ok to show sensitive information.
I believe the approach used by strong-error-handler makes more sense, because the same error (e.g. "file /etc/passwd" cannot be found) usually needs different treatment in production (don't show any details) vs. in development (show all details to make debugging easier).
Let's remove e.expose
please.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
☝️
a7270af
to
57146cd
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is a question to answer as to how we want to deal with expose
property from errors generated through HttpError
. The default behavior for setting expose
is if statusCode is < 500, it's set to true. If it's >= 500, it's set to false. This can also be manually set through the HttpError
constructor as well.
I'm concerned that users would expect expose
to work as intended, and I don't know how to discourage users from using it. Would documentation on this would be enough?
On a side note, please remind me to add in documentation
@@ -24,7 +31,8 @@ export class RejectProvider implements Provider<Reject> { | |||
action({request, response}: HandlerContext, error: Error) { | |||
const err = <HttpError>error; | |||
const statusCode = err.statusCode || err.status || 500; | |||
writeErrorToResponse(response, err); | |||
const writeErrorToResponse = errorHandler(this.errorHandlerOptions); | |||
writeErrorToResponse(err, request, response); | |||
|
|||
// Always log the error in debug mode, even when the application | |||
// has a custom error logger configured (e.g. in tests) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this debug log is no longer needed because strong-error-handler is already printing a debug log. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There isn't a strict equivalent of this debug statement (the request method/url information is lost here) but I don't think it's a big deal to remove the debug log here.
packages/rest/src/writer.ts
Outdated
response.write(JSON.stringify(errObj)); | ||
response.end(); | ||
} | ||
export const defaultErrorHandlerOptions: ErrorHandlerOptions = {log: false}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's move this to RejectProvider
please. Now that writeErrorToResponse
is gone from writer.ts
, it feels weird to be to see defaultErrorHandlerOptions
defined here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we move defaultErrorHandlerOptions
to the provider as a property or outside of it as a constant? A problem I see with it being a property is that binding REJECT_OPTIONS
to the object may become more awkward; app.bind(RestBindings.SequenceActions.REJECT_OPTIONS).to(RejectProvider.defaultErrorHandlerOptions);
does not seem ideal when we want users to register providers separately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would keep defaultErrorHandlerOptions
as a constant outside of the class.
packages/rest/src/rest.component.ts
Outdated
@@ -48,6 +49,9 @@ export class RestComponent implements Component { | |||
@inject(RestBindings.CONFIG) config?: RestComponentConfig, | |||
) { | |||
app.bind(RestBindings.SEQUENCE).toClass(DefaultSequence); | |||
app | |||
.bind(RestBindings.SequenceActions.REJECT_OPTIONS) | |||
.to(defaultErrorHandlerOptions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A question to consider: should REJECT_OPTIONS be part of RestBinding.CONFIG
, alongside port
etc.?
If we decide to keep it as a top-level binding, then please move the key from RestBinding.SequenceActions
to RestBindings
namespace. IMO, options are not a sequence action.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does seem awkward that REJECT_OPTIONS
is a part of SequenceActions
@@ -24,7 +31,8 @@ export class RejectProvider implements Provider<Reject> { | |||
action({request, response}: HandlerContext, error: Error) { | |||
const err = <HttpError>error; | |||
const statusCode = err.statusCode || err.status || 500; | |||
writeErrorToResponse(response, err); | |||
const writeErrorToResponse = errorHandler(this.errorHandlerOptions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please note that we should always set log: false
, otherwise we may end up with two logs for each failed request (see this.logError
below).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Creating a new handler function every time an error is handled is suboptimal performance wise. V8 has to allocate a new closure and create a new dynamic function, which puts more pressure on the garbage collector. Additionally, V8 may not be able to optimize the handler function when it's always created anew (it definitely used to be a problem back in CrankShaft days, before Node.js 8.3 upgraded to a TurboFan-enabled version of V8).
That's why I proposed to modify strong-error-handler to export a function accepting (err, req, res, options)
.
Another reason is that technically, the error handler middleware accepts also a next
callback and I am not sure if it's a good idea to rely on the fact that the middleware never calls next
.
Sorry for not providing a detailed explanation from the beginning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please note that we should always set
log: false
, otherwise we may end up with two logs for each failed request (seethis.logError
below).
On the second thought, if we refactor strong-error-handler to export a new function that can be used directly from LB4, then I think a better alternative is to leave logging out of this newly exported function. That way we don't have to worry about log
flag because it will be always ignored.
Thoughts?
Oh, I didn't realize
This is a fair point, thank you for raising it. For the scope of this pull request, let's describe this limitation in our docs and point users to the strong-error-handler issue tracking support for |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Almost there :)
packages/rest/src/keys.ts
Outdated
* options | ||
*/ | ||
export const ERROR_HANDLER_OPTIONS = BindingKey.create<ErrorHandlerOptions>( | ||
'rest.sequence.actions.reject.options', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we remove a reference to sequence actions from the key name too? E.g. rest.errorHandlerOptions
.
|
||
const debug = require('debug')('loopback:rest:reject'); | ||
export const defaultErrorHandlerOptions: errorHandler.ErrorHandlerOptions = { | ||
log: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still need log
considering that writeErrorToResponse
is never logging anything?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops, can't believe I missed that
|
||
export class RejectProvider implements Provider<Reject> { | ||
constructor( | ||
@inject(RestBindings.SequenceActions.LOG_ERROR) | ||
protected logError: LogError, | ||
@inject(RestBindings.ERROR_HANDLER_OPTIONS) | ||
protected errorHandlerOptions?: errorHandler.ErrorHandlerOptions, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the options are optional (as you specified in the type annotation), then you also need to configure @inject
to be optional. In which case it's better to use property-level injection instead of constructor-level injection.
Alternatively, keep the options required and fix the typescript signature (remove the question mark).
import {RestBindings} from '../keys'; | ||
import * as errorHandler from 'strong-error-handler'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason for not using the following?
import {ErrorHandlerOptions, writeErrorToResponse} from 'strong-error-handler';
@@ -437,6 +482,9 @@ describe('HttpHandler', () => { | |||
.to(createUnexpectedHttpErrorLogger()); | |||
rootContext.bind(SequenceActions.SEND).to(writeResultToResponse); | |||
rootContext.bind(SequenceActions.REJECT).toProvider(RejectProvider); | |||
rootContext | |||
.bind(RestBindings.ERROR_HANDLER_OPTIONS) | |||
.to(defaultErrorHandlerOptions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be nicer to have RestBindings.ERROR_HANDLER_OPTIONS
as an optional binding, so that the tests don't have to provide it. Not a big deal though.
}); | ||
|
||
it('respects error handler options', async () => { | ||
rootContext.bind(RestBindings.ERROR_WRITER_OPTIONS).to({debug: true}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a question: how is the option {debug: true}
tested?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the error handler is set to debug
mode, it returns details of 500 errors in the HTTP responses. By default, debug
is disabled and HTTP responses show only a generic HTTP status message.
This is important for security to prevent the error handler from leaking sensitive informations like filesystem paths or hostnames & ports. This information is often included in the error message and/or error properties.
$ node
> fs.readFileSync('/etc/passwords')
Error: ENOENT: no such file or directory, open '/etc/passwords'
See also the assertion on lines L434-438 above, where the error handler in production mode returns the following output:
error: {
message: 'Internal Server Error',
statusCode: 500,
},
Compare it with debug mode:
error: {
message: 'Bad hello',
statusCode: 500,
},
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bajtos 👍 Thanks for the detailed answer, got it. Flag debug
controls the security level which affects the returned error message.
packages/rest/src/keys.ts
Outdated
* Binding key for setting and injecting Reject action's error handling | ||
* options | ||
*/ | ||
export const ERROR_WRITER_OPTIONS = BindingKey.create<ErrorHandlerOptions>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be ErrorWriterOptions
after recent changes in the strong-error-handler pull request.
}); | ||
|
||
it('respects error handler options', async () => { | ||
rootContext.bind(RestBindings.ERROR_WRITER_OPTIONS).to({debug: true}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the error handler is set to debug
mode, it returns details of 500 errors in the HTTP responses. By default, debug
is disabled and HTTP responses show only a generic HTTP status message.
This is important for security to prevent the error handler from leaking sensitive informations like filesystem paths or hostnames & ports. This information is often included in the error message and/or error properties.
$ node
> fs.readFileSync('/etc/passwords')
Error: ENOENT: no such file or directory, open '/etc/passwords'
See also the assertion on lines L434-438 above, where the error handler in production mode returns the following output:
error: {
message: 'Internal Server Error',
statusCode: 500,
},
Compare it with debug mode:
error: {
message: 'Bad hello',
statusCode: 500,
},
f95fa57
to
ba67cb4
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR configures
writer.ts
to usestrong-error-handler
to transform the thrown error.Please take a look at the modified test cases to see what the response body would now look like.
Checklist
npm test
passes on your machinepackages/cli
were updatedexamples/*
were updated