hapi-terminator 0.0.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -23
- package/dist/index.d.ts +16 -13
- package/dist/index.js +54 -19
- package/dist/package.json +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# hapi-terminator
|
|
2
2
|
|
|
3
|
-
A Hapi plugin that terminates requests with payloads that exceed a specified size limit. This plugin helps protect your server from excessively large payloads by
|
|
3
|
+
A Hapi plugin that terminates requests with payloads that exceed a specified size limit. This plugin helps protect your server from excessively large payloads by gracefully ending the socket connection before the entire payload is processed.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- 🛡️ Protects against large payload attacks
|
|
8
8
|
- 🔀 Different limits for registered vs unregistered routes
|
|
9
|
-
- 🎯
|
|
9
|
+
- 🎯 Per-route limit configuration
|
|
10
10
|
- ⚡ Terminates connections early to save resources
|
|
11
11
|
- 📦 TypeScript support included
|
|
12
12
|
|
|
@@ -54,46 +54,90 @@ await server.start();
|
|
|
54
54
|
console.log('Server running on %s', server.info.uri);
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
###
|
|
57
|
+
### Per-Route Limits
|
|
58
58
|
|
|
59
|
-
You can
|
|
59
|
+
You can override the global limits for specific routes by setting the limit in the route options:
|
|
60
60
|
|
|
61
61
|
```typescript
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
import Hapi, { type PluginSpecificConfiguration } from '@hapi/hapi';
|
|
63
|
+
import terminatorPlugin, { type TerminatorOptions, type TerminatorRouteOptions } from 'hapi-terminator';
|
|
64
|
+
|
|
65
|
+
type RoutePluginOptions = PluginSpecificConfiguration & TerminatorRouteOptions;
|
|
66
|
+
|
|
67
|
+
const server = Hapi.server({ port: 3000, host: '127.0.0.1' });
|
|
68
|
+
|
|
69
|
+
await server.register({
|
|
70
|
+
plugin: terminatorPlugin,
|
|
71
|
+
options: {
|
|
72
|
+
registeredLimit: 500 * 1024, // 500KB default for registered routes
|
|
73
|
+
unregisteredLimit: 100 * 1024, // 100KB for unregistered routes
|
|
69
74
|
},
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Standard route with default limit (500KB)
|
|
78
|
+
server.route({
|
|
79
|
+
method: ['GET', 'POST'],
|
|
80
|
+
path: '/',
|
|
81
|
+
handler: () => 'Hello World!',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Upload route with higher limit (10MB)
|
|
85
|
+
server.route({
|
|
86
|
+
method: ['POST'],
|
|
87
|
+
path: '/upload',
|
|
88
|
+
handler: () => ({ success: true }),
|
|
89
|
+
options: {
|
|
90
|
+
plugins: {
|
|
91
|
+
'hapi-terminator': { limit: 10 * 1024 * 1024 }, // 10MB
|
|
92
|
+
} as RoutePluginOptions,
|
|
72
93
|
},
|
|
73
|
-
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Unlimited route (disable limit for this specific route)
|
|
97
|
+
server.route({
|
|
98
|
+
method: ['POST'],
|
|
99
|
+
path: '/stream',
|
|
100
|
+
handler: () => ({ success: true }),
|
|
101
|
+
options: {
|
|
102
|
+
plugins: {
|
|
103
|
+
'hapi-terminator': { limit: null }, // No limit
|
|
104
|
+
} as RoutePluginOptions,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await server.start();
|
|
74
109
|
```
|
|
75
110
|
|
|
76
111
|
## Configuration
|
|
77
112
|
|
|
78
113
|
### TerminatorOptions
|
|
79
114
|
|
|
80
|
-
| Option | Type
|
|
81
|
-
| ------------------- |
|
|
82
|
-
| `registeredLimit` | `number
|
|
83
|
-
| `unregisteredLimit` | `number
|
|
115
|
+
| Option | Type | Description |
|
|
116
|
+
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| `registeredLimit` | `number` | Maximum payload size in bytes for registered routes. Must be >= 0. Set to `null` or `undefined` to disable. |
|
|
118
|
+
| `unregisteredLimit` | `number` | Maximum payload size in bytes for unregistered routes. Must be >= 0. Set to `null` or `undefined` to disable. |
|
|
119
|
+
|
|
120
|
+
### TerminatorRouteOptions
|
|
121
|
+
|
|
122
|
+
You can configure per-route limits using the route options:
|
|
123
|
+
|
|
124
|
+
| Option | Type | Description |
|
|
125
|
+
| ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `limit` | `number` | Maximum payload size in bytes for this specific route. Must be >= 0. Overrides the global `registeredLimit`. Set to `null` to disable. |
|
|
84
127
|
|
|
85
128
|
### Behavior
|
|
86
129
|
|
|
87
|
-
- **Registered Routes**: When a payload exceeds the limit on a registered route, the socket is
|
|
88
|
-
- **Unregistered Routes**: When a payload exceeds the limit on an unregistered route, the socket is
|
|
89
|
-
- **
|
|
130
|
+
- **Registered Routes**: When a payload exceeds the limit on a registered route, the socket is gracefully ended and a `413 Payload Too Large` error is returned.
|
|
131
|
+
- **Unregistered Routes**: When a payload exceeds the limit on an unregistered route, the socket is gracefully ended and a `404 Not Found` error is returned.
|
|
132
|
+
- **Per-Route Limits**: Route-specific limits take precedence over global limits, allowing you to customize limits for individual routes.
|
|
133
|
+
- **Disabled**: Set to `null` or `undefined` to disable termination for that category or route.
|
|
90
134
|
|
|
91
135
|
## How It Works
|
|
92
136
|
|
|
93
137
|
The plugin hooks into Hapi's `onRequest` extension point and checks the `Content-Length` header of incoming requests. If the content length exceeds the configured threshold:
|
|
94
138
|
|
|
95
|
-
1.
|
|
96
|
-
2.
|
|
139
|
+
1. An appropriate error response is returned (413 for registered routes, 404 for unregistered routes)
|
|
140
|
+
2. The socket connection is gracefully ended after the response is sent
|
|
97
141
|
3. No further processing occurs, saving server resources
|
|
98
142
|
|
|
99
143
|
## License
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
import type { Server
|
|
2
|
-
|
|
3
|
-
type
|
|
4
|
-
export type TerminatorOptions =
|
|
5
|
-
[Name in LimitOptionName]?: LimitOption;
|
|
6
|
-
};
|
|
7
|
-
declare const LIMIT_OPTION_NAMES: {
|
|
8
|
-
readonly REGISTERED: "registeredLimit";
|
|
9
|
-
readonly UNREGISTERED: "unregisteredLimit";
|
|
10
|
-
};
|
|
1
|
+
import type { Server } from '@hapi/hapi';
|
|
2
|
+
import { z } from 'zod/mini';
|
|
3
|
+
export type TerminatorRouteOptions = z.infer<typeof TerminatorRouteOptionsSchema>;
|
|
4
|
+
export type TerminatorOptions = z.infer<typeof TerminatorOptionsSchema>;
|
|
11
5
|
export declare const plugin: {
|
|
12
6
|
pkg: {
|
|
13
7
|
name: string;
|
|
@@ -34,7 +28,7 @@ export declare const plugin: {
|
|
|
34
28
|
"typescript-eslint": string;
|
|
35
29
|
};
|
|
36
30
|
dependencies: {
|
|
37
|
-
|
|
31
|
+
zod: string;
|
|
38
32
|
};
|
|
39
33
|
prettier: string;
|
|
40
34
|
"lint-staged": {
|
|
@@ -59,7 +53,16 @@ export declare const plugin: {
|
|
|
59
53
|
};
|
|
60
54
|
register: typeof register;
|
|
61
55
|
};
|
|
62
|
-
declare
|
|
56
|
+
declare const TerminatorRouteOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
|
|
57
|
+
"hapi-terminator": z.ZodMiniObject<{
|
|
58
|
+
limit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
59
|
+
}, z.core.$strip>;
|
|
60
|
+
}, z.core.$strip>>>;
|
|
61
|
+
declare const TerminatorOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
|
|
62
|
+
registeredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
63
|
+
unregisteredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
64
|
+
}, z.core.$strip>>>;
|
|
65
|
+
declare function register(server: Server, rawOptions: TerminatorOptions): Promise<void>;
|
|
63
66
|
declare const _default: {
|
|
64
67
|
plugin: {
|
|
65
68
|
pkg: {
|
|
@@ -87,7 +90,7 @@ declare const _default: {
|
|
|
87
90
|
"typescript-eslint": string;
|
|
88
91
|
};
|
|
89
92
|
dependencies: {
|
|
90
|
-
|
|
93
|
+
zod: string;
|
|
91
94
|
};
|
|
92
95
|
prettier: string;
|
|
93
96
|
"lint-staged": {
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { z } from 'zod/mini';
|
|
2
3
|
import pkg from './package.json';
|
|
3
4
|
const LIMIT_OPTION_NAMES = {
|
|
4
5
|
REGISTERED: 'registeredLimit',
|
|
5
6
|
UNREGISTERED: 'unregisteredLimit',
|
|
6
7
|
};
|
|
8
|
+
const PACKAGE_NAME = 'hapi-terminator';
|
|
9
|
+
assert(PACKAGE_NAME === pkg.name);
|
|
7
10
|
export const plugin = { pkg, register };
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
const LimitOptionShape = z.nullish(z.number().check(z.minimum(0)));
|
|
12
|
+
const TerminatorRouteOptionsSchema = z.nullish(z.object({ [PACKAGE_NAME]: z.object({ limit: LimitOptionShape }) }));
|
|
13
|
+
const TerminatorOptionsSchema = z.nullish(z.object({ registeredLimit: LimitOptionShape, unregisteredLimit: LimitOptionShape }));
|
|
14
|
+
async function register(server, rawOptions) {
|
|
15
|
+
const options = TerminatorOptionsSchema.parse(rawOptions);
|
|
16
|
+
const routeOptionsCache = new Map();
|
|
17
|
+
server.ext('onRequest', validateHookHandler(options, routeOptionsCache));
|
|
10
18
|
}
|
|
11
|
-
function
|
|
19
|
+
function validateHookHandler(pluginOptions, routeOptionsCache) {
|
|
12
20
|
return (request, h) => {
|
|
13
|
-
const handler = validateRoute(request, h,
|
|
21
|
+
const handler = validateRoute(request, h, pluginOptions);
|
|
14
22
|
const rawContentLength = request.headers['content-length'];
|
|
15
23
|
if (!rawContentLength) {
|
|
16
24
|
return h.continue;
|
|
@@ -19,29 +27,56 @@ function onRequest(options) {
|
|
|
19
27
|
if (Number.isNaN(contentLength)) {
|
|
20
28
|
return h.continue;
|
|
21
29
|
}
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
return handler(contentLength, LIMIT_OPTION_NAMES.REGISTERED, option => {
|
|
25
|
-
|
|
30
|
+
const { route, options } = getRouteAndOptions(request, routeOptionsCache) ?? {};
|
|
31
|
+
if (route != null) {
|
|
32
|
+
return handler(contentLength, LIMIT_OPTION_NAMES.REGISTERED, options, option => {
|
|
33
|
+
return h
|
|
34
|
+
.response({
|
|
35
|
+
error: 'Request Entity Too Large',
|
|
36
|
+
message: `Payload content length greater than maximum allowed: ${option}`,
|
|
37
|
+
statusCode: 413,
|
|
38
|
+
})
|
|
39
|
+
.code(413);
|
|
26
40
|
});
|
|
27
41
|
}
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
assert(options == null, "Unregistered routes can't have route options");
|
|
43
|
+
return handler(contentLength, LIMIT_OPTION_NAMES.UNREGISTERED, null, () => {
|
|
44
|
+
return h.response({ error: 'Not Found', message: 'Not Found', statusCode: 404 }).code(404);
|
|
30
45
|
});
|
|
31
46
|
};
|
|
32
47
|
}
|
|
48
|
+
function getRouteAndOptions(request, routeOptionsCache) {
|
|
49
|
+
const matchedRoute = request.server.match(request.method, request.path, request.info.host);
|
|
50
|
+
if (matchedRoute == null) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const cacheKey = `${matchedRoute.method}-${matchedRoute.path}`;
|
|
54
|
+
const cachedResult = routeOptionsCache.get(cacheKey);
|
|
55
|
+
if (cachedResult != null) {
|
|
56
|
+
return { options: cachedResult, route: matchedRoute };
|
|
57
|
+
}
|
|
58
|
+
const options = getRoutePluginSettings(matchedRoute);
|
|
59
|
+
routeOptionsCache.set(cacheKey, options);
|
|
60
|
+
return { options, route: matchedRoute };
|
|
61
|
+
}
|
|
33
62
|
function validateRoute(request, h, options) {
|
|
34
|
-
return (contentLength, optionName,
|
|
35
|
-
const option = options[optionName];
|
|
36
|
-
|
|
63
|
+
return (contentLength, optionName, routeOptions, response) => {
|
|
64
|
+
const option = options?.[optionName];
|
|
65
|
+
const limit = routeOptions?.[PACKAGE_NAME]?.limit ?? option;
|
|
66
|
+
if (limit == null) {
|
|
37
67
|
return h.continue;
|
|
38
68
|
}
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
request.raw.req.socket?.destroy();
|
|
42
|
-
throwError(option);
|
|
69
|
+
if (contentLength <= limit) {
|
|
70
|
+
return h.continue;
|
|
43
71
|
}
|
|
44
|
-
|
|
72
|
+
const result = response(limit).takeover();
|
|
73
|
+
request.raw.res.once('finish', () => {
|
|
74
|
+
request.raw.req.socket.end();
|
|
75
|
+
});
|
|
76
|
+
return result;
|
|
45
77
|
};
|
|
46
78
|
}
|
|
79
|
+
function getRoutePluginSettings(matchedRoute) {
|
|
80
|
+
return TerminatorRouteOptionsSchema.safeParse(matchedRoute?.settings.plugins).data;
|
|
81
|
+
}
|
|
47
82
|
export default { plugin };
|
package/dist/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"main": "dist/index.js",
|
|
4
4
|
"typings": "dist/index.d.ts",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"version": "0.0
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Kamaal Farah"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"typescript-eslint": "^8.52.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"
|
|
26
|
+
"zod": "^4.3.5"
|
|
27
27
|
},
|
|
28
28
|
"prettier": "@kamaalio/prettier-config",
|
|
29
29
|
"lint-staged": {
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"main": "dist/index.js",
|
|
4
4
|
"typings": "dist/index.d.ts",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"version": "0.0
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Kamaal Farah"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"typescript-eslint": "^8.52.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"
|
|
26
|
+
"zod": "^4.3.5"
|
|
27
27
|
},
|
|
28
28
|
"prettier": "@kamaalio/prettier-config",
|
|
29
29
|
"lint-staged": {
|