@naturalcycles/backend-lib 9.75.0 → 9.77.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/dist/index.d.ts CHANGED
@@ -18,4 +18,5 @@ export type * from './server/server.model.js';
18
18
  export * from './server/serverStatsMiddleware.js';
19
19
  export * from './server/serverStatusMiddleware.js';
20
20
  export * from './server/simpleRequestLoggerMiddleware.js';
21
+ export * from './server/trace.util.js';
21
22
  export * from './util.js';
package/dist/index.js CHANGED
@@ -17,4 +17,5 @@ export * from './server/safeJsonMiddleware.js';
17
17
  export * from './server/serverStatsMiddleware.js';
18
18
  export * from './server/serverStatusMiddleware.js';
19
19
  export * from './server/simpleRequestLoggerMiddleware.js';
20
+ export * from './server/trace.util.js';
20
21
  export * from './util.js';
@@ -14,6 +14,7 @@ export function basicAuthMiddleware(cfg) {
14
14
  res
15
15
  .set('WWW-Authenticate', `Basic${realm ? ` realm="${realm}"` : ''}`)
16
16
  .status(401)
17
+ .type('text/plain')
17
18
  .send('Unauthorized');
18
19
  };
19
20
  }
@@ -5,6 +5,7 @@ import { _safeJsonStringify } from '@naturalcycles/js-lib/string/safeJsonStringi
5
5
  import { _objectAssign } from '@naturalcycles/js-lib/types';
6
6
  import { logEntryToString } from '@naturalcycles/nodejs-lib';
7
7
  import { dimGrey } from '@naturalcycles/nodejs-lib/colors';
8
+ import { getRequestTraceId } from './trace.util.js';
8
9
  const { GOOGLE_CLOUD_PROJECT, GAE_INSTANCE, K_SERVICE, APP_ENV } = process.env;
9
10
  const isGAE = !!GAE_INSTANCE;
10
11
  const isCloudRun = !!K_SERVICE;
