hapi-terminator 0.1.0 → 0.3.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 +54 -9
- package/dist/index.d.ts +1 -3
- package/dist/index.js +32 -12
- package/dist/package.json +1 -2
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
|
@@ -108,14 +108,56 @@ server.route({
|
|
|
108
108
|
await server.start();
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
+
### Boolean Limits for Unregistered Routes
|
|
112
|
+
|
|
113
|
+
You can use boolean values for `unregisteredLimit` to control unregistered route behavior:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import Hapi from '@hapi/hapi';
|
|
117
|
+
import terminatorPlugin, { type TerminatorOptions } from 'hapi-terminator';
|
|
118
|
+
|
|
119
|
+
const server = Hapi.server({ port: 3000, host: '127.0.0.1' });
|
|
120
|
+
|
|
121
|
+
// Reject all unregistered routes immediately
|
|
122
|
+
await server.register({
|
|
123
|
+
plugin: terminatorPlugin,
|
|
124
|
+
options: {
|
|
125
|
+
registeredLimit: 1024 * 1024, // 1MB for registered routes
|
|
126
|
+
unregisteredLimit: true, // Immediately reject all unregistered routes
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// This route will work normally
|
|
131
|
+
server.route({
|
|
132
|
+
method: ['POST'],
|
|
133
|
+
path: '/api/data',
|
|
134
|
+
handler: () => ({ success: true }),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Any request to unregistered routes (e.g., /unknown) will be rejected immediately
|
|
138
|
+
await server.start();
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
You can also set `unregisteredLimit` to `false` to bypass payload size checks for unregistered routes:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
await server.register({
|
|
145
|
+
plugin: terminatorPlugin,
|
|
146
|
+
options: {
|
|
147
|
+
registeredLimit: 500 * 1024, // 500KB for registered routes
|
|
148
|
+
unregisteredLimit: false, // Bypass payload size checks for unregistered routes
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
111
153
|
## Configuration
|
|
112
154
|
|
|
113
155
|
### TerminatorOptions
|
|
114
156
|
|
|
115
|
-
| Option | Type
|
|
116
|
-
| ------------------- |
|
|
117
|
-
| `registeredLimit` | `number`
|
|
118
|
-
| `unregisteredLimit` | `number` | Maximum payload size in bytes for unregistered routes. Must be >= 0. Set to `null` or `undefined` to disable. |
|
|
157
|
+
| Option | Type | Description |
|
|
158
|
+
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
159
|
+
| `registeredLimit` | `number` | Maximum payload size in bytes for registered routes. Must be >= 0. Set to `null` or `undefined` to disable. |
|
|
160
|
+
| `unregisteredLimit` | `number \| boolean` | Maximum payload size in bytes for unregistered routes. Must be >= 0. Set to `null` or `undefined` to disable. Set to `true` to reject all requests immediately. Set to `false` to bypass payload size checks. |
|
|
119
161
|
|
|
120
162
|
### TerminatorRouteOptions
|
|
121
163
|
|
|
@@ -127,17 +169,20 @@ You can configure per-route limits using the route options:
|
|
|
127
169
|
|
|
128
170
|
### Behavior
|
|
129
171
|
|
|
130
|
-
- **Registered Routes**: When a payload exceeds the limit on a registered route, the socket is
|
|
131
|
-
- **Unregistered Routes**: When a payload exceeds the limit on an unregistered route, the socket is
|
|
172
|
+
- **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.
|
|
173
|
+
- **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
174
|
- **Per-Route Limits**: Route-specific limits take precedence over global limits, allowing you to customize limits for individual routes.
|
|
133
175
|
- **Disabled**: Set to `null` or `undefined` to disable termination for that category or route.
|
|
176
|
+
- **Boolean Values for Unregistered Routes**:
|
|
177
|
+
- Set `unregisteredLimit` to `true` to immediately reject all unregistered route requests regardless of Content-Length (even 0 bytes).
|
|
178
|
+
- Set `unregisteredLimit` to `false` to bypass payload size checks for unregistered route requests (they will still receive 404 responses).
|
|
134
179
|
|
|
135
180
|
## How It Works
|
|
136
181
|
|
|
137
182
|
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:
|
|
138
183
|
|
|
139
|
-
1.
|
|
140
|
-
2.
|
|
184
|
+
1. An appropriate error response is returned (413 for registered routes, 404 for unregistered routes)
|
|
185
|
+
2. The socket connection is gracefully ended after the response is sent
|
|
141
186
|
3. No further processing occurs, saving server resources
|
|
142
187
|
|
|
143
188
|
## License
|
package/dist/index.d.ts
CHANGED
|
@@ -28,7 +28,6 @@ export declare const plugin: {
|
|
|
28
28
|
"typescript-eslint": string;
|
|
29
29
|
};
|
|
30
30
|
dependencies: {
|
|
31
|
-
"@hapi/boom": string;
|
|
32
31
|
zod: string;
|
|
33
32
|
};
|
|
34
33
|
prettier: string;
|
|
@@ -61,7 +60,7 @@ declare const TerminatorRouteOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<
|
|
|
61
60
|
}, z.core.$strip>>>;
|
|
62
61
|
declare const TerminatorOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
|
|
63
62
|
registeredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
64
|
-
unregisteredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number
|
|
63
|
+
unregisteredLimit: z.ZodMiniUnion<readonly [z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>, z.ZodMiniBoolean<boolean>]>;
|
|
65
64
|
}, z.core.$strip>>>;
|
|
66
65
|
declare function register(server: Server, rawOptions: TerminatorOptions): Promise<void>;
|
|
67
66
|
declare const _default: {
|
|
@@ -91,7 +90,6 @@ declare const _default: {
|
|
|
91
90
|
"typescript-eslint": string;
|
|
92
91
|
};
|
|
93
92
|
dependencies: {
|
|
94
|
-
"@hapi/boom": string;
|
|
95
93
|
zod: string;
|
|
96
94
|
};
|
|
97
95
|
prettier: string;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import Boom from '@hapi/boom';
|
|
3
2
|
import { z } from 'zod/mini';
|
|
4
3
|
import pkg from './package.json';
|
|
5
4
|
const LIMIT_OPTION_NAMES = {
|
|
@@ -9,9 +8,11 @@ const LIMIT_OPTION_NAMES = {
|
|
|
9
8
|
const PACKAGE_NAME = 'hapi-terminator';
|
|
10
9
|
assert(PACKAGE_NAME === pkg.name);
|
|
11
10
|
export const plugin = { pkg, register };
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
11
|
+
const TerminatorRouteOptionsSchema = z.nullish(z.object({ [PACKAGE_NAME]: z.object({ limit: z.nullish(z.number().check(z.minimum(0))) }) }));
|
|
12
|
+
const TerminatorOptionsSchema = z.nullish(z.object({
|
|
13
|
+
registeredLimit: z.nullish(z.number().check(z.minimum(0))),
|
|
14
|
+
unregisteredLimit: z.union([z.nullish(z.number().check(z.minimum(0))), z.boolean()]),
|
|
15
|
+
}));
|
|
15
16
|
async function register(server, rawOptions) {
|
|
16
17
|
const options = TerminatorOptionsSchema.parse(rawOptions);
|
|
17
18
|
const routeOptionsCache = new Map();
|
|
@@ -31,17 +32,23 @@ function validateHookHandler(pluginOptions, routeOptionsCache) {
|
|
|
31
32
|
const { route, options } = getRouteAndOptions(request, routeOptionsCache) ?? {};
|
|
32
33
|
if (route != null) {
|
|
33
34
|
return handler(contentLength, LIMIT_OPTION_NAMES.REGISTERED, options, option => {
|
|
34
|
-
|
|
35
|
+
return h
|
|
36
|
+
.response({
|
|
37
|
+
error: 'Request Entity Too Large',
|
|
38
|
+
message: `Payload content length greater than maximum allowed: ${option}`,
|
|
39
|
+
statusCode: 413,
|
|
40
|
+
})
|
|
41
|
+
.code(413);
|
|
35
42
|
});
|
|
36
43
|
}
|
|
37
44
|
assert(options == null, "Unregistered routes can't have route options");
|
|
38
45
|
return handler(contentLength, LIMIT_OPTION_NAMES.UNREGISTERED, null, () => {
|
|
39
|
-
|
|
46
|
+
return h.response({ error: 'Not Found', message: 'Not Found', statusCode: 404 }).code(404);
|
|
40
47
|
});
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
function getRouteAndOptions(request, routeOptionsCache) {
|
|
44
|
-
const matchedRoute = request.server.match(request.method, request.path);
|
|
51
|
+
const matchedRoute = request.server.match(request.method, request.path, request.info.host);
|
|
45
52
|
if (matchedRoute == null) {
|
|
46
53
|
return null;
|
|
47
54
|
}
|
|
@@ -55,17 +62,30 @@ function getRouteAndOptions(request, routeOptionsCache) {
|
|
|
55
62
|
return { options, route: matchedRoute };
|
|
56
63
|
}
|
|
57
64
|
function validateRoute(request, h, options) {
|
|
58
|
-
return (contentLength, optionName, routeOptions,
|
|
65
|
+
return (contentLength, optionName, routeOptions, response) => {
|
|
59
66
|
const option = options?.[optionName];
|
|
60
67
|
const limit = routeOptions?.[PACKAGE_NAME]?.limit ?? option;
|
|
61
68
|
if (limit == null) {
|
|
62
69
|
return h.continue;
|
|
63
70
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
if (limit === false) {
|
|
72
|
+
return h.continue;
|
|
73
|
+
}
|
|
74
|
+
if (limit === true) {
|
|
75
|
+
const result = response(0).takeover();
|
|
76
|
+
request.raw.res.once('finish', () => {
|
|
77
|
+
request.raw.req.socket.end();
|
|
78
|
+
});
|
|
79
|
+
return result;
|
|
67
80
|
}
|
|
68
|
-
|
|
81
|
+
if (contentLength <= limit) {
|
|
82
|
+
return h.continue;
|
|
83
|
+
}
|
|
84
|
+
const result = response(limit).takeover();
|
|
85
|
+
request.raw.res.once('finish', () => {
|
|
86
|
+
request.raw.req.socket.end();
|
|
87
|
+
});
|
|
88
|
+
return result;
|
|
69
89
|
};
|
|
70
90
|
}
|
|
71
91
|
function getRoutePluginSettings(matchedRoute) {
|
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.
|
|
6
|
+
"version": "0.3.0",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Kamaal Farah"
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"typescript-eslint": "^8.52.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hapi/boom": "^10.0.1",
|
|
27
26
|
"zod": "^4.3.5"
|
|
28
27
|
},
|
|
29
28
|
"prettier": "@kamaalio/prettier-config",
|
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.
|
|
6
|
+
"version": "0.3.0",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Kamaal Farah"
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"typescript-eslint": "^8.52.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hapi/boom": "^10.0.1",
|
|
27
26
|
"zod": "^4.3.5"
|
|
28
27
|
},
|
|
29
28
|
"prettier": "@kamaalio/prettier-config",
|