@thi.ng/server 0.9.1 → 0.10.1

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-04-01T21:42:04Z
3
+ - **Last updated**: 2025-04-30T12:52:32Z
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,15 @@ 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.10.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.10.0) (2025-04-16)
15
+
16
+ #### 🚀 Features
17
+
18
+ - add `#` in URL safety check, update `RequestCtx` ([f0a7025](https://github.com/thi-ng/umbrella/commit/f0a7025))
19
+ - respond with HTTP 400 if request URL contains `#`
20
+ - this stops maliciously contructed requests via HTTP `request-target`
21
+ - add docs
22
+
14
23
  ## [0.9.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.9.0) (2025-03-22)
15
24
 
16
25
  #### 🚀 Features
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![Mastodon Follow](https://img.shields.io/mastodon/follow/109331703950160316?domain=https%3A%2F%2Fmastodon.thi.ng&style=social)](https://mastodon.thi.ng/@toxi)
8
8
 
9
9
  > [!NOTE]
10
- > This is one of 205 standalone projects, maintained as part
10
+ > This is one of 206 standalone projects, maintained as part
11
11
  > of the [@thi.ng/umbrella](https://github.com/thi-ng/umbrella/) monorepo
12
12
  > and anti-framework.
13
13
  >
@@ -164,7 +164,7 @@ For Node.js REPL:
164
164
  const ser = await import("@thi.ng/server");
165
165
  ```
166
166
 
167
- Package sizes (brotli'd, pre-treeshake): ESM: 6.25 KB
167
+ Package sizes (brotli'd, pre-treeshake): ESM: 6.27 KB
168
168
 
169
169
  ## Dependencies
170
170
 
package/api.d.ts CHANGED
@@ -85,17 +85,69 @@ export interface CompiledServerRoute<CTX extends RequestCtx = RequestCtx> extend
85
85
  handlers: Partial<Record<Method, CompiledHandler<CTX>>>;
86
86
  }
87
87
  export interface RequestCtx {
88
+ /**
89
+ * Server instance.
90
+ */
88
91
  server: Server;
92
+ /**
93
+ * Logger instance.
94
+ */
89
95
  logger: ILogger;
96
+ /**
97
+ * Parsed request URL.
98
+ *
99
+ * @remarks
100
+ * Route handlers SHOULD only use this url instead of
101
+ * {@link RequestCtx.req}'s `.url` property. This ensures, that the
102
+ * hash-fragment (`#...`) part is properly dealt with, avoiding security
103
+ * issues related to HTTP `request-target`
104
+ * (https://datatracker.ietf.org/doc/html/rfc9112#section-3.2).
105
+ *
106
+ * Note: The server already responds to any request containing a `#` in its
107
+ * URL with a HTTP 400.
108
+ *
109
+ * Also see {@link RequestCtx.path} and {@link RequestCtx.query}.
110
+ */
111
+ url: URL;
112
+ /**
113
+ * HTTP request instance.
114
+ */
90
115
  req: IncomingMessage;
116
+ /**
117
+ * HTTP server response instance.
118
+ */
91
119
  res: ServerResponse;
120
+ /**
121
+ * Pre-compiled route handler and its interceptors
122
+ */
92
123
  route: CompiledServerRoute;
124
+ /**
125
+ * Parsed route details.
126
+ */
93
127
  match: RouteMatch;
128
+ /**
129
+ * Request method (possibly adapted). Also see {@link RequestCtx.origMethod}.
130
+ */
94
131
  method: Method;
132
+ /**
133
+ * Original request method. Also see {@link RequestCtx.method}.
134
+ */
95
135
  origMethod: Method;
136
+ /**
137
+ * URI-decoded path part of request URL.
138
+ */
96
139
  path: string;
140
+ /**
141
+ * Parsed query string params (aka URL search params).
142
+ */
97
143
  query: Record<string, any>;
144
+ /**
145
+ * Parsed cookies, if any.
146
+ */
98
147
  cookies?: Record<string, string>;
148
+ /**
149
+ * Server session (only if {@link SessionInterceptor} is used).
150
+ */
99
151
  session?: ServerSession;
100
152
  }
101
153
  export type HandlerResult = MaybePromise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thi.ng/server",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
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",
@@ -39,26 +39,26 @@
39
39
  "tool:tangle": "../../node_modules/.bin/tangle src/**/*.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@thi.ng/api": "^8.11.25",
43
- "@thi.ng/arrays": "^2.10.22",
44
- "@thi.ng/cache": "^2.3.30",
45
- "@thi.ng/checks": "^3.7.5",
46
- "@thi.ng/errors": "^2.5.31",
47
- "@thi.ng/file-io": "^2.1.34",
48
- "@thi.ng/leaky-bucket": "^0.2.3",
49
- "@thi.ng/logger": "^3.1.6",
50
- "@thi.ng/mime": "^2.7.7",
51
- "@thi.ng/paths": "^5.2.8",
52
- "@thi.ng/router": "^4.1.25",
53
- "@thi.ng/strings": "^3.9.10",
54
- "@thi.ng/timestamp": "^1.1.10",
55
- "@thi.ng/uuid": "^1.1.22"
42
+ "@thi.ng/api": "^8.11.27",
43
+ "@thi.ng/arrays": "^2.11.0",
44
+ "@thi.ng/cache": "^2.3.32",
45
+ "@thi.ng/checks": "^3.7.7",
46
+ "@thi.ng/errors": "^2.5.33",
47
+ "@thi.ng/file-io": "^2.1.36",
48
+ "@thi.ng/leaky-bucket": "^0.2.5",
49
+ "@thi.ng/logger": "^3.1.8",
50
+ "@thi.ng/mime": "^2.7.9",
51
+ "@thi.ng/paths": "^5.2.10",
52
+ "@thi.ng/router": "^4.1.27",
53
+ "@thi.ng/strings": "^3.9.12",
54
+ "@thi.ng/timestamp": "^1.1.12",
55
+ "@thi.ng/uuid": "^1.1.24"
56
56
  },
57
57
  "devDependencies": {
58
- "@types/node": "^22.13.14",
59
- "esbuild": "^0.25.2",
60
- "typedoc": "^0.28.1",
61
- "typescript": "^5.8.2"
58
+ "@types/node": "^22.15.3",
59
+ "esbuild": "^0.25.3",
60
+ "typedoc": "^0.28.3",
61
+ "typescript": "^5.8.3"
62
62
  },
63
63
  "keywords": [
64
64
  "cookie",
@@ -167,5 +167,5 @@
167
167
  "status": "alpha",
168
168
  "year": 2024
169
169
  },
170
- "gitHead": "87aa2d0e64a357476c10fd57aabdfded13c79f7d\n"
170
+ "gitHead": "4354686a6fb1f82c09ea48f92f87786191b231a0\n"
171
171
  }
package/server.js CHANGED
@@ -90,6 +90,7 @@ class Server {
90
90
  }
91
91
  async listener(req, res) {
92
92
  try {
93
+ if (req.url.includes("#")) return res.badRequest();
93
94
  const url = new URL(req.url, `http://${req.headers.host}`);
94
95
  if (this.opts.host && !isMatchingHost(url.hostname, this.host)) {
95
96
  this.logger.debug(
@@ -123,6 +124,7 @@ class Server {
123
124
  // @ts-ignore
124
125
  server: this,
125
126
  logger: this.logger,
127
+ url,
126
128
  req,
127
129
  res,
128
130
  path,
@@ -1,96 +0,0 @@
1
- import type { Fn, MaybePromise, Predicate } from "@thi.ng/api";
2
- import { type HashAlgo } from "@thi.ng/file-io";
3
- import type { OutgoingHttpHeaders } from "node:http";
4
- import type { Interceptor, RequestCtx, ServerRoute } from "./api.js";
5
- /**
6
- * Static file configuration options.
7
- */
8
- export interface StaticOpts<CTX extends RequestCtx = RequestCtx> {
9
- /**
10
- * Path to local root directory for static assets. Also see
11
- * {@link StaticOpts.prefix}
12
- *
13
- * @defaultValue `.` (current cwd)
14
- */
15
- rootDir: string;
16
- /**
17
- * Filter predicate to exclude files from being served. Called with the
18
- * absolute local file path. If the function returns false, the server
19
- * produces a 404 response. By default all files (within
20
- * {@link StaticOpts.rootDir}) will be allowed.
21
- */
22
- filter: Predicate<string>;
23
- /**
24
- * Base URL prefix for static assets. Also see {@link StaticOpts.rootDir}
25
- *
26
- * @defaultValue "static"
27
- */
28
- prefix: string;
29
- /**
30
- * Additional route specific interceptors.
31
- */
32
- intercept: Interceptor<CTX>[];
33
- /**
34
- * Additional common headers (e.g. cache control) for all static files
35
- */
36
- headers: OutgoingHttpHeaders;
37
- /**
38
- * If true (default: false), files will be served with brotli, gzip or deflate
39
- * compression (if the client supports it).
40
- *
41
- * @defaultValue false
42
- */
43
- compress: boolean;
44
- /**
45
- * User defined function to compute an Etag value for given file path. The
46
- * file is guaranteed to exist when this function is called.
47
- */
48
- etag: Fn<string, MaybePromise<string>>;
49
- /**
50
- * If true, the route will have its `auth` flag enabled, e.g. for use with
51
- * the {@link authenticateWith} interceptor.
52
- *
53
- * @defaultValue false
54
- */
55
- auth: boolean;
56
- }
57
- /**
58
- * Defines a configurable {@link ServerRoute} and handler for serving static
59
- * files from a local directory (optionally with compression, see
60
- * {@link StaticOpts.compress} for details).
61
- *
62
- * @param opts
63
- */
64
- export declare const staticFiles: <CTX extends RequestCtx = RequestCtx>({
65
- prefix,
66
- rootDir,
67
- intercept,
68
- filter,
69
- compress,
70
- auth,
71
- etag,
72
- headers,
73
- }?: Partial<StaticOpts<CTX>>) => ServerRoute<CTX>;
74
- /**
75
- * Etag header value function for {@link StaticOpts.etag}. Computes Etag based
76
- * on file modified date.
77
- *
78
- * @remarks
79
- * Also see {@link etagFileHash}.
80
- *
81
- * @param path
82
- */
83
- export declare const etagFileTimeModified: (path: string) => string;
84
- /**
85
- * Higher-order Etag header value function for {@link StaticOpts.etag}. Computes
86
- * Etag value by computing the hash digest of a given file. Uses MD5 by default.
87
- *
88
- * @remarks
89
- * Also see {@link etagFileTimeModified}.
90
- *
91
- * @param algo
92
- */
93
- export declare const etagFileHash: (
94
- algo?: HashAlgo
95
- ) => (path: string) => Promise<string>;
96
- //# sourceMappingURL=static.d.ts.map
@@ -1,73 +0,0 @@
1
- import { fileHash as $fileHash } from "@thi.ng/file-io";
2
- import { preferredTypeForPath } from "@thi.ng/mime";
3
- import { existsSync, statSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { isUnmodified } from "../utils/cache.js";
6
- const staticFiles = ({
7
- prefix = "static",
8
- rootDir = ".",
9
- intercept = [],
10
- filter = () => true,
11
- compress = false,
12
- auth = false,
13
- etag,
14
- headers
15
- } = {}) => ({
16
- id: "__static",
17
- match: [prefix, "+"],
18
- auth,
19
- handlers: {
20
- head: {
21
- fn: async (ctx) => {
22
- const path = join(rootDir, ...ctx.match.rest);
23
- ctx.logger.debug("path", path);
24
- const $headers = await __fileHeaders(
25
- path,
26
- ctx,
27
- filter,
28
- etag,
29
- headers
30
- );
31
- if (!$headers) return;
32
- ctx.res.writeHead(200, {
33
- "content-type": preferredTypeForPath(path),
34
- ...$headers
35
- });
36
- },
37
- intercept
38
- },
39
- get: {
40
- fn: async (ctx) => {
41
- const path = join(rootDir, ...ctx.match.rest);
42
- ctx.logger.debug("path", path);
43
- const $headers = await __fileHeaders(
44
- path,
45
- ctx,
46
- filter,
47
- etag,
48
- headers
49
- );
50
- if (!$headers) return;
51
- return ctx.server.sendFile(ctx, path, $headers, compress);
52
- },
53
- intercept
54
- }
55
- }
56
- });
57
- const __fileHeaders = async (path, ctx, filter, etag, headers) => {
58
- if (!(existsSync(path) && filter(path))) {
59
- return ctx.res.missing();
60
- }
61
- if (etag) {
62
- const etagValue = await etag(path);
63
- return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...headers, etag: etagValue };
64
- }
65
- return { ...headers };
66
- };
67
- const etagFileTimeModified = (path) => String(statSync(path).mtimeMs);
68
- const etagFileHash = (algo = "md5") => (path) => $fileHash(path, void 0, algo);
69
- export {
70
- etagFileHash,
71
- etagFileTimeModified,
72
- staticFiles
73
- };