Skip to content

Commit 129ba54

Browse files
committed
feat: manage bad requests with 'allow request methods' package
Current behaviour: a DDoS can use an unexpected HTTP method to bypass the Fastly cache and cause interruption to service. For example, making a request to the homepage as a POST rather than a GET. Express will return a 404 for this error. Ideal future work: If we can transform this 404 error into a 405 Method Not Allowed, we may be able to use Signal Sciences to block this kind of attack very quickly. This is because it’s highly unlikely that a genuine user will accidentally make a POST request. Definition of done for this ticket: Build and release a package containing an Express middleware that applications can consume which will cause unexpected request methods to error with a 405 rather than a 404. See Also: [CPREL-1276]
1 parent 58a5a91 commit 129ba54

File tree

10 files changed

+270
-13
lines changed

10 files changed

+270
-13
lines changed

.release-please-manifest.json

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
{
2-
"packages/app-info": "4.0.0",
3-
"packages/crash-handler": "5.0.0",
4-
"packages/errors": "4.0.0",
5-
"packages/eslint-config": "4.0.0",
6-
"packages/fetch-error-handler": "1.0.0",
7-
"packages/log-error": "5.0.0",
8-
"packages/logger": "4.0.0",
9-
"packages/middleware-log-errors": "5.0.0",
10-
"packages/middleware-render-error-info": "6.0.0",
11-
"packages/serialize-error": "4.0.0",
12-
"packages/serialize-request": "4.0.0",
13-
"packages/opentelemetry": "3.0.0"
2+
"packages/app-info": "4.0.0",
3+
"packages/crash-handler": "5.0.0",
4+
"packages/errors": "4.0.0",
5+
"packages/eslint-config": "4.0.0",
6+
"packages/fetch-error-handler": "1.0.0",
7+
"packages/log-error": "5.0.0",
8+
"packages/logger": "4.0.0",
9+
"packages/middleware-log-errors": "5.0.0",
10+
"packages/middleware-render-error-info": "6.0.0",
11+
"packages/serialize-error": "4.0.0",
12+
"packages/serialize-request": "4.0.0",
13+
"packages/opentelemetry": "3.0.0",
14+
"packages/middleware-allow-request-methods": "0.0.0"
1415
}

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ We maintain documentation in this repo:
3434
* **[@dotcom-reliability-kit/logger](./packages/logger/#readme):**<br/>
3535
A simple and fast logger based on [Pino](https://getpino.io/), with FT preferences baked in
3636

37+
* **[@dotcom-reliability-kit/middleware-allow-request-methods](./packages/middleware-allow-request-methods/README.md):**<br/>
38+
Express middleware that returns 405 (rather than 404) for disallowed request methods
39+
3740
* **[@dotcom-reliability-kit/middleware-log-errors](./packages/middleware-log-errors/#readme):**<br/>
3841
Express middleware to consistently log errors
3942

package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
CHANGELOG.md
2+
docs
3+
test
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
2+
# @dotcom-reliability-kit/middleware-allow-request-methods
3+
4+
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).
5+
6+
* [Usage](#usage)
7+
* [Configuration options](#configuration-options)
8+
* [`options.allowedMethods`](#optionsallowedmethods)
9+
* [`options.message`](#optionsmessage)
10+
* [`options.logger`](#optionslogger)
11+
* [Migrating](#migrating)
12+
* [Contributing](#contributing)
13+
* [License](#license)
14+
15+
16+
## Usage
17+
18+
Install `@dotcom-reliability-kit/middleware-allow-request-methods` as a dependency:
19+
20+
```bash
21+
npm install --save @dotcom-reliability-kit/middleware-allow-request-methods
22+
```
23+
24+
Include in your code:
25+
26+
```js
27+
import allowRequestMethods from '@dotcom-reliability-kit/middleware-allow-request-methods';
28+
// or
29+
const allowRequestMethods = require('@dotcom-reliability-kit/middleware-allow-request-methods');
30+
```
31+
32+
Example usage:
33+
34+
```js
35+
const express = require('express');
36+
const allowRequestMethods = require('@dotcom-reliability-kit/middleware-allow-request-methods');
37+
38+
const app = express();
39+
40+
// Apply the middleware to specific routes, for example:
41+
app.use('/', allowRequestMethods(['GET'])); // Allow only GET requests on '/'
42+
app.use('/submit', allowRequestMethods(['POST'])); // Allow only POST requests on '/submit'
43+
44+
// Define your routes
45+
app.get('/', (req, res) => {
46+
res.send('Homepage');
47+
});
48+
49+
app.post('/submit', (req, res) => {
50+
res.send('Form submitted');
51+
});
52+
53+
app.listen(3000, () => console.log('Server running on port 3000'));
54+
```
55+
56+
### Configuration options
57+
58+
Config options can be passed into the `allowRequestMethods` function as an object with any of the keys below.
59+
60+
```js
61+
app.use(allowRequestMethods({
62+
// Config options go here
63+
}));
64+
```
65+
66+
#### `options.allowedMethods`
67+
68+
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.
69+
70+
This option defaults to `[]`.
71+
72+
#### `options.message`
73+
74+
A string to be used as the response body when a request is made with an unsupported method.
75+
76+
This option defaults to `'Method Not Allowed'`.
77+
78+
#### `options.logger`
79+
80+
A logger object which implements two methods, `error` and `warn`, which have the following permissive signature:
81+
82+
```ts
83+
type LogMethod = (...logData: any) => any;
84+
```
85+
86+
This is passed directly onto the relevant log-error method, [see the documentation for that package for more details](../log-error/README.md#optionslogger).
87+
88+
## Migrating
89+
90+
Consult the [Migration Guide](./docs/migration.md) if you're trying to migrate to a later major version of this package.
91+
92+
93+
## Contributing
94+
95+
See the [central contributing guide for Reliability Kit](https://github.com/Financial-Times/dotcom-reliability-kit/blob/main/docs/contributing.md).
96+
97+
98+
## License
99+
100+
Licensed under the [MIT](https://github.com/Financial-Times/dotcom-reliability-kit/blob/main/LICENSE) license.<br/>
101+
Copyright &copy; 2025, The Financial Times Ltd.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
const { logRecoverableError } = require('@dotcom-reliability-kit/log-error');
2+
3+
/**
4+
* @typedef {object} RequestMethodOptions
5+
* @property {string[]} [allowedMethods] The HTTP methods that are allowed i.e. will not throw 405 errors.
6+
* @property {string} [message] The error message text to use if a disallowed method is used.
7+
* @property {import('@dotcom-reliability-kit/log-error').Logger} [logger] The logger to use for logging errors.
8+
*/
9+
10+
/**
11+
* @typedef {import('express').ErrorRequestHandler} ExpressErrorHandler
12+
*/
13+
14+
/**
15+
* Create a middleware function to return 405 (rather than 404) for disallowed request methods.
16+
*
17+
* @param {RequestMethodOptions} [options]
18+
* @returns {ExpressErrorHandler}
19+
*/
20+
function allowRequestMethods(
21+
options = { allowedMethods: [], message: 'Method Not Allowed' }
22+
) {
23+
const normalisedAllowedRequestMethods = normaliseAllowedRequestMethods(
24+
options.allowedMethods || []
25+
);
26+
27+
return function allowRequestMethodsMiddleware(
28+
error,
29+
request,
30+
response,
31+
next
32+
) {
33+
// If headers are already sent, pass the error to the default Express error handler
34+
if (response.headersSent) {
35+
return next(error);
36+
}
37+
38+
try {
39+
// If the allowed methods array is empty, you can either allow all methods or reject everything
40+
if (normalisedAllowedRequestMethods.length === 0) {
41+
// TODO: Option 1: Allow all methods (no restriction) i.e. request proceeds as normal
42+
return next();
43+
44+
// TODO: or Option 2: Reject all methods (405 for every request) i.e. block all requests when no methods are explicitly stated
45+
// response.header('Allow', normalisedAllowedRequestMethods.join(', '));
46+
// response.status(405).send(options.message);
47+
// return next(new MethodNotAllowedError(options.message));
48+
}
49+
50+
// If the incoming request method is not in the allowed methods array, then send a 405 error
51+
if (
52+
!normalisedAllowedRequestMethods.includes(request.method.toUpperCase())
53+
) {
54+
response.header('Allow', normalisedAllowedRequestMethods.join(', '));
55+
response.status(405).send(options.message);
56+
return next(new MethodNotAllowedError(options.message));
57+
} else {
58+
// Else if it is, then pass the request to the next() middleware
59+
next();
60+
}
61+
} catch (/** @type {any} */ error) {
62+
if (options.logger) {
63+
logRecoverableError({
64+
error,
65+
logger: options.logger,
66+
request
67+
});
68+
}
69+
next(error);
70+
}
71+
};
72+
}
73+
74+
/**
75+
* Normalise an array of HTTP methods.
76+
*
77+
* @param {string[]} methods - The HTTP methods to normalise.
78+
* @returns {string[]} - Returns an array of capitalised HTTP methods.
79+
*/
80+
function normaliseAllowedRequestMethods(methods) {
81+
if (!Array.isArray(methods) || methods.length === 0) {
82+
return [];
83+
}
84+
return methods
85+
.filter((method) => typeof method === 'string')
86+
.map((method) => method.toUpperCase());
87+
}
88+
89+
/**
90+
* Error class for 405 Method Not Allowed errors.
91+
*
92+
* @augments Error
93+
* @property {string} name
94+
* @property {number} status
95+
* @property {number} statusCode
96+
*/
97+
class MethodNotAllowedError extends Error {
98+
/**
99+
* @override
100+
* @type {string}
101+
*/
102+
name = 'MethodNotAllowedError';
103+
104+
/** @type {number} */
105+
status = 405;
106+
107+
/** @type {number} */
108+
statusCode = 405;
109+
}
110+
111+
module.exports = allowRequestMethods;
112+
module.exports.default = module.exports;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@dotcom-reliability-kit/middleware-allow-request-methods",
3+
"version": "0.0.0",
4+
"description": "Express middleware that returns 405 (rather than 404) for disallowed request methods",
5+
"repository": {
6+
"type": "git",
7+
"url": "https://github.com/Financial-Times/dotcom-reliability-kit.git",
8+
"directory": "packages/middleware-allow-request-methods"
9+
},
10+
"homepage": "https://github.com/Financial-Times/dotcom-reliability-kit/tree/main/packages/middleware-allow-request-methods#readme",
11+
"bugs": "https://github.com/Financial-Times/dotcom-reliability-kit/issues?q=label:\"package: middleware-allow-request-methods\"",
12+
"license": "MIT",
13+
"engines": {
14+
"node": "20.x || 22.x"
15+
},
16+
"main": "lib/index.js",
17+
"types": "types/index.d.ts"
18+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
describe('@dotcom-reliability-kit/middleware-allow-request-methods', () => {
2+
it('has some tests', () => {
3+
throw new Error('Please write some tests');
4+
});
5+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module '@dotcom-reliability-kit/middleware-allow-request-methods' {}

release-please-config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"packages/middleware-render-error-info": {},
4242
"packages/serialize-error": {},
4343
"packages/serialize-request": {},
44-
"packages/opentelemetry": {}
44+
"packages/opentelemetry": {},
45+
"packages/middleware-allow-request-methods": {}
4546
}
4647
}

0 commit comments

Comments
 (0)