@thi.ng/server 0.3.0 → 0.4.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/CHANGELOG.md +31 -1
- package/README.md +9 -4
- package/api.d.ts +11 -5
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/interceptors/logging.js +3 -5
- package/interceptors/measure.d.ts +9 -0
- package/interceptors/measure.js +27 -0
- package/interceptors/rate-limit.d.ts +73 -0
- package/interceptors/rate-limit.js +82 -0
- package/package.json +12 -5
- package/server.d.ts +6 -0
- package/server.js +25 -15
- package/session/session.d.ts +34 -9
- package/session/session.js +56 -9
- package/utils/host.js +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
-
- **Last updated**: 2025-02-
|
|
3
|
+
- **Last updated**: 2025-02-10T21:44:04Z
|
|
4
4
|
- **Generator**: [thi.ng/monopub](https://thi.ng/monopub)
|
|
5
5
|
|
|
6
6
|
All notable changes to this project will be documented in this file.
|
|
@@ -11,6 +11,36 @@ See [Conventional Commits](https://conventionalcommits.org/) for commit guidelin
|
|
|
11
11
|
**Note:** Unlisted _patch_ versions only involve non-code or otherwise excluded changes
|
|
12
12
|
and/or version bumps of transitive dependencies.
|
|
13
13
|
|
|
14
|
+
## [0.4.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.4.0) (2025-02-10)
|
|
15
|
+
|
|
16
|
+
#### 🚀 Features
|
|
17
|
+
|
|
18
|
+
- update ServerSession & interceptor ([71d26bb](https://github.com/thi-ng/umbrella/commit/71d26bb))
|
|
19
|
+
- add `ServerSession.ip`
|
|
20
|
+
- update `SessionInterceptor` to validate stored IP addr
|
|
21
|
+
- rename `.delete()` => `.deleteSession()`
|
|
22
|
+
- add `.replaceSession()`
|
|
23
|
+
- remove obsolete `FlashMsg` (for now)
|
|
24
|
+
- update ServerResponse, update host matching ([25a07f3](https://github.com/thi-ng/umbrella/commit/25a07f3))
|
|
25
|
+
- update `isMatchingHost()`
|
|
26
|
+
- add `ServerResponse.rateLimit()` and `.noResponse()`
|
|
27
|
+
- update tests
|
|
28
|
+
- update SessionInterceptor to create signed cookie ([d240107](https://github.com/thi-ng/umbrella/commit/d240107))
|
|
29
|
+
- add `SessionOpts.secret`
|
|
30
|
+
- sign session ID with salt & SHA256
|
|
31
|
+
- add validateSession()
|
|
32
|
+
- update pre() interceptor
|
|
33
|
+
- add `rateLimiter()` interceptor ([245cc9d](https://github.com/thi-ng/umbrella/commit/245cc9d))
|
|
34
|
+
- add `measure()` interceptor ([4702e84](https://github.com/thi-ng/umbrella/commit/4702e84))
|
|
35
|
+
- refactor logRequest/Response() interceptors
|
|
36
|
+
|
|
37
|
+
#### ⏱ Performance improvements
|
|
38
|
+
|
|
39
|
+
- update Server 404 & OPTIONS handling, remove method override ([71307af](https://github.com/thi-ng/umbrella/commit/71307af))
|
|
40
|
+
- process 404 asap (without full request ctx)
|
|
41
|
+
- process default HTTP OPTIONS handler asap
|
|
42
|
+
- in both cases no interceptors will be run anymore
|
|
43
|
+
|
|
14
44
|
## [0.3.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.3.0) (2025-02-02)
|
|
15
45
|
|
|
16
46
|
#### 🚀 Features
|
package/README.md
CHANGED
|
@@ -75,8 +75,10 @@ for more details.
|
|
|
75
75
|
- [`crossOriginOpenerPolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginOpenerPolicy-1.html): Policy header injection
|
|
76
76
|
- [`crossOriginResourcePolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginResourcePolicy-1.html): Policy header injection
|
|
77
77
|
- [`injectHeaders()`](https://docs.thi.ng/umbrella/server/functions/injectHeaders.html): Arbitrary header injection
|
|
78
|
+
- [`measure()`](https://docs.thi.ng/umbrella/server/functions/measure.html): Request process timing info
|
|
78
79
|
- [`logRequest()`](https://docs.thi.ng/umbrella/server/functions/logRequest.html): Request detail logging
|
|
79
80
|
- [`logResponse()`](https://docs.thi.ng/umbrella/server/functions/logResponse.html): Response logging
|
|
81
|
+
- [`rateLimiter()`](https://docs.thi.ng/umbrella/server/functions/rateLimiter-1.html): Configurable rate limiting
|
|
80
82
|
- [`referrerPolicy()`](https://docs.thi.ng/umbrella/server/functions/referrerPolicy-1.html): Policy header injection
|
|
81
83
|
- [`serverSession()`](https://docs.thi.ng/umbrella/server/functions/serverSession-1.html): User defined in-memory sessions with TTL
|
|
82
84
|
- [`strictTransportSecurity()`](https://docs.thi.ng/umbrella/server/functions/strictTransportSecurity.html): Policy header injection
|
|
@@ -147,7 +149,7 @@ For Node.js REPL:
|
|
|
147
149
|
const ser = await import("@thi.ng/server");
|
|
148
150
|
```
|
|
149
151
|
|
|
150
|
-
Package sizes (brotli'd, pre-treeshake): ESM:
|
|
152
|
+
Package sizes (brotli'd, pre-treeshake): ESM: 5.24 KB
|
|
151
153
|
|
|
152
154
|
## Dependencies
|
|
153
155
|
|
|
@@ -162,6 +164,7 @@ Package sizes (brotli'd, pre-treeshake): ESM: 4.02 KB
|
|
|
162
164
|
- [@thi.ng/paths](https://github.com/thi-ng/umbrella/tree/develop/packages/paths)
|
|
163
165
|
- [@thi.ng/router](https://github.com/thi-ng/umbrella/tree/develop/packages/router)
|
|
164
166
|
- [@thi.ng/strings](https://github.com/thi-ng/umbrella/tree/develop/packages/strings)
|
|
167
|
+
- [@thi.ng/timestamp](https://github.com/thi-ng/umbrella/tree/develop/packages/timestamp)
|
|
165
168
|
- [@thi.ng/uuid](https://github.com/thi-ng/umbrella/tree/develop/packages/uuid)
|
|
166
169
|
|
|
167
170
|
Note: @thi.ng/api is in _most_ cases a type-only import (not used at runtime)
|
|
@@ -229,10 +232,12 @@ const app = srv.server<AppCtx>({
|
|
|
229
232
|
const { user, pass } = await srv.parseRequestFormData(ctx.req);
|
|
230
233
|
ctx.logger.info("login details", user, pass);
|
|
231
234
|
if (user === "thi.ng" && pass === "1234") {
|
|
232
|
-
|
|
235
|
+
// create new session for security reasons (session fixation)
|
|
236
|
+
const newSession = await session.replaceSession(ctx)!;
|
|
237
|
+
newSession!.user = user;
|
|
233
238
|
ctx.res.writeHead(200).end("logged in as " + user);
|
|
234
239
|
} else {
|
|
235
|
-
ctx.res.
|
|
240
|
+
ctx.res.unauthorized({}, "login failed");
|
|
236
241
|
}
|
|
237
242
|
},
|
|
238
243
|
},
|
|
@@ -246,7 +251,7 @@ const app = srv.server<AppCtx>({
|
|
|
246
251
|
handlers: {
|
|
247
252
|
get: async (ctx) => {
|
|
248
253
|
// remove session & force expire session cookie
|
|
249
|
-
await session.
|
|
254
|
+
await session.deleteSession(ctx, ctx.session!.id);
|
|
250
255
|
ctx.res.writeHead(200).end("logged out");
|
|
251
256
|
},
|
|
252
257
|
},
|
package/api.d.ts
CHANGED
|
@@ -119,12 +119,18 @@ export interface Interceptor<CTX extends RequestCtx = RequestCtx> {
|
|
|
119
119
|
post?: Fn<CTX, InterceptorResult>;
|
|
120
120
|
}
|
|
121
121
|
export interface ServerSession {
|
|
122
|
+
/**
|
|
123
|
+
* Unique session ID
|
|
124
|
+
*/
|
|
122
125
|
id: string;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Client's remote IP address when session was originally created. To
|
|
128
|
+
* counteract session fixation, each request's remote address is being
|
|
129
|
+
* checked (by {@link SessionInterceptor.pre}) against this stored address.
|
|
130
|
+
* If there's a mismatch between the two, then a new session will be
|
|
131
|
+
* generated automatically.
|
|
132
|
+
*/
|
|
133
|
+
ip: string;
|
|
128
134
|
}
|
|
129
135
|
export interface ISessionStore<T extends ServerSession = ServerSession> {
|
|
130
136
|
/**
|
package/index.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export * from "./interceptors/auth-route.js";
|
|
|
5
5
|
export * from "./interceptors/cache-control.js";
|
|
6
6
|
export * from "./interceptors/inject-headers.js";
|
|
7
7
|
export * from "./interceptors/logging.js";
|
|
8
|
+
export * from "./interceptors/measure.js";
|
|
9
|
+
export * from "./interceptors/rate-limit.js";
|
|
8
10
|
export * from "./interceptors/referrer-policy.js";
|
|
9
11
|
export * from "./interceptors/strict-transport.js";
|
|
10
12
|
export * from "./interceptors/x-origin-opener.js";
|
package/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export * from "./interceptors/auth-route.js";
|
|
|
5
5
|
export * from "./interceptors/cache-control.js";
|
|
6
6
|
export * from "./interceptors/inject-headers.js";
|
|
7
7
|
export * from "./interceptors/logging.js";
|
|
8
|
+
export * from "./interceptors/measure.js";
|
|
9
|
+
export * from "./interceptors/rate-limit.js";
|
|
8
10
|
export * from "./interceptors/referrer-policy.js";
|
|
9
11
|
export * from "./interceptors/strict-transport.js";
|
|
10
12
|
export * from "./interceptors/x-origin-opener.js";
|
package/interceptors/logging.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { LogLevel } from "@thi.ng/logger";
|
|
3
|
-
const __method = (level) => (isString(level) ? level : LogLevel[level]).toLowerCase();
|
|
1
|
+
import { LogLevel, methodForLevel } from "@thi.ng/logger";
|
|
4
2
|
const logRequest = (level = "INFO") => {
|
|
5
|
-
const method =
|
|
3
|
+
const method = methodForLevel(level);
|
|
6
4
|
return {
|
|
7
5
|
pre: ({ logger, req, match, query, cookies }) => {
|
|
8
6
|
logger[method]("request route", req.method, match);
|
|
@@ -14,7 +12,7 @@ const logRequest = (level = "INFO") => {
|
|
|
14
12
|
};
|
|
15
13
|
};
|
|
16
14
|
const logResponse = (level = "INFO") => {
|
|
17
|
-
const method =
|
|
15
|
+
const method = methodForLevel(level);
|
|
18
16
|
return {
|
|
19
17
|
post: ({ logger, match, res }) => {
|
|
20
18
|
logger[method]("response status", res.statusCode, match);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type LogLevel, type LogLevelName } from "@thi.ng/logger";
|
|
2
|
+
import type { Interceptor } from "../api.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pre/post interceptor to measure & log a request's processing time.
|
|
5
|
+
*
|
|
6
|
+
* @param level
|
|
7
|
+
*/
|
|
8
|
+
export declare const measure: (level?: LogLevel | LogLevelName) => Interceptor;
|
|
9
|
+
//# sourceMappingURL=measure.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
methodForLevel
|
|
3
|
+
} from "@thi.ng/logger";
|
|
4
|
+
import { now, timeDiff } from "@thi.ng/timestamp";
|
|
5
|
+
const measure = (level = "DEBUG") => {
|
|
6
|
+
const requests = /* @__PURE__ */ new WeakMap();
|
|
7
|
+
const method = methodForLevel(level);
|
|
8
|
+
return {
|
|
9
|
+
pre: (ctx) => {
|
|
10
|
+
requests.set(ctx, now());
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
post: (ctx) => {
|
|
14
|
+
const t0 = requests.get(ctx);
|
|
15
|
+
if (t0) {
|
|
16
|
+
requests.delete(ctx);
|
|
17
|
+
ctx.logger[method](
|
|
18
|
+
`request processed in: ${timeDiff(t0).toFixed(3)}ms`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export {
|
|
26
|
+
measure
|
|
27
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Fn, Fn2, Maybe } from "@thi.ng/api";
|
|
2
|
+
import { TLRUCache } from "@thi.ng/cache";
|
|
3
|
+
import type { Interceptor, RequestCtx } from "../api.js";
|
|
4
|
+
export interface RateLimitOpts<T extends RequestCtx = RequestCtx> {
|
|
5
|
+
/**
|
|
6
|
+
* Function to produce a unique ID for rate limiting a client. By default
|
|
7
|
+
* uses client's remote IP address. If this function returns undefined, the
|
|
8
|
+
* client will NOT be rate limited.
|
|
9
|
+
*/
|
|
10
|
+
id?: Fn<T, Maybe<string>>;
|
|
11
|
+
/**
|
|
12
|
+
* Function to compute the max number of allowed requests for given client
|
|
13
|
+
* ID and configured {@link RateLimitOpts.period}. If given as number, that
|
|
14
|
+
* limit will be enforced for all identified clients.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Quotas are only computed whenever a new client ID is first encountered or
|
|
18
|
+
* when its previous request records have expired from the cache (after
|
|
19
|
+
* {@link RateLimitOpts.period} seconds).
|
|
20
|
+
*/
|
|
21
|
+
quota: number | Fn2<T, string, number>;
|
|
22
|
+
/**
|
|
23
|
+
* Size of the time window (in secords) to which this rate limiter applies.
|
|
24
|
+
*
|
|
25
|
+
* @defaultValue 900
|
|
26
|
+
*/
|
|
27
|
+
period?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates a new {@link RateLimiter} interceptor.
|
|
31
|
+
*
|
|
32
|
+
* @param opts
|
|
33
|
+
*/
|
|
34
|
+
export declare const rateLimiter: <T extends RequestCtx = RequestCtx>(opts: RateLimitOpts<T>) => RateLimiter<T>;
|
|
35
|
+
/**
|
|
36
|
+
* Configurable, sliding time window-based rate limiter interceptor.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RateLimiter<T extends RequestCtx = RequestCtx> implements Interceptor<T> {
|
|
39
|
+
cache: TLRUCache<string, Timestamps>;
|
|
40
|
+
clientQuota: Fn2<T, string, number>;
|
|
41
|
+
clientID: Fn<T, Maybe<string>>;
|
|
42
|
+
period: number;
|
|
43
|
+
constructor({ id, quota, period }: RateLimitOpts<T>);
|
|
44
|
+
pre(ctx: T): boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Ring-buffer based history of request timestamps (per client).
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* Based on thi.ng/buffers `FIFOBuffer` implementation.
|
|
51
|
+
*
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
declare class Timestamps {
|
|
55
|
+
quota: number;
|
|
56
|
+
buf: number[];
|
|
57
|
+
rpos: number;
|
|
58
|
+
wpos: number;
|
|
59
|
+
num: number;
|
|
60
|
+
constructor(quota: number);
|
|
61
|
+
/**
|
|
62
|
+
* Removes any timestamps older than `threshold` from the buffer and returns
|
|
63
|
+
* remaining number of timestamps.
|
|
64
|
+
*
|
|
65
|
+
* @param threshold
|
|
66
|
+
*/
|
|
67
|
+
expire(threshold: number): number;
|
|
68
|
+
peek(): number;
|
|
69
|
+
writable(): boolean;
|
|
70
|
+
write(x: number): boolean;
|
|
71
|
+
}
|
|
72
|
+
export {};
|
|
73
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { TLRUCache } from "@thi.ng/cache";
|
|
2
|
+
import { isNumber } from "@thi.ng/checks";
|
|
3
|
+
const rateLimiter = (opts) => new RateLimiter(opts);
|
|
4
|
+
class RateLimiter {
|
|
5
|
+
cache;
|
|
6
|
+
clientQuota;
|
|
7
|
+
clientID;
|
|
8
|
+
period;
|
|
9
|
+
constructor({ id, quota, period = 15 * 60 }) {
|
|
10
|
+
this.clientID = id ?? ((ctx) => ctx.req.socket.remoteAddress);
|
|
11
|
+
this.clientQuota = isNumber(quota) ? () => quota : quota;
|
|
12
|
+
this.period = (period ?? 15 * 60) * 1e3;
|
|
13
|
+
this.cache = new TLRUCache(null, {
|
|
14
|
+
ttl: this.period,
|
|
15
|
+
autoExtend: true
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
pre(ctx) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const id = this.clientID(ctx);
|
|
21
|
+
if (!id) return true;
|
|
22
|
+
let timestamps = this.cache.get(id);
|
|
23
|
+
if (timestamps) {
|
|
24
|
+
if (timestamps.expire(now - this.period) >= timestamps.quota) {
|
|
25
|
+
ctx.res.rateLimit(
|
|
26
|
+
{},
|
|
27
|
+
`Try again @ ${new Date(
|
|
28
|
+
timestamps.peek() + this.period
|
|
29
|
+
).toISOString()}`
|
|
30
|
+
);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
timestamps = new Timestamps(this.clientQuota(ctx, id));
|
|
35
|
+
this.cache.set(id, timestamps);
|
|
36
|
+
}
|
|
37
|
+
timestamps.write(now);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
class Timestamps {
|
|
42
|
+
constructor(quota) {
|
|
43
|
+
this.quota = quota;
|
|
44
|
+
}
|
|
45
|
+
buf = [];
|
|
46
|
+
rpos = 0;
|
|
47
|
+
wpos = 0;
|
|
48
|
+
num = 0;
|
|
49
|
+
/**
|
|
50
|
+
* Removes any timestamps older than `threshold` from the buffer and returns
|
|
51
|
+
* remaining number of timestamps.
|
|
52
|
+
*
|
|
53
|
+
* @param threshold
|
|
54
|
+
*/
|
|
55
|
+
expire(threshold) {
|
|
56
|
+
let { buf, rpos, num } = this;
|
|
57
|
+
const max = buf.length;
|
|
58
|
+
while (num && buf[rpos] < threshold) {
|
|
59
|
+
rpos = (rpos + 1) % max;
|
|
60
|
+
num--;
|
|
61
|
+
}
|
|
62
|
+
this.rpos = rpos;
|
|
63
|
+
return this.num = num;
|
|
64
|
+
}
|
|
65
|
+
peek() {
|
|
66
|
+
return this.buf[this.rpos];
|
|
67
|
+
}
|
|
68
|
+
writable() {
|
|
69
|
+
return this.num < this.quota;
|
|
70
|
+
}
|
|
71
|
+
write(x) {
|
|
72
|
+
const { buf, wpos } = this;
|
|
73
|
+
buf[wpos] = x;
|
|
74
|
+
this.wpos = (wpos + 1) % this.quota;
|
|
75
|
+
this.num++;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
RateLimiter,
|
|
81
|
+
rateLimiter
|
|
82
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thi.ng/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Minimal HTTP server with declarative routing, static file serving and freely extensible via pre/post interceptors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./index.js",
|
|
@@ -44,12 +44,13 @@
|
|
|
44
44
|
"@thi.ng/cache": "^2.3.22",
|
|
45
45
|
"@thi.ng/checks": "^3.6.22",
|
|
46
46
|
"@thi.ng/errors": "^2.5.25",
|
|
47
|
-
"@thi.ng/file-io": "^2.1.
|
|
48
|
-
"@thi.ng/logger": "^3.0
|
|
49
|
-
"@thi.ng/mime": "^2.7.
|
|
47
|
+
"@thi.ng/file-io": "^2.1.26",
|
|
48
|
+
"@thi.ng/logger": "^3.1.0",
|
|
49
|
+
"@thi.ng/mime": "^2.7.1",
|
|
50
50
|
"@thi.ng/paths": "^5.2.0",
|
|
51
51
|
"@thi.ng/router": "^4.1.17",
|
|
52
52
|
"@thi.ng/strings": "^3.9.4",
|
|
53
|
+
"@thi.ng/timestamp": "^1.1.4",
|
|
53
54
|
"@thi.ng/uuid": "^1.1.16"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
@@ -112,6 +113,12 @@
|
|
|
112
113
|
"./interceptors/logging": {
|
|
113
114
|
"default": "./interceptors/logging.js"
|
|
114
115
|
},
|
|
116
|
+
"./interceptors/measure": {
|
|
117
|
+
"default": "./interceptors/measure.js"
|
|
118
|
+
},
|
|
119
|
+
"./interceptors/rate-limit": {
|
|
120
|
+
"default": "./interceptors/rate-limit.js"
|
|
121
|
+
},
|
|
115
122
|
"./interceptors/referrer-policy": {
|
|
116
123
|
"default": "./interceptors/referrer-policy.js"
|
|
117
124
|
},
|
|
@@ -156,5 +163,5 @@
|
|
|
156
163
|
"status": "alpha",
|
|
157
164
|
"year": 2024
|
|
158
165
|
},
|
|
159
|
-
"gitHead": "
|
|
166
|
+
"gitHead": "dcc1dbfa6eae31ac65e12843987b94d4a7edc144\n"
|
|
160
167
|
}
|
package/server.d.ts
CHANGED
|
@@ -35,5 +35,11 @@ export declare class ServerResponse extends http.ServerResponse<http.IncomingMes
|
|
|
35
35
|
missing(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
36
36
|
notAllowed(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
37
37
|
notAcceptable(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
38
|
+
rateLimit(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
39
|
+
/**
|
|
40
|
+
* HTTP 444. Indicates the server has returned no information to the client and closed
|
|
41
|
+
* the connection (useful as a deterrent for malware)
|
|
42
|
+
*/
|
|
43
|
+
noResponse(): void;
|
|
38
44
|
}
|
|
39
45
|
//# sourceMappingURL=server.d.ts.map
|
package/server.js
CHANGED
|
@@ -79,16 +79,27 @@ class Server {
|
|
|
79
79
|
async listener(req, res) {
|
|
80
80
|
try {
|
|
81
81
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
82
|
-
if (this.opts.host && !isMatchingHost(url.hostname, this.
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
if (this.opts.host && !isMatchingHost(url.hostname, this.host)) {
|
|
83
|
+
this.logger.debug(
|
|
84
|
+
"ignoring request, host mismatch:",
|
|
85
|
+
url.hostname,
|
|
86
|
+
this.host
|
|
87
|
+
);
|
|
88
|
+
return res.noResponse();
|
|
85
89
|
}
|
|
86
90
|
const path = decodeURIComponent(url.pathname);
|
|
87
|
-
const query = parseSearchParams(url.searchParams);
|
|
88
91
|
const match = this.router.route(path);
|
|
92
|
+
if (match.id === MISSING) return res.missing();
|
|
89
93
|
const route = this.router.routeForID(match.id).spec;
|
|
94
|
+
let method = req.method.toLowerCase();
|
|
95
|
+
if (method === "options" && !route.handlers.options) {
|
|
96
|
+
return res.noContent({
|
|
97
|
+
allow: Object.keys(route.handlers).map(upper).join(", ")
|
|
98
|
+
});
|
|
99
|
+
}
|
|
90
100
|
const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
|
|
91
101
|
const cookies = rawCookies ? parseCoookies(rawCookies) : {};
|
|
102
|
+
const query = parseSearchParams(url.searchParams);
|
|
92
103
|
const ctx = this.augmentCtx({
|
|
93
104
|
// @ts-ignore
|
|
94
105
|
server: this,
|
|
@@ -101,17 +112,6 @@ class Server {
|
|
|
101
112
|
route,
|
|
102
113
|
match
|
|
103
114
|
});
|
|
104
|
-
if (match.id === MISSING) {
|
|
105
|
-
this.runHandler(route.handlers.get, ctx);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
let method = ctx.query?.__method || req.method.toLowerCase();
|
|
109
|
-
if (method === "options" && !route.handlers.options) {
|
|
110
|
-
res.writeHead(204, {
|
|
111
|
-
allow: Object.keys(route.handlers).map(upper).join(", ")
|
|
112
|
-
}).end();
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
115
|
if (method === "head" && !route.handlers.head && route.handlers.get) {
|
|
116
116
|
method = "get";
|
|
117
117
|
}
|
|
@@ -241,6 +241,16 @@ class ServerResponse extends http.ServerResponse {
|
|
|
241
241
|
notAcceptable(headers, body) {
|
|
242
242
|
this.writeHead(406, headers).end(body);
|
|
243
243
|
}
|
|
244
|
+
rateLimit(headers, body) {
|
|
245
|
+
this.writeHead(429, headers).end(body);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* HTTP 444. Indicates the server has returned no information to the client and closed
|
|
249
|
+
* the connection (useful as a deterrent for malware)
|
|
250
|
+
*/
|
|
251
|
+
noResponse() {
|
|
252
|
+
this.writeHead(444).end();
|
|
253
|
+
}
|
|
244
254
|
}
|
|
245
255
|
export {
|
|
246
256
|
Server,
|
package/session/session.d.ts
CHANGED
|
@@ -3,14 +3,13 @@ import { ServerResponse } from "node:http";
|
|
|
3
3
|
import type { Interceptor, ISessionStore, RequestCtx, ServerSession } from "../api.js";
|
|
4
4
|
export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> {
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Factory function to create a new session object. See {@link createSession}.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
factory: Fn<CTX, SESSION>;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* only contains a {@link ServerSession.id} (UUID v4).
|
|
10
|
+
* Session storage implementation. Default: {@link InMemorySessionStore}.
|
|
12
11
|
*/
|
|
13
|
-
|
|
12
|
+
store?: ISessionStore<SESSION>;
|
|
14
13
|
/**
|
|
15
14
|
* Session cookie name
|
|
16
15
|
*
|
|
@@ -23,22 +22,48 @@ export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extend
|
|
|
23
22
|
* @defaultValue "Secure;HttpOnly;SameSite=Strict;Path=/"
|
|
24
23
|
*/
|
|
25
24
|
cookieOpts?: string;
|
|
25
|
+
/**
|
|
26
|
+
* HMAC key/secret used for signing cookies (using SHA256). Max length 64
|
|
27
|
+
* bytes. If given as number, generates N random bytes.
|
|
28
|
+
*/
|
|
29
|
+
secret?: number | string | Buffer;
|
|
26
30
|
}
|
|
27
31
|
export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> implements Interceptor<CTX> {
|
|
28
|
-
store: ISessionStore<SESSION>;
|
|
29
32
|
factory: Fn<CTX, SESSION>;
|
|
33
|
+
store: ISessionStore<SESSION>;
|
|
30
34
|
cookieName: string;
|
|
31
35
|
cookieOpts: string;
|
|
32
|
-
|
|
36
|
+
secret: Buffer;
|
|
37
|
+
constructor({ factory, store, cookieName, cookieOpts, secret, }: SessionOpts<CTX, SESSION>);
|
|
33
38
|
pre(ctx: CTX): Promise<boolean>;
|
|
34
|
-
|
|
39
|
+
deleteSession(ctx: CTX, sessionID: string): Promise<void>;
|
|
35
40
|
newSession(ctx: CTX): Promise<SESSION | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Calls {@link SessionInterceptor.newSession} to create a new session and,
|
|
43
|
+
* if successful, associates it with current context & response. Deletes
|
|
44
|
+
* existing session (if any). Returns new session object.
|
|
45
|
+
*
|
|
46
|
+
* @param ctx
|
|
47
|
+
*/
|
|
48
|
+
replaceSession(ctx: CTX): Promise<SESSION | undefined>;
|
|
36
49
|
withSession(res: ServerResponse, sessionID: string): ServerResponse<import("http").IncomingMessage>;
|
|
50
|
+
validateSession(cookie: string): string | undefined;
|
|
37
51
|
}
|
|
38
52
|
/**
|
|
39
53
|
* Factory function to create a new {@link SessionInterceptor} instance.
|
|
40
54
|
*
|
|
41
55
|
* @param opts
|
|
42
56
|
*/
|
|
43
|
-
export declare const serverSession: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts
|
|
57
|
+
export declare const serverSession: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts: SessionOpts<CTX, SESSION>) => SessionInterceptor<CTX, SESSION>;
|
|
58
|
+
/**
|
|
59
|
+
* Creates a new basic {@link ServerSession}, using a UUID v4 for
|
|
60
|
+
* {@link ServerSession.id}.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* Intended to be used for {@link SessionOpts.factory} and/or as basis for
|
|
64
|
+
* creating custom session objects.
|
|
65
|
+
*
|
|
66
|
+
* @param ctx
|
|
67
|
+
*/
|
|
68
|
+
export declare const createSession: (ctx: RequestCtx) => ServerSession;
|
|
44
69
|
//# sourceMappingURL=session.d.ts.map
|
package/session/session.js
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
|
+
import { isNumber, isString } from "@thi.ng/checks";
|
|
1
2
|
import { uuid } from "@thi.ng/uuid";
|
|
3
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
4
|
import { ServerResponse } from "node:http";
|
|
3
5
|
import { inMemorySessionStore } from "./memory.js";
|
|
4
6
|
class SessionInterceptor {
|
|
5
|
-
store;
|
|
6
7
|
factory;
|
|
8
|
+
store;
|
|
7
9
|
cookieName;
|
|
8
10
|
cookieOpts;
|
|
11
|
+
secret;
|
|
9
12
|
constructor({
|
|
13
|
+
factory,
|
|
10
14
|
store = inMemorySessionStore(),
|
|
11
|
-
factory = () => ({ id: uuid() }),
|
|
12
15
|
cookieName = "__sid",
|
|
13
|
-
cookieOpts = "Secure;HttpOnly;SameSite=Strict;Path=/"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
cookieOpts = "Secure;HttpOnly;SameSite=Strict;Path=/",
|
|
17
|
+
secret = 32
|
|
18
|
+
}) {
|
|
16
19
|
this.factory = factory;
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.secret = isNumber(secret) ? randomBytes(secret) : isString(secret) ? Buffer.from(secret) : secret;
|
|
17
22
|
this.cookieName = cookieName;
|
|
18
23
|
this.cookieOpts = cookieOpts;
|
|
19
24
|
}
|
|
20
25
|
async pre(ctx) {
|
|
21
|
-
const
|
|
26
|
+
const cookie = ctx.cookies?.[this.cookieName];
|
|
27
|
+
let id;
|
|
28
|
+
if (cookie) {
|
|
29
|
+
id = this.validateSession(cookie);
|
|
30
|
+
if (!id) {
|
|
31
|
+
ctx.res.forbidden();
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
22
35
|
let session = id ? await this.store.get(id) : void 0;
|
|
23
|
-
if (!session) {
|
|
36
|
+
if (!session || session.ip !== ctx.req.socket.remoteAddress) {
|
|
24
37
|
session = await this.newSession(ctx);
|
|
25
38
|
if (!session) return false;
|
|
26
39
|
}
|
|
@@ -28,7 +41,7 @@ class SessionInterceptor {
|
|
|
28
41
|
this.withSession(ctx.res, session.id);
|
|
29
42
|
return true;
|
|
30
43
|
}
|
|
31
|
-
async
|
|
44
|
+
async deleteSession(ctx, sessionID) {
|
|
32
45
|
if (await this.store.delete(sessionID)) {
|
|
33
46
|
ctx.logger.info("delete session:", sessionID);
|
|
34
47
|
ctx.session = void 0;
|
|
@@ -47,15 +60,49 @@ class SessionInterceptor {
|
|
|
47
60
|
}
|
|
48
61
|
return session;
|
|
49
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Calls {@link SessionInterceptor.newSession} to create a new session and,
|
|
65
|
+
* if successful, associates it with current context & response. Deletes
|
|
66
|
+
* existing session (if any). Returns new session object.
|
|
67
|
+
*
|
|
68
|
+
* @param ctx
|
|
69
|
+
*/
|
|
70
|
+
async replaceSession(ctx) {
|
|
71
|
+
const session = await this.newSession(ctx);
|
|
72
|
+
if (session) {
|
|
73
|
+
if (ctx.session?.id) this.store.delete(ctx.session.id);
|
|
74
|
+
ctx.session = session;
|
|
75
|
+
this.withSession(ctx.res, session.id);
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
50
79
|
withSession(res, sessionID) {
|
|
80
|
+
const cookie = sessionID + ":" + randomBytes(8).toString("base64url");
|
|
81
|
+
const signature = createHmac("sha256", this.secret).update(cookie, "ascii").digest().toString("base64url");
|
|
51
82
|
return res.appendHeader(
|
|
52
83
|
"set-cookie",
|
|
53
|
-
`${this.cookieName}=${
|
|
84
|
+
`${this.cookieName}=${cookie}:${signature};Max-Age=${this.store.ttl};${this.cookieOpts}`
|
|
54
85
|
);
|
|
55
86
|
}
|
|
87
|
+
validateSession(cookie) {
|
|
88
|
+
const parts = cookie.split(":");
|
|
89
|
+
if (parts.length < 3) return;
|
|
90
|
+
const actual = Buffer.from(parts[2], "base64url");
|
|
91
|
+
const expected = createHmac("sha256", this.secret).update(
|
|
92
|
+
cookie.substring(0, cookie.length - parts[2].length - 1),
|
|
93
|
+
"ascii"
|
|
94
|
+
).digest();
|
|
95
|
+
const sameLength = actual.length === expected.length;
|
|
96
|
+
return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? parts[0] : void 0;
|
|
97
|
+
}
|
|
56
98
|
}
|
|
57
99
|
const serverSession = (opts) => new SessionInterceptor(opts);
|
|
100
|
+
const createSession = (ctx) => ({
|
|
101
|
+
id: uuid(),
|
|
102
|
+
ip: ctx.req.socket.remoteAddress
|
|
103
|
+
});
|
|
58
104
|
export {
|
|
59
105
|
SessionInterceptor,
|
|
106
|
+
createSession,
|
|
60
107
|
serverSession
|
|
61
108
|
};
|
package/utils/host.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { illegalArgs } from "@thi.ng/errors";
|
|
2
2
|
import { HEX } from "@thi.ng/strings";
|
|
3
|
-
const isMatchingHost = (test, expected) =>
|
|
3
|
+
const isMatchingHost = (test, expected) => {
|
|
4
|
+
if (/^\[[0-9a-f:]{1,39}\]$/.test(test)) {
|
|
5
|
+
try {
|
|
6
|
+
return normalizeIPv6Address(test.substring(1, test.length - 1)) === expected;
|
|
7
|
+
} catch (_) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return test === expected;
|
|
12
|
+
};
|
|
4
13
|
const parseIPv6Address = (addr) => {
|
|
5
14
|
if (addr == "::") return [0, 0, 0, 0, 0, 0, 0, 0];
|
|
6
15
|
if (addr == "::1") return [0, 0, 0, 0, 0, 0, 0, 1];
|