@thi.ng/server 0.2.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-01-30T15:45:22Z
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,43 @@ 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
+
44
+ ## [0.3.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.3.0) (2025-02-02)
45
+
46
+ #### 🚀 Features
47
+
48
+ - add more HTTP error response methods ([5731ff3](https://github.com/thi-ng/umbrella/commit/5731ff3))
49
+ - add ServerResponse, IPv6 support ([22f64c5](https://github.com/thi-ng/umbrella/commit/22f64c5))
50
+
14
51
  ## [0.2.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.2.0) (2025-01-30)
15
52
 
16
53
  #### 🚀 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
@@ -1,8 +1,8 @@
1
1
  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
- import type { IncomingMessage, ServerResponse } from "node:http";
5
- import type { Server } from "./server.js";
4
+ import type { IncomingMessage } from "node:http";
5
+ import type { ServerResponse, Server } from "./server.js";
6
6
  export type Method = "get" | "put" | "post" | "delete" | "head" | "options" | "patch";
7
7
  export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
8
8
  logger: ILogger;
@@ -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";
@@ -13,6 +15,7 @@ export * from "./session/session.js";
13
15
  export * from "./session/memory.js";
14
16
  export * from "./utils/cookies.js";
15
17
  export * from "./utils/cache.js";
18
+ export * from "./utils/host.js";
16
19
  export * from "./utils/formdata.js";
17
20
  export * from "./utils/multipart.js";
18
21
  //# sourceMappingURL=index.d.ts.map
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";
@@ -13,5 +15,6 @@ export * from "./session/session.js";
13
15
  export * from "./session/memory.js";
14
16
  export * from "./utils/cookies.js";
15
17
  export * from "./utils/cache.js";
18
+ export * from "./utils/host.js";
16
19
  export * from "./utils/formdata.js";
17
20
  export * from "./utils/multipart.js";
@@ -1,7 +1,7 @@
1
1
  const authenticateWith = (pred) => ({
2
2
  pre: (ctx) => {
3
3
  if (ctx.route.auth && !pred(ctx)) {
4
- ctx.server.unauthorized(ctx.res);
4
+ ctx.res.unauthorized();
5
5
  return false;
6
6
  }
7
7
  return true;
@@ -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.2.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
  },
@@ -145,6 +152,9 @@
145
152
  "./utils/formdata": {
146
153
  "default": "./utils/formdata.js"
147
154
  },
155
+ "./utils/host": {
156
+ "default": "./utils/host.js"
157
+ },
148
158
  "./utils/multipart": {
149
159
  "default": "./utils/multipart.js"
150
160
  }
@@ -153,5 +163,5 @@
153
163
  "status": "alpha",
154
164
  "year": 2024
155
165
  },
156
- "gitHead": "078de98f4365f0d472c87198bcd6a112e732d9ef\n"
166
+ "gitHead": "dcc1dbfa6eae31ac65e12843987b94d4a7edc144\n"
157
167
  }
package/server.d.ts CHANGED
@@ -7,21 +7,39 @@ export declare class Server<CTX extends RequestCtx = RequestCtx> {
7
7
  opts: Partial<ServerOpts<CTX>>;
8
8
  logger: ILogger;
9
9
  router: Router<CompiledServerRoute<CTX>>;
10
- server: http.Server;
10
+ server: http.Server<typeof http.IncomingMessage, typeof ServerResponse>;
11
+ host: string;
11
12
  protected augmentCtx: Fn<RequestCtx, CTX>;
12
13
  constructor(opts?: Partial<ServerOpts<CTX>>);
13
14
  start(): Promise<boolean>;
14
15
  stop(): Promise<boolean>;
15
- protected listener(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
16
+ protected listener(req: http.IncomingMessage, res: ServerResponse): Promise<void>;
16
17
  protected runHandler({ fn, pre, post }: CompiledHandler, ctx: CTX): Promise<void>;
17
18
  protected compileRoute(route: ServerRoute<CTX>): CompiledServerRoute<CTX>;
18
- addRoutes(routes: ServerRoute[]): void;
19
+ addRoutes(routes: ServerRoute<CTX>[]): void;
19
20
  sendFile({ req, res }: RequestCtx, path: string, headers?: http.OutgoingHttpHeaders, compress?: boolean): Promise<void>;
20
- unauthorized(res: http.ServerResponse): void;
21
- unmodified(res: http.ServerResponse): void;
22
- missing(res: http.ServerResponse): void;
23
- redirectTo(res: http.ServerResponse, location: string): void;
24
- redirectToRoute(res: http.ServerResponse, route: RouteMatch): void;
21
+ redirectToRoute(res: ServerResponse, route: RouteMatch): void;
25
22
  }
26
23
  export declare const server: <CTX extends RequestCtx>(opts?: Partial<ServerOpts<CTX>>) => Server<CTX>;
24
+ /**
25
+ * Extended version of the default NodeJS ServerResponse with additional methods
26
+ * for various commonly used HTTP statuses/errors.
27
+ */
28
+ export declare class ServerResponse extends http.ServerResponse<http.IncomingMessage> {
29
+ noContent(headers?: http.OutgoingHttpHeaders): void;
30
+ redirectTo(location: string, headers?: http.OutgoingHttpHeaders): void;
31
+ seeOther(location: string, headers?: http.OutgoingHttpHeaders): void;
32
+ unmodified(headers?: http.OutgoingHttpHeaders): void;
33
+ unauthorized(headers?: http.OutgoingHttpHeaders, body?: any): void;
34
+ forbidden(headers?: http.OutgoingHttpHeaders, body?: any): void;
35
+ missing(headers?: http.OutgoingHttpHeaders, body?: any): void;
36
+ notAllowed(headers?: http.OutgoingHttpHeaders, body?: any): void;
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;
44
+ }
27
45
  //# sourceMappingURL=server.d.ts.map
package/server.js CHANGED
@@ -4,26 +4,30 @@ import { readText } from "@thi.ng/file-io";
4
4
  import { ConsoleLogger } from "@thi.ng/logger";
5
5
  import { preferredTypeForPath } from "@thi.ng/mime";
6
6
  import { Router } from "@thi.ng/router";
7
+ import { upper } from "@thi.ng/strings";
7
8
  import { createReadStream } from "node:fs";
8
9
  import * as http from "node:http";
9
10
  import * as https from "node:https";
11
+ import { isIPv6 } from "node:net";
10
12
  import { pipeline, Transform } from "node:stream";
11
13
  import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
12
14
  import { parseCoookies } from "./utils/cookies.js";
13
15
  import { parseSearchParams } from "./utils/formdata.js";
14
- import { upper } from "@thi.ng/strings";
16
+ import { isMatchingHost, normalizeIPv6Address } from "./utils/host.js";
15
17
  const MISSING = "__missing";
16
18
  class Server {
17
19
  constructor(opts = {}) {
18
20
  this.opts = opts;
19
21
  this.logger = opts.logger ?? new ConsoleLogger("server");
22
+ this.host = opts.host ?? "localhost";
23
+ if (isIPv6(this.host)) this.host = normalizeIPv6Address(this.host);
20
24
  this.augmentCtx = opts.context ?? identity;
21
25
  const routes = [
22
26
  {
23
27
  id: MISSING,
24
28
  match: ["__404__"],
25
29
  handlers: {
26
- get: async ({ res }) => this.missing(res)
30
+ get: async ({ res }) => res.missing()
27
31
  }
28
32
  },
29
33
  ...this.opts.routes ?? []
@@ -38,19 +42,22 @@ class Server {
38
42
  logger;
39
43
  router;
40
44
  server;
45
+ host;
41
46
  augmentCtx;
42
47
  async start() {
43
- const ssl = this.opts.ssl;
44
- const port = this.opts.port ?? (ssl ? 443 : 8080);
45
- const host = this.opts.host ?? "localhost";
48
+ const { ssl, host = "localhost", port = ssl ? 443 : 8080 } = this.opts;
46
49
  try {
47
50
  this.server = ssl ? https.createServer(
48
51
  {
49
52
  key: readText(ssl.key, this.logger),
50
- cert: readText(ssl.cert, this.logger)
53
+ cert: readText(ssl.cert, this.logger),
54
+ ServerResponse
51
55
  },
52
56
  this.listener.bind(this)
53
- ) : http.createServer({}, this.listener.bind(this));
57
+ ) : http.createServer(
58
+ { ServerResponse },
59
+ this.listener.bind(this)
60
+ );
54
61
  this.server.listen(port, host, void 0, () => {
55
62
  this.logger.info(
56
63
  `starting server: http${ssl ? "s" : ""}://${host}:${port}`
@@ -70,18 +77,29 @@ class Server {
70
77
  return true;
71
78
  }
72
79
  async listener(req, res) {
73
- const url = new URL(req.url, `http://${req.headers.host}`);
74
- if (this.opts.host && this.opts.host !== url.host) {
75
- res.writeHead(503).end();
76
- return;
77
- }
78
- const path = decodeURIComponent(url.pathname);
79
80
  try {
80
- const query = parseSearchParams(url.searchParams);
81
+ const url = new URL(req.url, `http://${req.headers.host}`);
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();
89
+ }
90
+ const path = decodeURIComponent(url.pathname);
81
91
  const match = this.router.route(path);
92
+ if (match.id === MISSING) return res.missing();
82
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
+ }
83
100
  const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
84
101
  const cookies = rawCookies ? parseCoookies(rawCookies) : {};
102
+ const query = parseSearchParams(url.searchParams);
85
103
  const ctx = this.augmentCtx({
86
104
  // @ts-ignore
87
105
  server: this,
@@ -94,17 +112,6 @@ class Server {
94
112
  route,
95
113
  match
96
114
  });
97
- if (match.id === MISSING) {
98
- this.runHandler(route.handlers.get, ctx);
99
- return;
100
- }
101
- let method = ctx.query?.__method || req.method.toLowerCase();
102
- if (method === "options" && !route.handlers.options) {
103
- res.writeHead(204, {
104
- allow: Object.keys(route.handlers).map(upper).join(", ")
105
- }).end();
106
- return;
107
- }
108
115
  if (method === "head" && !route.handlers.head && route.handlers.get) {
109
116
  method = "get";
110
117
  }
@@ -112,7 +119,7 @@ class Server {
112
119
  if (handler) {
113
120
  this.runHandler(handler, ctx);
114
121
  } else {
115
- res.writeHead(405).end();
122
+ res.notAllowed();
116
123
  }
117
124
  } catch (e) {
118
125
  this.logger.warn("error:", e.message);
@@ -196,29 +203,57 @@ class Server {
196
203
  encoding ? pipeline(src, encoding.tx(), res, finalize) : pipeline(src, res, finalize);
197
204
  } catch (e) {
198
205
  this.logger.warn(e.message);
199
- this.missing(res);
206
+ res.missing();
200
207
  resolve();
201
208
  }
202
209
  });
203
210
  }
204
- unauthorized(res) {
205
- res.writeHead(403, "Forbidden").end();
211
+ redirectToRoute(res, route) {
212
+ res.redirectTo(this.router.format(route));
206
213
  }
207
- unmodified(res) {
208
- res.writeHead(304, "Not modified").end();
214
+ }
215
+ const server = (opts) => new Server(opts);
216
+ class ServerResponse extends http.ServerResponse {
217
+ noContent(headers) {
218
+ this.writeHead(204, headers).end();
209
219
  }
210
- missing(res) {
211
- res.writeHead(404, "Not found").end();
220
+ redirectTo(location, headers) {
221
+ this.writeHead(302, { ...headers, location }).end();
212
222
  }
213
- redirectTo(res, location) {
214
- res.writeHead(302, { location }).end();
223
+ seeOther(location, headers) {
224
+ this.writeHead(303, { ...headers, location }).end();
215
225
  }
216
- redirectToRoute(res, route) {
217
- this.redirectTo(res, this.router.format(route));
226
+ unmodified(headers) {
227
+ this.writeHead(304, headers).end();
228
+ }
229
+ unauthorized(headers, body) {
230
+ this.writeHead(401, headers).end(body);
231
+ }
232
+ forbidden(headers, body) {
233
+ this.writeHead(403, headers).end(body);
234
+ }
235
+ missing(headers, body) {
236
+ this.writeHead(404, headers).end(body);
237
+ }
238
+ notAllowed(headers, body) {
239
+ this.writeHead(405, headers).end(body);
240
+ }
241
+ notAcceptable(headers, body) {
242
+ this.writeHead(406, headers).end(body);
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();
218
253
  }
219
254
  }
220
- const server = (opts) => new Server(opts);
221
255
  export {
222
256
  Server,
257
+ ServerResponse,
223
258
  server
224
259
  };
@@ -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,21 +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>;
35
- withSession(res: ServerResponse, sessionID: string): void;
39
+ deleteSession(ctx: CTX, sessionID: string): Promise<void>;
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>;
49
+ withSession(res: ServerResponse, sessionID: string): ServerResponse<import("http").IncomingMessage>;
50
+ validateSession(cookie: string): string | undefined;
36
51
  }
37
52
  /**
38
53
  * Factory function to create a new {@link SessionInterceptor} instance.
39
54
  *
40
55
  * @param opts
41
56
  */
42
- 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;
43
69
  //# sourceMappingURL=session.d.ts.map
@@ -1,36 +1,47 @@
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 { res, logger, cookies } = ctx;
22
- const id = 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
+ }
23
35
  let session = id ? await this.store.get(id) : void 0;
24
- if (!session) {
25
- session = this.factory(ctx);
26
- logger.info("new session:", session.id);
27
- this.store.set(session);
36
+ if (!session || session.ip !== ctx.req.socket.remoteAddress) {
37
+ session = await this.newSession(ctx);
38
+ if (!session) return false;
28
39
  }
29
40
  ctx.session = session;
30
- this.withSession(res, session.id);
41
+ this.withSession(ctx.res, session.id);
31
42
  return true;
32
43
  }
33
- async delete(ctx, sessionID) {
44
+ async deleteSession(ctx, sessionID) {
34
45
  if (await this.store.delete(sessionID)) {
35
46
  ctx.logger.info("delete session:", sessionID);
36
47
  ctx.session = void 0;
@@ -40,15 +51,58 @@ class SessionInterceptor {
40
51
  );
41
52
  }
42
53
  }
54
+ async newSession(ctx) {
55
+ const session = this.factory(ctx);
56
+ ctx.logger.info("new session:", session.id);
57
+ if (!await this.store.set(session)) {
58
+ ctx.logger.warn("could not store session...");
59
+ return;
60
+ }
61
+ return session;
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
+ }
43
79
  withSession(res, sessionID) {
44
- res.appendHeader(
80
+ const cookie = sessionID + ":" + randomBytes(8).toString("base64url");
81
+ const signature = createHmac("sha256", this.secret).update(cookie, "ascii").digest().toString("base64url");
82
+ return res.appendHeader(
45
83
  "set-cookie",
46
- `${this.cookieName}=${sessionID};Max-Age=${this.store.ttl};${this.cookieOpts}`
84
+ `${this.cookieName}=${cookie}:${signature};Max-Age=${this.store.ttl};${this.cookieOpts}`
47
85
  );
48
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
+ }
49
98
  }
50
99
  const serverSession = (opts) => new SessionInterceptor(opts);
100
+ const createSession = (ctx) => ({
101
+ id: uuid(),
102
+ ip: ctx.req.socket.remoteAddress
103
+ });
51
104
  export {
52
105
  SessionInterceptor,
106
+ createSession,
53
107
  serverSession
54
108
  };
package/static.js CHANGED
@@ -54,11 +54,11 @@ const staticFiles = ({
54
54
  });
55
55
  const __fileHeaders = async (path, ctx, filter, etag, headers) => {
56
56
  if (!(existsSync(path) && filter(path))) {
57
- return ctx.server.missing(ctx.res);
57
+ return ctx.res.missing();
58
58
  }
59
59
  if (etag) {
60
60
  const etagValue = await etag(path);
61
- return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.server.unmodified(ctx.res) : { ...headers, etag: etagValue };
61
+ return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...headers, etag: etagValue };
62
62
  }
63
63
  return { ...headers };
64
64
  };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Takes a hostname or IPv6 address `test` and compares it against `expected`,
3
+ * returns true if matching.
4
+ *
5
+ * @remarks
6
+ * If `test` is an IPv6 address in the format `[...]`, it will be compared
7
+ * without surrounding square brackets. If `expected` is an IPv6 address it MUST
8
+ * have been pre-normalized using {@link normalizeIPv6Address}. For performance
9
+ * reasons only `test` will be automatically normalized, i.e. `::1` will be
10
+ * normalized as `0:0:0:0:0:0:0:1`.
11
+ *
12
+ * @param test
13
+ * @param expected
14
+ *
15
+ * @internal
16
+ */
17
+ export declare const isMatchingHost: (test: string, expected: string) => boolean;
18
+ /**
19
+ * Parses given IPv6 address into an 8-tuple of 16bit uints. Throws error if
20
+ * address is invalid.
21
+ *
22
+ * @remarks
23
+ * https://en.wikipedia.org/wiki/IPv6_address
24
+ *
25
+ * @param addr
26
+ */
27
+ export declare const parseIPv6Address: (addr: string) => number[];
28
+ /**
29
+ * Returns normalized version of given IPv6 address, i.e. expanding `::`
30
+ * sections, removing leading zeroes and performing other syntactic validations.
31
+ * The returned address always has 8 parts. Throws error if address is invalid.
32
+ *
33
+ * @remarks
34
+ * Internally uses {@link parseIPv6Address}.
35
+ *
36
+ * Reference:
37
+ * https://en.wikipedia.org/wiki/IPv6_address
38
+ *
39
+ * @param addr
40
+ */
41
+ export declare const normalizeIPv6Address: (addr: string) => string;
42
+ //# sourceMappingURL=host.d.ts.map
package/utils/host.js ADDED
@@ -0,0 +1,49 @@
1
+ import { illegalArgs } from "@thi.ng/errors";
2
+ import { HEX } from "@thi.ng/strings";
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
+ };
13
+ const parseIPv6Address = (addr) => {
14
+ if (addr == "::") return [0, 0, 0, 0, 0, 0, 0, 0];
15
+ if (addr == "::1") return [0, 0, 0, 0, 0, 0, 0, 1];
16
+ const n = addr.length - 1;
17
+ if (n > 38) invalidIPv6(addr);
18
+ const parts = [];
19
+ let curr = 0;
20
+ let zeroes = -1;
21
+ for (let i = 0; i <= n; i++) {
22
+ const ch = addr[i];
23
+ if (i === n && ch == ":") illegalArgs(addr);
24
+ if (i === n || ch === ":") {
25
+ if (parts.length >= (zeroes >= 0 ? 6 : 8)) invalidIPv6(addr);
26
+ const end = i === n ? n + 1 : i > curr ? i : invalidIPv6(addr);
27
+ if (end - curr > 4) invalidIPv6(addr);
28
+ parts.push(parseInt(addr.substring(curr, end), 16));
29
+ if (addr[i + 1] === ":") {
30
+ if (zeroes >= 0) invalidIPv6(addr);
31
+ zeroes = parts.length;
32
+ i++;
33
+ }
34
+ curr = i + 1;
35
+ } else if (!HEX[ch]) invalidIPv6(addr);
36
+ }
37
+ if (zeroes >= 0) {
38
+ parts.splice(zeroes, 0, ...new Array(8 - parts.length).fill(0));
39
+ }
40
+ if (parts.length !== 8) invalidIPv6(addr);
41
+ return parts;
42
+ };
43
+ const normalizeIPv6Address = (addr) => parseIPv6Address(addr).map((x) => x.toString(16)).join(":");
44
+ const invalidIPv6 = (addr) => illegalArgs("invalid IPv6 address: " + addr);
45
+ export {
46
+ isMatchingHost,
47
+ normalizeIPv6Address,
48
+ parseIPv6Address
49
+ };