hapi-terminator 0.0.5 → 0.1.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 +62 -18
- package/dist/index.d.ts +16 -6
- package/dist/index.js +49 -31
- package/dist/package.json +3 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A Hapi plugin that terminates requests with payloads that exceed a specified siz
|
|
|
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,39 +54,83 @@ 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
130
|
- **Registered Routes**: When a payload exceeds the limit on a registered route, the socket is destroyed and a `413 Payload Too Large` error is thrown.
|
|
88
131
|
- **Unregistered Routes**: When a payload exceeds the limit on an unregistered route, the socket is destroyed and a `404 Not Found` error is thrown.
|
|
89
|
-
- **
|
|
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
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type { Server
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
};
|
|
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>;
|
|
6
5
|
export declare const plugin: {
|
|
7
6
|
pkg: {
|
|
8
7
|
name: string;
|
|
@@ -30,6 +29,7 @@ export declare const plugin: {
|
|
|
30
29
|
};
|
|
31
30
|
dependencies: {
|
|
32
31
|
"@hapi/boom": string;
|
|
32
|
+
zod: string;
|
|
33
33
|
};
|
|
34
34
|
prettier: string;
|
|
35
35
|
"lint-staged": {
|
|
@@ -54,7 +54,16 @@ export declare const plugin: {
|
|
|
54
54
|
};
|
|
55
55
|
register: typeof register;
|
|
56
56
|
};
|
|
57
|
-
declare
|
|
57
|
+
declare const TerminatorRouteOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
|
|
58
|
+
"hapi-terminator": z.ZodMiniObject<{
|
|
59
|
+
limit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
60
|
+
}, z.core.$strip>;
|
|
61
|
+
}, z.core.$strip>>>;
|
|
62
|
+
declare const TerminatorOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
|
|
63
|
+
registeredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
64
|
+
unregisteredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
65
|
+
}, z.core.$strip>>>;
|
|
66
|
+
declare function register(server: Server, rawOptions: TerminatorOptions): Promise<void>;
|
|
58
67
|
declare const _default: {
|
|
59
68
|
plugin: {
|
|
60
69
|
pkg: {
|
|
@@ -83,6 +92,7 @@ declare const _default: {
|
|
|
83
92
|
};
|
|
84
93
|
dependencies: {
|
|
85
94
|
"@hapi/boom": string;
|
|
95
|
+
zod: string;
|
|
86
96
|
};
|
|
87
97
|
prettier: string;
|
|
88
98
|
"lint-staged": {
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
1
2
|
import Boom from '@hapi/boom';
|
|
3
|
+
import { z } from 'zod/mini';
|
|
2
4
|
import pkg from './package.json';
|
|
5
|
+
const LIMIT_OPTION_NAMES = {
|
|
6
|
+
REGISTERED: 'registeredLimit',
|
|
7
|
+
UNREGISTERED: 'unregisteredLimit',
|
|
8
|
+
};
|
|
9
|
+
const PACKAGE_NAME = 'hapi-terminator';
|
|
10
|
+
assert(PACKAGE_NAME === pkg.name);
|
|
3
11
|
export const plugin = { pkg, register };
|
|
4
|
-
|
|
5
|
-
|
|
12
|
+
const LimitOptionShape = z.nullish(z.number().check(z.minimum(0)));
|
|
13
|
+
const TerminatorRouteOptionsSchema = z.nullish(z.object({ [PACKAGE_NAME]: z.object({ limit: LimitOptionShape }) }));
|
|
14
|
+
const TerminatorOptionsSchema = z.nullish(z.object({ registeredLimit: LimitOptionShape, unregisteredLimit: LimitOptionShape }));
|
|
15
|
+
async function register(server, rawOptions) {
|
|
16
|
+
const options = TerminatorOptionsSchema.parse(rawOptions);
|
|
17
|
+
const routeOptionsCache = new Map();
|
|
18
|
+
server.ext('onRequest', validateHookHandler(options, routeOptionsCache));
|
|
6
19
|
}
|
|
7
|
-
function
|
|
20
|
+
function validateHookHandler(pluginOptions, routeOptionsCache) {
|
|
8
21
|
return (request, h) => {
|
|
9
|
-
const
|
|
10
|
-
const registeredRouteHandler = handleRegisteredRoute(request, h, options);
|
|
22
|
+
const handler = validateRoute(request, h, pluginOptions);
|
|
11
23
|
const rawContentLength = request.headers['content-length'];
|
|
12
24
|
if (!rawContentLength) {
|
|
13
25
|
return h.continue;
|
|
@@ -16,41 +28,47 @@ function onRequest(options) {
|
|
|
16
28
|
if (Number.isNaN(contentLength)) {
|
|
17
29
|
return h.continue;
|
|
18
30
|
}
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
return
|
|
31
|
+
const { route, options } = getRouteAndOptions(request, routeOptionsCache) ?? {};
|
|
32
|
+
if (route != null) {
|
|
33
|
+
return handler(contentLength, LIMIT_OPTION_NAMES.REGISTERED, options, option => {
|
|
34
|
+
throw Boom.entityTooLarge(`Payload content length greater than maximum allowed: ${option}`);
|
|
35
|
+
});
|
|
22
36
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
function handleUnregisteredRoute(request, h, options) {
|
|
27
|
-
return (contentLength) => {
|
|
28
|
-
if (options.unregisteredLimit == null ||
|
|
29
|
-
(typeof options.unregisteredLimit === 'number' && options.unregisteredLimit < 0)) {
|
|
30
|
-
return h.continue;
|
|
31
|
-
}
|
|
32
|
-
if ((typeof options.unregisteredLimit === 'number' && contentLength > options.unregisteredLimit) ||
|
|
33
|
-
(typeof options.unregisteredLimit === 'function' && options.unregisteredLimit(request, contentLength))) {
|
|
34
|
-
request.raw.req.socket?.destroy();
|
|
37
|
+
assert(options == null, "Unregistered routes can't have route options");
|
|
38
|
+
return handler(contentLength, LIMIT_OPTION_NAMES.UNREGISTERED, null, () => {
|
|
35
39
|
throw Boom.notFound();
|
|
36
|
-
}
|
|
37
|
-
return h.continue;
|
|
40
|
+
});
|
|
38
41
|
};
|
|
39
42
|
}
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
function getRouteAndOptions(request, routeOptionsCache) {
|
|
44
|
+
const matchedRoute = request.server.match(request.method, request.path);
|
|
45
|
+
if (matchedRoute == null) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const cacheKey = `${matchedRoute.method}-${matchedRoute.path}`;
|
|
49
|
+
const cachedResult = routeOptionsCache.get(cacheKey);
|
|
50
|
+
if (cachedResult != null) {
|
|
51
|
+
return { options: cachedResult, route: matchedRoute };
|
|
52
|
+
}
|
|
53
|
+
const options = getRoutePluginSettings(matchedRoute);
|
|
54
|
+
routeOptionsCache.set(cacheKey, options);
|
|
55
|
+
return { options, route: matchedRoute };
|
|
56
|
+
}
|
|
57
|
+
function validateRoute(request, h, options) {
|
|
58
|
+
return (contentLength, optionName, routeOptions, throwError) => {
|
|
59
|
+
const option = options?.[optionName];
|
|
60
|
+
const limit = routeOptions?.[PACKAGE_NAME]?.limit ?? option;
|
|
61
|
+
if (limit == null) {
|
|
44
62
|
return h.continue;
|
|
45
63
|
}
|
|
46
|
-
if (
|
|
47
|
-
(typeof options.registeredLimit === 'function' && options.registeredLimit(request, contentLength))) {
|
|
64
|
+
if (contentLength > limit) {
|
|
48
65
|
request.raw.req.socket?.destroy();
|
|
49
|
-
|
|
50
|
-
? `Payload content length greater than maximum allowed: ${options.registeredLimit}`
|
|
51
|
-
: undefined);
|
|
66
|
+
throwError(limit);
|
|
52
67
|
}
|
|
53
68
|
return h.continue;
|
|
54
69
|
};
|
|
55
70
|
}
|
|
71
|
+
function getRoutePluginSettings(matchedRoute) {
|
|
72
|
+
return TerminatorRouteOptionsSchema.safeParse(matchedRoute?.settings.plugins).data;
|
|
73
|
+
}
|
|
56
74
|
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.1.0",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Kamaal Farah"
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"typescript-eslint": "^8.52.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hapi/boom": "^10.0.1"
|
|
26
|
+
"@hapi/boom": "^10.0.1",
|
|
27
|
+
"zod": "^4.3.5"
|
|
27
28
|
},
|
|
28
29
|
"prettier": "@kamaalio/prettier-config",
|
|
29
30
|
"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.1.0",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Kamaal Farah"
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"typescript-eslint": "^8.52.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hapi/boom": "^10.0.1"
|
|
26
|
+
"@hapi/boom": "^10.0.1",
|
|
27
|
+
"zod": "^4.3.5"
|
|
27
28
|
},
|
|
28
29
|
"prettier": "@kamaalio/prettier-config",
|
|
29
30
|
"lint-staged": {
|