@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Change Log
2
2
 
3
- - **Last updated**: 2025-02-02T22:46:17Z
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: 4.02 KB
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
- ctx.session!.user = user;
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.writeHead(403).end("login failed");
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.delete(ctx, ctx.session!.id);
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
- flash?: FlashMsg;
124
- }
125
- export interface FlashMsg {
126
- type: "success" | "info" | "warn" | "error";
127
- body: any;
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";
@@ -1,8 +1,6 @@
1
- import { isString } from "@thi.ng/checks";
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 = __method(level);
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 = __method(level);
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.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.25",
48
- "@thi.ng/logger": "^3.0.30",
49
- "@thi.ng/mime": "^2.7.0",
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": "fa1407b41ef907a5523d30bcb28691a5aed6e85c\n"
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.opts.host)) {
83
- res.writeHead(503).end();
84
- return;
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,
@@ -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
- * Session storage implementation. Default: {@link InMemorySessionStore}.
6
+ * Factory function to create a new session object. See {@link createSession}.
7
7
  */
8
- store: ISessionStore<SESSION>;
8
+ factory: Fn<CTX, SESSION>;
9
9
  /**
10
- * Factory function to create a new session object. By default the object
11
- * only contains a {@link ServerSession.id} (UUID v4).
10
+ * Session storage implementation. Default: {@link InMemorySessionStore}.
12
11
  */
13
- factory: Fn<CTX, SESSION>;
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
- constructor({ store, factory, cookieName, cookieOpts, }?: Partial<SessionOpts<CTX, SESSION>>);
36
+ secret: Buffer;
37
+ constructor({ factory, store, cookieName, cookieOpts, secret, }: SessionOpts<CTX, SESSION>);
33
38
  pre(ctx: CTX): Promise<boolean>;
34
- delete(ctx: CTX, sessionID: string): Promise<void>;
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?: Partial<SessionOpts<CTX, SESSION>>) => SessionInterceptor<CTX, SESSION>;
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
@@ -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
- this.store = store;
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 id = ctx.cookies?.[this.cookieName];
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 delete(ctx, sessionID) {
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}=${sessionID};Max-Age=${this.store.ttl};${this.cookieOpts}`
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) => /^\[[0-9a-f:]+\]$/.test(test) ? normalizeIPv6Address(test.substring(1, test.length - 1)) === expected : 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];