@thi.ng/server 0.5.0 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Change Log
2
2
 
3
- - **Last updated**: 2025-02-19T20:59:58Z
3
+ - **Last updated**: 2025-02-21T21:54:17Z
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,18 @@ 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.6.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.6.0) (2025-02-21)
15
+
16
+ #### 🚀 Features
17
+
18
+ - update `sessionInterceptor()` cookie signing/handling ([bdd4d66](https://github.com/thi-ng/umbrella/commit/bdd4d66))
19
+ - update `SessionInterceptor.newSession()`
20
+ - pre-compute session metadata (HMAC & cookie values), store in WeakMap
21
+ - update `.withSession()`
22
+ - update `.validateSession()` to use cached signature
23
+ - update tests
24
+ - add/update `ServerOpts` ([23b5321](https://github.com/thi-ng/umbrella/commit/23b5321))
25
+
14
26
  ## [0.5.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.5.0) (2025-02-19)
15
27
 
16
28
  #### 🚀 Features
package/api.d.ts CHANGED
@@ -2,7 +2,8 @@ import type { Fn, Maybe, MaybePromise } from "@thi.ng/api";
2
2
  import type { ILogger } from "@thi.ng/logger";
3
3
  import type { Route, RouteMatch } from "@thi.ng/router";
4
4
  import type { IncomingMessage } from "node:http";
5
- import type { ServerResponse, Server } from "./server.js";
5
+ import type { BlockList } from "node:net";
6
+ import type { Server, ServerResponse } from "./server.js";
6
7
  export type Method = "get" | "put" | "post" | "delete" | "head" | "options" | "patch";
7
8
  export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
8
9
  logger: ILogger;
@@ -50,6 +51,22 @@ export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
50
51
  * request before processing its handler & interceptors.
51
52
  */
52
53
  context: Fn<RequestCtx, CTX>;
54
+ /**
55
+ * Timeout in milliseconds for receiving the entire request from the client.
56
+ */
57
+ requestTimeout: number;
58
+ /**
59
+ * List of response headers that should be sent only once.
60
+ */
61
+ uniqueHeaders: string[];
62
+ /**
63
+ * Used for disabling inbound access to specific IP addresses, IP ranges, or
64
+ * IP subnets. This does not work if the server is behind a reverse proxy.
65
+ *
66
+ * @remarks
67
+ * Reference: https://nodejs.org/api/net.html#class-netblocklist
68
+ */
69
+ blockList: BlockList;
53
70
  }
54
71
  export interface ServerRoute<CTX extends RequestCtx = RequestCtx> extends Route {
55
72
  handlers: Partial<Record<Method, RequestHandler<CTX>>>;
@@ -143,7 +160,7 @@ export interface ISessionStore<T extends ServerSession = ServerSession> {
143
160
  */
144
161
  get(id: string): MaybePromise<Maybe<T>>;
145
162
  /**
146
- * Adds given `session` to underlying storage.
163
+ * Adds to or updates given `session` in underlying storage.
147
164
  *
148
165
  * @param session
149
166
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thi.ng/server",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -40,15 +40,15 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@thi.ng/api": "^8.11.21",
43
- "@thi.ng/arrays": "^2.10.16",
44
- "@thi.ng/cache": "^2.3.24",
45
- "@thi.ng/checks": "^3.6.24",
43
+ "@thi.ng/arrays": "^2.10.17",
44
+ "@thi.ng/cache": "^2.3.25",
45
+ "@thi.ng/checks": "^3.7.0",
46
46
  "@thi.ng/errors": "^2.5.27",
47
- "@thi.ng/file-io": "^2.1.28",
47
+ "@thi.ng/file-io": "^2.1.29",
48
48
  "@thi.ng/logger": "^3.1.2",
49
49
  "@thi.ng/mime": "^2.7.3",
50
- "@thi.ng/paths": "^5.2.2",
51
- "@thi.ng/router": "^4.1.19",
50
+ "@thi.ng/paths": "^5.2.3",
51
+ "@thi.ng/router": "^4.1.20",
52
52
  "@thi.ng/strings": "^3.9.6",
53
53
  "@thi.ng/timestamp": "^1.1.6",
54
54
  "@thi.ng/uuid": "^1.1.18"
@@ -163,5 +163,5 @@
163
163
  "status": "alpha",
164
164
  "year": 2024
165
165
  },
166
- "gitHead": "bee617702ac61d093465b967f8f973dc566faa6b\n"
166
+ "gitHead": "2958e6a9881bd9e06de587a2141f6d23c64278db\n"
167
167
  }
package/server.js CHANGED
@@ -45,13 +45,23 @@ class Server {
45
45
  host;
46
46
  augmentCtx;
47
47
  async start() {
48
- const { ssl, host = "localhost", port = ssl ? 443 : 8080 } = this.opts;
48
+ const {
49
+ ssl,
50
+ host = "localhost",
51
+ port = ssl ? 443 : 8080,
52
+ uniqueHeaders,
53
+ blockList,
54
+ requestTimeout
55
+ } = this.opts;
49
56
  try {
50
57
  this.server = ssl ? https.createServer(
51
58
  {
52
59
  key: readText(ssl.key, this.logger),
53
60
  cert: readText(ssl.cert, this.logger),
54
- ServerResponse
61
+ ServerResponse,
62
+ uniqueHeaders,
63
+ blockList,
64
+ requestTimeout
55
65
  },
56
66
  this.listener.bind(this)
57
67
  ) : http.createServer(
@@ -32,19 +32,29 @@ export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extend
32
32
  */
33
33
  secret?: number | string | Buffer;
34
34
  }
35
+ /**
36
+ * Cached session metadata, stored in a WeakMap.
37
+ *
38
+ * @internal
39
+ */
40
+ export interface SessionMeta {
41
+ hmac: Buffer;
42
+ cookie: string;
43
+ }
35
44
  /**
36
45
  * Pre-interceptor which parses & validates session cookie (if available) from
37
46
  * current request and injects/updates session cookie in response. Only a signed
38
- * session ID will be stored in the cookie. Thr actual session data is held
39
- * server side, via configured session storage (see {@link SessionOpts.store})
40
- * (by default uses {@link InMemorySessionStore}).
47
+ * session ID will be stored in the cookie. Thr actual session data and HMAC is
48
+ * held server side (via configured session storage, see
49
+ * {@link SessionOpts.store}; by default uses {@link InMemorySessionStore}).
41
50
  */
42
51
  export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> implements Interceptor<CTX> {
43
- factory: Fn<CTX, SESSION>;
52
+ factory: SessionOpts<CTX, SESSION>["factory"];
44
53
  store: ISessionStore<SESSION>;
54
+ meta: WeakMap<SESSION, SessionMeta>;
55
+ secret: Buffer;
45
56
  cookieName: string;
46
57
  cookieOpts: string;
47
- secret: Buffer;
48
58
  constructor({ factory, store, cookieName, cookieOpts, secret, }: SessionOpts<CTX, SESSION>);
49
59
  pre(ctx: CTX): Promise<boolean>;
50
60
  /**
@@ -60,9 +70,9 @@ export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SES
60
70
  */
61
71
  deleteSession(ctx: CTX, sessionID: string): Promise<void>;
62
72
  /**
63
- * Creates a new session object (via configured {@link SessionOpts.factory})
64
- * and submits it to configured {@link SessionOpts.store}, Returns session
65
- * if successful, otherwise returns `undefined`.
73
+ * Creates a new session object (via configured {@link SessionOpts.factory}), pre-computes HMAC
74
+ * and submits it to configured {@link SessionOpts.store}. If successful, Returns session
75
+ * , otherwise returns `undefined`.
66
76
  *
67
77
  * @param ctx
68
78
  */
@@ -75,8 +85,8 @@ export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SES
75
85
  * @param ctx
76
86
  */
77
87
  replaceSession(ctx: CTX): Promise<SESSION | undefined>;
78
- withSession(res: ServerResponse, sessionID: string): ServerResponse<import("http").IncomingMessage>;
79
- validateSession(cookie: string): string | undefined;
88
+ withSession(res: ServerResponse, session: SESSION): ServerResponse<import("http").IncomingMessage>;
89
+ validateSession(cookie: string): Promise<SESSION | undefined>;
80
90
  }
81
91
  /**
82
92
  * Factory function to create a new {@link SessionInterceptor} instance
@@ -6,9 +6,10 @@ import { inMemorySessionStore } from "./memory.js";
6
6
  class SessionInterceptor {
7
7
  factory;
8
8
  store;
9
+ meta = /* @__PURE__ */ new WeakMap();
10
+ secret;
9
11
  cookieName;
10
12
  cookieOpts;
11
- secret;
12
13
  constructor({
13
14
  factory,
14
15
  store = inMemorySessionStore(),
@@ -24,21 +25,20 @@ class SessionInterceptor {
24
25
  }
25
26
  async pre(ctx) {
26
27
  const cookie = ctx.cookies?.[this.cookieName];
27
- let id;
28
+ let session;
28
29
  if (cookie) {
29
- id = this.validateSession(cookie);
30
- if (!id) {
30
+ session = await this.validateSession(cookie);
31
+ if (!session) {
31
32
  ctx.res.forbidden();
32
33
  return false;
33
34
  }
34
35
  }
35
- let session = id ? await this.store.get(id) : void 0;
36
36
  if (!session || session.ip !== ctx.req.socket.remoteAddress) {
37
37
  session = await this.newSession(ctx);
38
38
  if (!session) return false;
39
39
  }
40
40
  ctx.session = session;
41
- this.withSession(ctx.res, session.id);
41
+ this.withSession(ctx.res, session);
42
42
  return true;
43
43
  }
44
44
  /**
@@ -63,9 +63,9 @@ class SessionInterceptor {
63
63
  }
64
64
  }
65
65
  /**
66
- * Creates a new session object (via configured {@link SessionOpts.factory})
67
- * and submits it to configured {@link SessionOpts.store}, Returns session
68
- * if successful, otherwise returns `undefined`.
66
+ * Creates a new session object (via configured {@link SessionOpts.factory}), pre-computes HMAC
67
+ * and submits it to configured {@link SessionOpts.store}. If successful, Returns session
68
+ * , otherwise returns `undefined`.
69
69
  *
70
70
  * @param ctx
71
71
  */
@@ -76,6 +76,11 @@ class SessionInterceptor {
76
76
  ctx.logger.warn("could not store session...");
77
77
  return;
78
78
  }
79
+ const hmac = createHmac("sha256", this.secret).update(session.id, "ascii").update(randomBytes(8)).digest();
80
+ this.meta.set(session, {
81
+ hmac,
82
+ cookie: session.id + ":" + hmac.toString("base64url")
83
+ });
79
84
  return session;
80
85
  }
81
86
  /**
@@ -90,28 +95,27 @@ class SessionInterceptor {
90
95
  if (session) {
91
96
  if (ctx.session?.id) this.store.delete(ctx.session.id);
92
97
  ctx.session = session;
93
- this.withSession(ctx.res, session.id);
98
+ this.withSession(ctx.res, session);
94
99
  return session;
95
100
  }
96
101
  }
97
- withSession(res, sessionID) {
98
- const cookie = sessionID + ":" + randomBytes(8).toString("base64url");
99
- const signature = createHmac("sha256", this.secret).update(cookie, "ascii").digest().toString("base64url");
102
+ withSession(res, session) {
103
+ const cookie = this.meta.get(session)?.cookie;
100
104
  return res.appendHeader(
101
105
  "set-cookie",
102
- `${this.cookieName}=${cookie}:${signature};Max-Age=${this.store.ttl};${this.cookieOpts}`
106
+ `${this.cookieName}=${cookie};Max-Age=${this.store.ttl};${this.cookieOpts}`
103
107
  );
104
108
  }
105
- validateSession(cookie) {
109
+ async validateSession(cookie) {
106
110
  const parts = cookie.split(":");
107
- if (parts.length < 3) return;
108
- const actual = Buffer.from(parts[2], "base64url");
109
- const expected = createHmac("sha256", this.secret).update(
110
- cookie.substring(0, cookie.length - parts[2].length - 1),
111
- "ascii"
112
- ).digest();
111
+ if (parts.length !== 2) return;
112
+ const session = await this.store.get(parts[0]);
113
+ if (!session) return;
114
+ const actual = Buffer.from(parts[1], "base64url");
115
+ const expected = this.meta.get(session)?.hmac;
116
+ if (!expected) return;
113
117
  const sameLength = actual.length === expected.length;
114
- return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? parts[0] : void 0;
118
+ return timingSafeEqual(sameLength ? actual : expected, expected) && sameLength ? session : void 0;
115
119
  }
116
120
  }
117
121
  const sessionInterceptor = (opts) => new SessionInterceptor(opts);