@@ -75,11 +76,11 @@ export function logMiddleware() {
75
76
  // gcloud run deploy my-service \
76
77
  // --update-env-vars=GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project)
77
78
  if (GOOGLE_CLOUD_PROJECT) {
78
- const traceHeader = req.header('x-cloud-trace-context');
79
- if (traceHeader) {
80
- const [trace] = traceHeader.split('/');
81
- meta['logging.googleapis.com/trace'] = `projects/${GOOGLE_CLOUD_PROJECT}/traces/${trace}`;
82
- req.requestId = trace;
79
+ const traceId = getRequestTraceId(req);
80
+ if (traceId) {
81
+ meta['logging.googleapis.com/trace'] =
82
+ `projects/${GOOGLE_CLOUD_PROJECT}/traces/${traceId}`;
83
+ req.requestId = traceId;
83
84
  }
84
85
  }
85
86
  if (isGAE) {
@@ -9,5 +9,19 @@ import type { BackendRequest } from './server.model.js';
9
9
  *
10
10
  * If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
11
11
  * it will be stripped from the beginning of the path.
12
+ *
13
+ * The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
14
+ * (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
15
+ * `req.route.path` is the server-defined route pattern, so it's used as-is.
16
+ *
17
+ * Use `getRequestEndpointUnsafe` if the exact path is needed.
12
18
  */
13
19
  export declare function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): string;
20
+ /**
21
+ * Same as `getRequestEndpoint`, but without sanitization:
22
+ * for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
23
+ *
24
+ * Use at your own risk: the result must be escaped before it's embedded in html
25
+ * (or any other context where it could be interpreted).
26
+ */
27
+ export declare function getRequestEndpointUnsafe(req: BackendRequest, stripPrefix?: string): string;
@@ -8,9 +8,28 @@
8
8
  *
9
9
  * If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
10
10
  * it will be stripped from the beginning of the path.
11
+ *
12
+ * The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
13
+ * (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
14
+ * `req.route.path` is the server-defined route pattern, so it's used as-is.
15
+ *
16
+ * Use `getRequestEndpointUnsafe` if the exact path is needed.
11
17
  */
12
18
  export function getRequestEndpoint(req, stripPrefix) {
13
- let path = (req.baseUrl + (req.route?.path || req.path)).toLowerCase();
19
+ return buildRequestEndpoint(req, stripPrefix, sanitizePath);
20
+ }
21
+ /**
22
+ * Same as `getRequestEndpoint`, but without sanitization:
23
+ * for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
24
+ *
25
+ * Use at your own risk: the result must be escaped before it's embedded in html
26
+ * (or any other context where it could be interpreted).
27
+ */
28
+ export function getRequestEndpointUnsafe(req, stripPrefix) {
29
+ return buildRequestEndpoint(req, stripPrefix, identity);
30
+ }
31
+ function buildRequestEndpoint(req, stripPrefix, sanitize) {
32
+ let path = (sanitize(req.baseUrl) + (req.route?.path || sanitize(req.path))).toLowerCase();
14
33
  if (path.length > 1 && path.endsWith('/')) {
15
34
  path = path.slice(0, path.length - 1);
16
35
  }
@@ -19,3 +38,11 @@ export function getRequestEndpoint(req, stripPrefix) {
19
38
  }
20
39
  return [req.method, path].join(' ');
21
40
  }
41
+ // Letters, digits and the url punctuation that is inert in html, js, json and logs.
42
+ // Everything else (< > & " ' ( ) ` \ control chars, etc) is replaced, not encoded:
43
+ // the exact bytes are not worth preserving here, `req.url` still has them.
44
+ const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu;
45
+ function sanitizePath(path) {
46
+ return path.replace(UNSAFE_PATH_CHAR, '_');
47
+ }
48
+ const identity = (path) => path;
@@ -13,6 +13,13 @@ export interface RequestTimeoutMiddlewareCfg {
13
13
  * @default 'Request timed out'
14
14
  */
15
15
  httpErrorMessage?: string;
16
+ /**
17
+ * Upper bound for the `?requestTimeout=N` query override.
18
+ * Higher values are clamped to it, so a client cannot hold a request open for arbitrarily long.
19
+ *
20
+ * @default 600
21
+ */
22
+ maxTimeoutSeconds?: NumberOfSeconds;
16
23
  }
17
24
  export declare function requestTimeoutMiddleware(cfg?: RequestTimeoutMiddlewareCfg): BackendRequestHandler;
18
25
  export interface CustomRequestTimeoutMiddlewareCfg {
@@ -25,7 +32,7 @@ export interface CustomRequestTimeoutMiddlewareCfg {
25
32
  * Example:
26
33
  *
27
34
  * router.get('/', customRequestTimeoutMiddleware(
28
- * (req, res) => res.status(409).send('my custom message!'),
35
+ * (req, res) => res.status(409).type('text/plain').send('my custom message!'),
29
36
  * { timeoutSeconds: 30 },
30
37
  * )
31
38
  */
@@ -6,7 +6,7 @@ import { getRequestEndpoint } from './request.util.js';
6
6
  const code = 'REQUEST_TIMEOUT';
7
7
  const REQUEST_TIMEOUT_QUERY_KEY = 'requestTimeout';
8
8
  export function requestTimeoutMiddleware(cfg = {}) {
9
- const { timeoutSeconds: defTimeoutSeconds, backendResponseStatusCode, httpErrorMessage, } = {
9
+ const { timeoutSeconds: defTimeoutSeconds, backendResponseStatusCode, httpErrorMessage, maxTimeoutSeconds, } = {
10
10
  // Considerations about the default value of the timeout.
11
11
  // Ideally the default value here would be HIGHER than the default timeout for getGot (in nodejs-lib),
12
12
  // so, cross-service communication has a chance to fail SOONER than server times out,
@@ -14,12 +14,11 @@ export function requestTimeoutMiddleware(cfg = {}) {
14
14
  timeoutSeconds: 120,
15
15
  backendResponseStatusCode: 503,
16
16
  httpErrorMessage: 'Request timed out',
17
+ maxTimeoutSeconds: 600,
17
18
  ...cfg,
18
19
  };
19
20
  return function requestTimeoutHandler(req, res, next) {
20
- const timeoutSeconds = req.query[REQUEST_TIMEOUT_QUERY_KEY]
21
- ? Number.parseInt(req.query[REQUEST_TIMEOUT_QUERY_KEY])
22
- : defTimeoutSeconds;
21
+ const timeoutSeconds = getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds);
23
22
  // If requestTimeout was previously set - cancel it first
24
23
  // Then set the new requestTimeout and handler
25
24
  if (req.requestTimeout)
@@ -39,11 +38,26 @@ export function requestTimeoutMiddleware(cfg = {}) {
39
38
  next();
40
39
  };
41
40
  }
41
+ /**
42
+ * `?requestTimeout=N` query param lets the client override the timeout of its request
43
+ * (used e.g by Cloud Tasks for long-running tasks).
44
+ * Being client-controlled, it is validated: non-numeric or non-positive values are ignored
45
+ * (fall back to the default), values above `maxTimeoutSeconds` are clamped.
46
+ */
47
+ function getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds) {
48
+ const raw = req.query[REQUEST_TIMEOUT_QUERY_KEY];
49
+ if (typeof raw !== 'string')
50
+ return defTimeoutSeconds;
51
+ const requested = Number.parseInt(raw, 10);
52
+ if (Number.isNaN(requested) || requested <= 0)
53
+ return defTimeoutSeconds;
54
+ return Math.min(requested, maxTimeoutSeconds);
55
+ }
42
56
  /**
43
57
  * Example:
44
58
  *
45
59
  * router.get('/', customRequestTimeoutMiddleware(
46
- * (req, res) => res.status(409).send('my custom message!'),
60
+ * (req, res) => res.status(409).type('text/plain').send('my custom message!'),
47
61
  * { timeoutSeconds: 30 },
48
62
  * )
49
63
  */
@@ -5,6 +5,7 @@ import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js';
5
5
  import { _percentile } from '@naturalcycles/js-lib/math/math.util.js';
6
6
  import { NumberStack } from '@naturalcycles/js-lib/math/stack.util.js';
7
7
  import { _get, _mapValues } from '@naturalcycles/js-lib/object/object.util.js';
8
+ import { htmlEscape } from '@naturalcycles/js-lib/string/escape.js';
8
9
  import { _stringMapEntries, _stringMapValues } from '@naturalcycles/js-lib/types';
9
10
  import { onFinished } from '../onFinished.js';
10
11
  import { getRequestEndpoint } from './request.util.js';
@@ -61,7 +62,7 @@ export const serverStatsHTMLHandler = (req, res) => {
61
62
  }).map(([endpoint, stat]) => {
62
63
  return [
63
64
  '<tr>',
64
- `<td><pre>${endpoint}</pre></td>`,
65
+ `<td><pre>${htmlEscape(endpoint)}</pre></td>`,
65
66
  `<td align="right"><pre>${stat.total}</pre></td>`,
66
67
  // oxlint-disable-next-line @typescript-eslint/no-base-to-string
67
68
  ...families.map(f => `<td align="right"><pre>${stat[f]}</pre></td>`),
@@ -71,7 +72,7 @@ export const serverStatsHTMLHandler = (req, res) => {
71
72
  }),
72
73
  '</table>',
73
74
  ].join('\n');
74
- res.send(html);
75
+ res.type('html').send(html);
75
76
  };
76
77
  /**
77
78
  * Unlocks serverStatsHTMLHandler
@@ -83,7 +84,12 @@ export function serverStatsMiddleware() {
83
84
  onFinished(res, () => {
84
85
  const now = Date.now();
85
86
  const latency = now - started;
86
- const endpoint = getRequestEndpoint(req);
87
+ // Only requests that matched a route are recorded by their route pattern.
88
+ // Unmatched requests (404s, requests rejected by router-level middleware) carry an
89
+ // arbitrary client-controlled path: recording them per-path would let any client
90
+ // grow the map unboundedly and plant arbitrary strings into it.
91
+ // So they're bucketed per mount point instead.
92
+ const endpoint = req.route ? getRequestEndpoint(req) : getUnmatchedEndpoint(req);
87
93
  serverStatsMap[endpoint] ||= {
88
94
  stack: new NumberStack(SIZE),
89
95
  '2xx': 0,
@@ -102,6 +108,13 @@ export function serverStatsMiddleware() {
102
108
  next();
103
109
  };
104
110
  }
111
+ /**
112
+ * Returns e.g `GET /*` or `GET /api/v3/admin/*`.
113
+ * `req.baseUrl` is the mount point that matched, so it's bounded by the app's routing config.
114
+ */
115
+ function getUnmatchedEndpoint(req) {
116
+ return `${req.method} ${req.baseUrl.toLowerCase()}/*`;
117
+ }
105
118
  function cleanupServerStats() {
106
119
  if (Object.keys(serverStatsMap).length <= MAX_ENDPOINTS)
107
120
  return;
@@ -0,0 +1,16 @@
1
+ import type { BackendRequest } from './server.model.js';
2
+ /**
3
+ * The trace a request belongs to, as carried by the W3C `traceparent` header.
4
+ */
5
+ export interface TraceContext {
6
+ traceId: string;
7
+ spanId: string;
8
+ sampled: boolean;
9
+ }
10
+ /**
11
+ * The trace id of the request, from `traceparent`. Cached per request, so it is cheap to call on
12
+ * every log line.
13
+ */
14
+ export declare function getRequestTraceId(req: BackendRequest): string | undefined;
15
+ export declare function getRequestTraceContext(req: BackendRequest): TraceContext | undefined;
16
+ export declare function parseTraceparent(header: string | undefined): TraceContext | undefined;
@@ -0,0 +1,35 @@
1
+ // https://www.w3.org/TR/trace-context/#traceparent-header
2
+ // Version `ff` is invalid. Higher versions may append `-` fields, which are ignored (forward
3
+ // compatibility), while version `00` must not have any.
4
+ const TRACEPARENT_REGEX = /^(?!ff)([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-.*)?$/;
5
+ const INVALID_TRACE_ID = '0'.repeat(32);
6
+ const INVALID_SPAN_ID = '0'.repeat(16);
7
+ const traceIdByRequest = new WeakMap();
8
+ /**
9
+ * The trace id of the request, from `traceparent`. Cached per request, so it is cheap to call on
10
+ * every log line.
11
+ */
12
+ export function getRequestTraceId(req) {
13
+ let traceId = traceIdByRequest.get(req);
14
+ if (traceId === undefined) {
15
+ traceId = getRequestTraceContext(req)?.traceId ?? null;
16
+ traceIdByRequest.set(req, traceId);
17
+ }
18
+ return traceId ?? undefined;
19
+ }
20
+ export function getRequestTraceContext(req) {
21
+ // Reads from `req.headers` and not `req.header()`, so a bare request object (as in tests) works too
22
+ const header = req.headers?.['traceparent'];
23
+ return parseTraceparent(typeof header === 'string' ? header : undefined);
24
+ }
25
+ export function parseTraceparent(header) {
26
+ const match = header ? TRACEPARENT_REGEX.exec(header) : null;
27
+ if (!match)
28
+ return;
29
+ const [, version, traceId, spanId, flags, extension] = match;
30
+ if (version === '00' && extension !== undefined)
31
+ return;
32
+ if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID)
33
+ return;
34
+ return { traceId, spanId, sampled: Number.parseInt(flags, 16) % 2 === 1 };
35
+ }
@@ -75,7 +75,9 @@ async function parseMultipart(req, maxFileSize) {
75
75
  stream.on('close', () => {
76
76
  const data = Buffer.concat(chunks);
77
77
  files[name] = {
78
- name: info.filename,
78
+ // busboy reports `filename` as undefined for a bare `application/octet-stream` part with
79
+ // no filename; normalize to `""`, same as a blank file input (`filename=""`) yields.
80
+ name: info.filename ?? '',
79
81
  mimeType: info.mimeType,
80
82
  encoding: info.encoding,
81
83
  data,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.75.0",
4
+ "version": "9.77.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -63,7 +63,8 @@
63
63
  })
64
64
 
65
65
  const qs = Object.fromEntries(new URLSearchParams(location.search))
66
- const { autoLogin, logout, returnUrl, adminTokenKey = 'admin_token' } = qs
66
+ const { autoLogin, logout, adminTokenKey = 'admin_token' } = qs
67
+ const returnUrl = toSafeReturnUrl(qs.returnUrl)
67
68
  // console.log(qs)
68
69
 
69
70
  const app = createApp({
@@ -137,6 +138,20 @@
137
138
  alert('Error\n ' + JSON.stringify(err, null, 2))
138
139
  }
139
140
 
141
+ /**
142
+ * `returnUrl` is client-controlled. Only http(s) urls are allowed:
143
+ * anything else (e.g `javascript:`) would otherwise be executed by `location.href = returnUrl`
144
+ * in the context of this page, where the token is exposed.
145
+ */
146
+ function toSafeReturnUrl(url) {
147
+ if (!url) return
148
+ try {
149
+ const { protocol } = new URL(url, location.href)
150
+ if (protocol === 'http:' || protocol === 'https:') return url
151
+ } catch {}
152
+ console.warn('ignoring invalid returnUrl', url)
153
+ }
154
+
140
155
  async function postToken(token) {
141
156
  await fetch(`/admin/login`, {
142
157
  method: 'post',
package/src/index.ts CHANGED
@@ -18,4 +18,5 @@ export type * from './server/server.model.js'
18
18
  export * from './server/serverStatsMiddleware.js'
19
19
  export * from './server/serverStatusMiddleware.js'
20
20
  export * from './server/simpleRequestLoggerMiddleware.js'
21
+ export * from './server/trace.util.js'
21
22
  export * from './util.js'
@@ -31,6 +31,7 @@ export function basicAuthMiddleware(cfg: BasicAuthMiddlewareCfg): BackendRequest
31
31
  res
32
32
  .set('WWW-Authenticate', `Basic${realm ? ` realm="${realm}"` : ''}`)
33
33
  .status(401)
34
+ .type('text/plain')
34
35
  .send('Unauthorized')
35
36
  }
36
37
  }
@@ -8,6 +8,7 @@ import type { AnyObject } from '@naturalcycles/js-lib/types'
8
8
  import { logEntryToString } from '@naturalcycles/nodejs-lib'
9
9
  import { dimGrey } from '@naturalcycles/nodejs-lib/colors'
10
10
  import type { BackendRequestHandler } from './server.model.js'
11
+ import { getRequestTraceId } from './trace.util.js'
11
12
 
12
13
  const { GOOGLE_CLOUD_PROJECT, GAE_INSTANCE, K_SERVICE, APP_ENV } = process.env
13
14
  const isGAE = !!GAE_INSTANCE
@@ -88,11 +89,11 @@ export function logMiddleware(): BackendRequestHandler {
88
89
  // gcloud run deploy my-service \
89
90
  // --update-env-vars=GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project)
90
91
  if (GOOGLE_CLOUD_PROJECT) {
91
- const traceHeader = req.header('x-cloud-trace-context')
92
- if (traceHeader) {
93
- const [trace] = traceHeader.split('/')
94
- meta['logging.googleapis.com/trace'] = `projects/${GOOGLE_CLOUD_PROJECT}/traces/${trace}`
95
- req.requestId = trace
92
+ const traceId = getRequestTraceId(req)
93
+ if (traceId) {
94
+ meta['logging.googleapis.com/trace'] =
95
+ `projects/${GOOGLE_CLOUD_PROJECT}/traces/${traceId}`
96
+ req.requestId = traceId
96
97
  }
97
98
  }
98
99
  if (isGAE) {
@@ -10,9 +10,34 @@ import type { BackendRequest } from './server.model.js'
10
10
  *
11
11
  * If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
12
12
  * it will be stripped from the beginning of the path.
13
+ *
14
+ * The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
15
+ * (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
16
+ * `req.route.path` is the server-defined route pattern, so it's used as-is.
17
+ *
18
+ * Use `getRequestEndpointUnsafe` if the exact path is needed.
13
19
  */
14
20
  export function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): string {
15
- let path = (req.baseUrl + (req.route?.path || req.path)).toLowerCase()
21
+ return buildRequestEndpoint(req, stripPrefix, sanitizePath)
22
+ }
23
+
24
+ /**
25
+ * Same as `getRequestEndpoint`, but without sanitization:
26
+ * for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
27
+ *
28
+ * Use at your own risk: the result must be escaped before it's embedded in html
29
+ * (or any other context where it could be interpreted).
30
+ */
31
+ export function getRequestEndpointUnsafe(req: BackendRequest, stripPrefix?: string): string {
32
+ return buildRequestEndpoint(req, stripPrefix, identity)
33
+ }
34
+
35
+ function buildRequestEndpoint(
36
+ req: BackendRequest,
37
+ stripPrefix: string | undefined,
38
+ sanitize: (path: string) => string,
39
+ ): string {
40
+ let path = (sanitize(req.baseUrl) + (req.route?.path || sanitize(req.path))).toLowerCase()
16
41
  if (path.length > 1 && path.endsWith('/')) {
17
42
  path = path.slice(0, path.length - 1)
18
43
  }
@@ -23,3 +48,14 @@ export function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): s
23
48
 
24
49
  return [req.method, path].join(' ')
25
50
  }
51
+
52
+ // Letters, digits and the url punctuation that is inert in html, js, json and logs.
53
+ // Everything else (< > & " ' ( ) ` \ control chars, etc) is replaced, not encoded:
54
+ // the exact bytes are not worth preserving here, `req.url` still has them.
55
+ const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu
56
+
57
+ function sanitizePath(path: string): string {
58
+ return path.replace(UNSAFE_PATH_CHAR, '_')
59
+ }
60
+
61
+ const identity = (path: string): string => path
@@ -21,6 +21,14 @@ export interface RequestTimeoutMiddlewareCfg {
21
21
  * @default 'Request timed out'
22
22
  */
23
23
  httpErrorMessage?: string
24
+
25
+ /**
26
+ * Upper bound for the `?requestTimeout=N` query override.
27
+ * Higher values are clamped to it, so a client cannot hold a request open for arbitrarily long.
28
+ *
29
+ * @default 600
30
+ */
31
+ maxTimeoutSeconds?: NumberOfSeconds
24
32
  }
25
33
 
26
34
  const code = 'REQUEST_TIMEOUT'
@@ -33,6 +41,7 @@ export function requestTimeoutMiddleware(
33
41
  timeoutSeconds: defTimeoutSeconds,
34
42
  backendResponseStatusCode,
35
43
  httpErrorMessage,
44
+ maxTimeoutSeconds,
36
45
  } = {
37
46
  // Considerations about the default value of the timeout.
38
47
  // Ideally the default value here would be HIGHER than the default timeout for getGot (in nodejs-lib),
@@ -41,13 +50,12 @@ export function requestTimeoutMiddleware(
41
50
  timeoutSeconds: 120,
42
51
  backendResponseStatusCode: 503,
43
52
  httpErrorMessage: 'Request timed out',
53
+ maxTimeoutSeconds: 600,
44
54
  ...cfg,
45
55
  }
46
56
 
47
57
  return function requestTimeoutHandler(req, res, next) {
48
- const timeoutSeconds = req.query[REQUEST_TIMEOUT_QUERY_KEY]
49
- ? Number.parseInt(req.query[REQUEST_TIMEOUT_QUERY_KEY] as string)
50
- : defTimeoutSeconds
58
+ const timeoutSeconds = getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds)
51
59
 
52
60
  // If requestTimeout was previously set - cancel it first
53
61
  // Then set the new requestTimeout and handler
@@ -76,6 +84,26 @@ export function requestTimeoutMiddleware(
76
84
  }
77
85
  }
78
86
 
87
+ /**
88
+ * `?requestTimeout=N` query param lets the client override the timeout of its request
89
+ * (used e.g by Cloud Tasks for long-running tasks).
90
+ * Being client-controlled, it is validated: non-numeric or non-positive values are ignored
91
+ * (fall back to the default), values above `maxTimeoutSeconds` are clamped.
92
+ */
93
+ function getTimeoutSeconds(
94
+ req: BackendRequest,
95
+ defTimeoutSeconds: NumberOfSeconds,
96
+ maxTimeoutSeconds: NumberOfSeconds,
97
+ ): NumberOfSeconds {
98
+ const raw = req.query[REQUEST_TIMEOUT_QUERY_KEY]
99
+ if (typeof raw !== 'string') return defTimeoutSeconds
100
+
101
+ const requested = Number.parseInt(raw, 10)
102
+ if (Number.isNaN(requested) || requested <= 0) return defTimeoutSeconds
103
+
104
+ return Math.min(requested, maxTimeoutSeconds)
105
+ }
106
+
79
107
  export interface CustomRequestTimeoutMiddlewareCfg {
80
108
  /**
81
109
  * @default 120
@@ -87,7 +115,7 @@ export interface CustomRequestTimeoutMiddlewareCfg {
87
115
  * Example:
88
116
  *
89
117
  * router.get('/', customRequestTimeoutMiddleware(
90
- * (req, res) => res.status(409).send('my custom message!'),
118
+ * (req, res) => res.status(409).type('text/plain').send('my custom message!'),
91
119
  * { timeoutSeconds: 30 },
92
120
  * )
93
121
  */
@@ -5,11 +5,12 @@ import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js'
5
5
  import { _percentile } from '@naturalcycles/js-lib/math/math.util.js'
6
6
  import { NumberStack } from '@naturalcycles/js-lib/math/stack.util.js'
7
7
  import { _get, _mapValues } from '@naturalcycles/js-lib/object/object.util.js'
8
+ import { htmlEscape } from '@naturalcycles/js-lib/string/escape.js'
8
9
  import { _stringMapEntries, _stringMapValues } from '@naturalcycles/js-lib/types'
9
10
  import type { StringMap } from '@naturalcycles/js-lib/types'
10
11
  import { onFinished } from '../onFinished.js'
11
12
  import { getRequestEndpoint } from './request.util.js'
12
- import type { BackendRequestHandler } from './server.model.js'
13
+ import type { BackendRequest, BackendRequestHandler } from './server.model.js'
13
14
 
14
15
  const { GAE_INSTANCE } = process.env
15
16
 
@@ -86,7 +87,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
86
87
  }).map(([endpoint, stat]) => {
87
88
  return [
88
89
  '<tr>',
89
- `<td><pre>${endpoint}</pre></td>`,
90
+ `<td><pre>${htmlEscape(endpoint)}</pre></td>`,
90
91
  `<td align="right"><pre>${stat.total}</pre></td>`,
91
92
  // oxlint-disable-next-line @typescript-eslint/no-base-to-string
92
93
  ...families.map(f => `<td align="right"><pre>${stat[f]}</pre></td>`),
@@ -97,7 +98,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
97
98
  '</table>',
98
99
  ].join('\n')
99
100
 
100
- res.send(html)
101
+ res.type('html').send(html)
101
102
  }
102
103
 
103
104
  /**
@@ -113,7 +114,12 @@ export function serverStatsMiddleware(): BackendRequestHandler {
113
114
  const now = Date.now()
114
115
  const latency = now - started
115
116
 
116
- const endpoint = getRequestEndpoint(req)
117
+ // Only requests that matched a route are recorded by their route pattern.
118
+ // Unmatched requests (404s, requests rejected by router-level middleware) carry an
119
+ // arbitrary client-controlled path: recording them per-path would let any client
120
+ // grow the map unboundedly and plant arbitrary strings into it.
121
+ // So they're bucketed per mount point instead.
122
+ const endpoint = req.route ? getRequestEndpoint(req) : getUnmatchedEndpoint(req)
117
123
 
118
124
  serverStatsMap[endpoint] ||= {
119
125
  stack: new NumberStack(SIZE),
@@ -137,6 +143,14 @@ export function serverStatsMiddleware(): BackendRequestHandler {
137
143
  }
138
144
  }
139
145
 
146
+ /**
147
+ * Returns e.g `GET /*` or `GET /api/v3/admin/*`.
148
+ * `req.baseUrl` is the mount point that matched, so it's bounded by the app's routing config.
149
+ */
150
+ function getUnmatchedEndpoint(req: BackendRequest): string {
151
+ return `${req.method} ${req.baseUrl.toLowerCase()}/*`
152
+ }
153
+
140
154
  function cleanupServerStats(): void {
141
155
  if (Object.keys(serverStatsMap).length <= MAX_ENDPOINTS) return
142
156
 
@@ -0,0 +1,54 @@
1
+ import type { BackendRequest } from './server.model.js'
2
+
3
+ /**
4
+ * The trace a request belongs to, as carried by the W3C `traceparent` header.
5
+ */
6
+ export interface TraceContext {
7
+ traceId: string
8
+ spanId: string
9
+ sampled: boolean
10
+ }
11
+
12
+ // https://www.w3.org/TR/trace-context/#traceparent-header
13
+ // Version `ff` is invalid. Higher versions may append `-` fields, which are ignored (forward
14
+ // compatibility), while version `00` must not have any.
15
+ const TRACEPARENT_REGEX = /^(?!ff)([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-.*)?$/
16
+ const INVALID_TRACE_ID = '0'.repeat(32)
17
+ const INVALID_SPAN_ID = '0'.repeat(16)
18
+
19
+ const traceIdByRequest = new WeakMap<BackendRequest, string | null>()
20
+
21
+ /**
22
+ * The trace id of the request, from `traceparent`. Cached per request, so it is cheap to call on
23
+ * every log line.
24
+ */
25
+ export function getRequestTraceId(req: BackendRequest): string | undefined {
26
+ let traceId = traceIdByRequest.get(req)
27
+ if (traceId === undefined) {
28
+ traceId = getRequestTraceContext(req)?.traceId ?? null
29
+ traceIdByRequest.set(req, traceId)
30
+ }
31
+ return traceId ?? undefined
32
+ }
33
+
34
+ export function getRequestTraceContext(req: BackendRequest): TraceContext | undefined {
35
+ // Reads from `req.headers` and not `req.header()`, so a bare request object (as in tests) works too
36
+ const header = req.headers?.['traceparent']
37
+ return parseTraceparent(typeof header === 'string' ? header : undefined)
38
+ }
39
+
40
+ export function parseTraceparent(header: string | undefined): TraceContext | undefined {
41
+ const match = header ? TRACEPARENT_REGEX.exec(header) : null
42
+ if (!match) return
43
+ const [, version, traceId, spanId, flags, extension] = match as unknown as [
44
+ string,
45
+ string,
46
+ string,
47
+ string,
48
+ string,
49
+ string?,
50
+ ]
51
+ if (version === '00' && extension !== undefined) return
52
+ if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID) return
53
+ return { traceId, spanId, sampled: Number.parseInt(flags, 16) % 2 === 1 }
54
+ }
@@ -102,7 +102,9 @@ async function parseMultipart(
102
102
  stream.on('close', () => {
103
103
  const data = Buffer.concat(chunks)
104
104
  files[name] = {
105
- name: info.filename,
105
+ // busboy reports `filename` as undefined for a bare `application/octet-stream` part with
106
+ // no filename; normalize to `""`, same as a blank file input (`filename=""`) yields.
107
+ name: info.filename ?? '',
106
108
  mimeType: info.mimeType,
107
109
  encoding: info.encoding,
108
110
  data,