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(rest): pass a better detailed error message into the response body #1598

Merged
merged 1 commit into from Aug 31, 2018

Conversation

shimks
Copy link
Contributor

@shimks shimks commented Aug 7, 2018

This PR configures writer.ts to use strong-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 machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • API Documentation in code was updated
  • Documentation in /docs/site was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in examples/* were updated

@@ -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');
Copy link
Contributor

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?

Copy link
Contributor Author

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

@bajtos bajtos mentioned this pull request Aug 8, 2018
31 tasks
const errObj: Partial<HttpError> = {
statusCode,
};
const errObj = buildResponseData(e, {});
Copy link
Contributor

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>

Copy link
Contributor Author

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

@bajtos
Copy link
Member

bajtos commented Aug 13, 2018

Cross-posting from #1434:

Ideally, error responses should contain a single top-level error property that contains an object with additional details. This makes it easier to consume the response in places where the response body is available only (no access to response status code).

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:

  1. Obtain error-handler configuration via dependency injection - see RejectProvider. At minimum, we need to support debug and safeField options - see https://github.com/strongloop/strong-error-handler#options.
  2. Modify the obtained error-handler config to always disable error logging to console (we have our own error logger in place).
  3. Call strong-error-handler to produce the response for the given error and config.

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.

https://github.com/strongloop/strong-error-handler/blob/8b35494da09f945c6a7b1b3c38191d0b014a0872/lib/handler.js#L39-L60

     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 writeError, here is a mock-up usage in writeErrorToResponse to illustrate my point:

export function writeErrorToResponse(
  {request, response}: HandlerContext, 
  error: Error,
  options: ErrorHandlerOptions,
) {
    strongErrorHandler.writeError(error, request, response, options);
}

I think we can remove writeErrorToResponse entirely and call strong-error-handler from the action method of RejectProvider.

Thoughts?

@@ -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;
Copy link
Member

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';

@@ -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) {
Copy link
Member

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.

};
const errObj: Partial<HttpError> = buildResponseData(e, {});
response.statusCode = errObj.statusCode!;

if (e.expose) {
Copy link
Member

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.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

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

☝️

@shimks shimks force-pushed the better-error branch 2 times, most recently from a7270af to 57146cd Compare August 13, 2018 21:20
Copy link
Contributor Author

@shimks shimks left a 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)
Copy link
Member

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?

Copy link
Contributor Author

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.

response.write(JSON.stringify(errObj));
response.end();
}
export const defaultErrorHandlerOptions: ErrorHandlerOptions = {log: false};
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

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.

@@ -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);
Copy link
Member

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.

Copy link
Contributor Author

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);
Copy link
Member

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).

Copy link
Member

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.

Copy link
Member

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).

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?

@bajtos
Copy link
Member

bajtos commented Aug 14, 2018

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.

Oh, I didn't realize expose property is coming from http-errors package. We need to eventually improve strong-error-handler to honor this property then. Can you create a new issue in strong-error-handler to keep track of that work please?

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?

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 expose.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

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

Almost there :)

* options
*/
export const ERROR_HANDLER_OPTIONS = BindingKey.create<ErrorHandlerOptions>(
'rest.sequence.actions.reject.options',
Copy link
Member

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,
Copy link
Member

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?

Copy link
Contributor Author

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,
Copy link
Member

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';
Copy link
Member

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);
Copy link
Member

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});
Copy link
Contributor

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?

Copy link
Member

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,
         },

Copy link
Contributor

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.

* Binding key for setting and injecting Reject action's error handling
* options
*/
export const ERROR_WRITER_OPTIONS = BindingKey.create<ErrorHandlerOptions>(
Copy link
Member

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});
Copy link
Member

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,
         },

@shimks shimks force-pushed the better-error branch 3 times, most recently from f95fa57 to ba67cb4 Compare August 30, 2018 17:04
Copy link
Contributor

@jannyHou jannyHou left a comment

Choose a reason for hiding this comment

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

:shipit:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

The error response for a request with malformed JSON body provides too little information
6 participants