Skip to content

feat: manage bad requests with 'allow request methods' package #1325

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

Merged
merged 4 commits into from
Feb 11, 2025
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
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"packages/middleware-render-error-info": "6.0.0",
"packages/serialize-error": "4.0.0",
"packages/serialize-request": "4.0.0",
"packages/opentelemetry": "3.0.0"
"packages/opentelemetry": "3.0.0",
"packages/middleware-allow-request-methods": "0.0.0"
}
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ We maintain documentation in this repo:
* **[@dotcom-reliability-kit/logger](./packages/logger/#readme):**<br/>
A simple and fast logger based on [Pino](https://getpino.io/), with FT preferences baked in

* **[@dotcom-reliability-kit/middleware-allow-request-methods](./packages/middleware-allow-request-methods/README.md):**<br/>
Express middleware that returns 405 (rather than 404) for disallowed request methods

* **[@dotcom-reliability-kit/middleware-log-errors](./packages/middleware-log-errors/#readme):**<br/>
Express middleware to consistently log errors

Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/middleware-allow-request-methods/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CHANGELOG.md
docs
test
91 changes: 91 additions & 0 deletions packages/middleware-allow-request-methods/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@

# @dotcom-reliability-kit/middleware-allow-request-methods

Express middleware that returns 405 (rather than 404) for disallowed request methods. This module is part of [FT.com Reliability Kit](https://github.com/Financial-Times/dotcom-reliability-kit#readme).

* [Usage](#usage)
* [Configuration options](#configuration-options)
* [`options.allowedMethods`](#optionsallowedmethods)
* [Migrating](#migrating)
* [Contributing](#contributing)
* [License](#license)

## Usage

Install `@dotcom-reliability-kit/middleware-allow-request-methods` as a dependency:

```bash
npm install --save @dotcom-reliability-kit/middleware-allow-request-methods
```

Include in your code:

```js
import { allowRequestMethods } from '@dotcom-reliability-kit/middleware-allow-request-methods';
// or
const { allowRequestMethods } = require('@dotcom-reliability-kit/middleware-allow-request-methods');
```

We recommend always using this middleware globally with app.use as a first middleware in your app. This is because, if a bad actor is making requests to your app to find attack vectors, you throw their request out as early as possible.

Route-specific blocking of methods is an additional layer of protection you can explore. It may be that your app does support POST requests for a form but the main view is GET only. You can filter out further junk requests on a per-route basis by using the app.route('...').all() method or use with a path.

Example usage:

```js
const express = require('express');
const { allowRequestMethods } = require('@dotcom-reliability-kit/middleware-allow-request-methods');

const app = express();

// Allow only certain request methods for the entire app. If you're
// doing this, it must be above ALL routes you want it to apply to:
app.use(allowRequestMethods({ allowedMethods: ['GET', 'HEAD', 'POST'] }));

// Allow only certain request methods for a specific route, e.g. here
// we only allow `GET` and `HEAD` methods for the home page. Note that
// we have to use `all` for the allowed methods here THEN define the get
// request handler:
app
.route('/')
.all(allowRequestMethods({ allowedMethods: ['GET', 'HEAD'] }))
.get((request, response) => {
response.send('Homepage');
});

// You can also allow methods for a subset of routes. Remember that this
// applies for all routes that START with the value. E.g. the following
// will also only allow POST requests on `/submit/example`:
app.use('/submit', allowRequestMethods({ allowedMethods: ['POST'] }));

app.post('/submit', (request, response) => {
response.send('Form submitted');
});

app.listen(3000, () => console.log('Server running on port 3000'));
```

### Configuration options

Config options can be passed into the `allowRequestMethods` function as an object with any of the keys below.

```js
app.use(allowRequestMethods({
// Config options go here
}));
```

#### `options.allowedMethods`

An array of HTTP methods that are allowed for the route. This must be an `Array` of `String`s, with each string being an HTTP method. It's important that you do not include methods which are not supported by the route.

This option defaults to `[]`.

## Contributing

See the [central contributing guide for Reliability Kit](https://github.com/Financial-Times/dotcom-reliability-kit/blob/main/docs/contributing.md).

## License

Licensed under the [MIT](https://github.com/Financial-Times/dotcom-reliability-kit/blob/main/LICENSE) license.<br/>
Copyright &copy; 2025, The Financial Times Ltd.
60 changes: 60 additions & 0 deletions packages/middleware-allow-request-methods/lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { UserInputError } = require('@dotcom-reliability-kit/errors');

/**
* @import { RequestMethodOptions } from '@dotcom-reliability-kit/middleware-allow-request-methods'
*/

/**
* @import { RequestHandler } from 'express'
*/

/**
* Create a middleware function to return 405 (rather than 404) for disallowed request methods.
*
* @param {RequestMethodOptions} options
* @returns {RequestHandler} - Returns an Express middleware function.
*/
function allowRequestMethods(options = { allowedMethods: [] }) {
// Check if allowed methods have been specified and are valid
const allowedMethodsSpecified = options?.allowedMethods;
if (
!Array.isArray(allowedMethodsSpecified) ||
allowedMethodsSpecified.length === 0 ||
allowedMethodsSpecified.every((method) => typeof method !== 'string')
) {
throw new TypeError(
'The `allowedMethods` option must be an array of strings'
);
}

const normalisedAllowedRequestMethods = normaliseAllowedRequestMethods(
allowedMethodsSpecified
);

return function allowRequestMethodsMiddleware(request, response, next) {
// We can't set the Allow header if headers have already been sent, otherwise the middleware will error
if (!response.headersSent) {
response.header('Allow', normalisedAllowedRequestMethods.join(', '));
}

// If the incoming request method is not in the allowed methods array, then send a 405 error
if (!normalisedAllowedRequestMethods.includes(request.method)) {
return next(new UserInputError({ statusCode: 405 }));
} else {
// Else if it is, then pass the request to the next() middleware
next();
}
};
}

/**
* Normalise an array of HTTP methods.
*
* @param {string[]} methods - The HTTP methods to normalise.
* @returns {string[]} - Returns an array of capitalised HTTP methods.
*/
function normaliseAllowedRequestMethods(methods) {
return methods.map((method) => method.toUpperCase());
}

exports.allowRequestMethods = allowRequestMethods;
18 changes: 18 additions & 0 deletions packages/middleware-allow-request-methods/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@dotcom-reliability-kit/middleware-allow-request-methods",
"version": "0.0.0",
"description": "Express middleware that returns 405 (rather than 404) for disallowed request methods",
"repository": {
"type": "git",
"url": "https://github.com/Financial-Times/dotcom-reliability-kit.git",
"directory": "packages/middleware-allow-request-methods"
},
"homepage": "https://github.com/Financial-Times/dotcom-reliability-kit/tree/main/packages/middleware-allow-request-methods#readme",
"bugs": "https://github.com/Financial-Times/dotcom-reliability-kit/issues?q=label:\"package: middleware-allow-request-methods\"",
"license": "MIT",
"engines": {
"node": "20.x || 22.x"
},
"main": "lib/index.js",
"types": "types/index.d.ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
const { allowRequestMethods } = require('../../../lib/index');
const { UserInputError } = require('@dotcom-reliability-kit/errors');

// Mock Express request and response objects
let mockRequest;
let mockResponse;
let mockNext;

describe('allowRequestMethods', () => {
beforeEach(() => {
// Reset all mocks before each test
mockRequest = {
method: 'GET'
};
mockResponse = {
headersSent: false,
header: jest.fn()
};
mockNext = jest.fn();
});

afterEach(() => {
// Clear all mocks after each test
jest.clearAllMocks();
});

describe('initialisation and validation', () => {
it('throws TypeError when no allowedMethods are provided', () => {
expect(() => {
allowRequestMethods();
}).toThrow(TypeError);

expect(() => {
allowRequestMethods({});
}).toThrow('The `allowedMethods` option must be an array of strings');
});

it('throws TypeError when allowedMethods is an empty array', () => {
expect(() => {
allowRequestMethods({ allowedMethods: [] });
}).toThrow(TypeError);
});

it('throws TypeError when allowedMethods contains non-string values', () => {
expect(() => {
allowRequestMethods({ allowedMethods: [123, true] });
}).toThrow(TypeError);
});

it('creates middleware function when valid allowedMethods are provided', () => {
const middleware = allowRequestMethods({
allowedMethods: ['GET', 'POST']
});
expect(typeof middleware).toBe('function');
});
});

describe('middleware behavior', () => {
it('sets Allow header with normalised methods', () => {
const middleware = allowRequestMethods({
allowedMethods: ['get', 'post']
});

middleware(mockRequest, mockResponse, mockNext);

expect(mockResponse.header).toHaveBeenCalledWith('Allow', 'GET, POST');
});

it('skips setting header if headers are already sent', () => {
mockResponse.headersSent = true;
const middleware = allowRequestMethods({
allowedMethods: ['GET', 'POST']
});

middleware(mockRequest, mockResponse, mockNext);

expect(mockResponse.header).not.toHaveBeenCalled();
});

it('calls next() with 405 error for disallowed method', () => {
mockRequest.method = 'DELETE';
const middleware = allowRequestMethods({
allowedMethods: ['GET', 'POST']
});

middleware(mockRequest, mockResponse, mockNext);

expect(mockNext).toHaveBeenCalledWith(expect.any(UserInputError));
const error = mockNext.mock.calls[0][0];
expect(error.statusCode).toBe(405);
});

it('calls next() without error for allowed method', () => {
mockRequest.method = 'GET';
const middleware = allowRequestMethods({
allowedMethods: ['GET', 'POST']
});

middleware(mockRequest, mockResponse, mockNext);

expect(mockNext).toHaveBeenCalledWith();
});
});

describe('normaliseAllowedRequestMethods', () => {
it('normalises methods to uppercase', () => {
const middleware = allowRequestMethods({
allowedMethods: ['get', 'Post', 'DELETE']
});

middleware(mockRequest, mockResponse, mockNext);

expect(mockResponse.header).toHaveBeenCalledWith(
'Allow',
'GET, POST, DELETE'
);
});
});
});
11 changes: 11 additions & 0 deletions packages/middleware-allow-request-methods/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { RequestHandler } from 'express';

declare module '@dotcom-reliability-kit/middleware-allow-request-methods' {
export type RequestMethodOptions = {
allowedMethods: string[];
};

declare function allowRequestMethods(
options: RequestMethodOptions
): RequestHandler;
}
3 changes: 2 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"packages/middleware-render-error-info": {},
"packages/serialize-error": {},
"packages/serialize-request": {},
"packages/opentelemetry": {}
"packages/opentelemetry": {},
"packages/middleware-allow-request-methods": {}
}
}