@datocms/rest-client-utils 5.8.0 → 6.1.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.
@@ -0,0 +1,105 @@
1
+ import { ApiError, TimeoutError } from '../errors';
2
+ import { request } from '../request';
3
+
4
+ const API_TOKEN = 'aaaabbbbccccdddd';
5
+
6
+ function jsonResponse(status: number, body: unknown) {
7
+ return new Response(JSON.stringify(body), {
8
+ status,
9
+ statusText: 'Unprocessable Entity',
10
+ headers: { 'Content-Type': 'application/json' },
11
+ });
12
+ }
13
+
14
+ function buildOptions(fetchFn: typeof fetch) {
15
+ return {
16
+ baseUrl: 'https://site-api.datocms.com',
17
+ fetchJobResult: async () => {
18
+ throw new Error('not needed');
19
+ },
20
+ fetchFn,
21
+ apiToken: API_TOKEN,
22
+ method: 'GET' as const,
23
+ url: '/items/bad-id',
24
+ autoRetry: false,
25
+ };
26
+ }
27
+
28
+ describe('request()', () => {
29
+ it('sends the real API token, but keeps it out of the error', async () => {
30
+ let sentAuthorization: string | undefined;
31
+
32
+ const fetchFn = jest.fn(async (_url: unknown, init?: RequestInit) => {
33
+ sentAuthorization = (init?.headers as Record<string, string>)
34
+ ?.authorization;
35
+ return jsonResponse(422, { data: [] });
36
+ }) as unknown as typeof fetch;
37
+
38
+ const error: ApiError = await request(buildOptions(fetchFn)).then(
39
+ () => {
40
+ throw new Error('expected the request to fail');
41
+ },
42
+ (error) => error,
43
+ );
44
+
45
+ expect(error).toBeInstanceOf(ApiError);
46
+ expect(sentAuthorization).toBe(`Bearer ${API_TOKEN}`);
47
+ expect(error.request.headers.authorization).toBe(
48
+ '[REDACTED, ending in dddd]',
49
+ );
50
+ expect(JSON.stringify(error)).not.toContain(API_TOKEN);
51
+ });
52
+
53
+ it('keeps the token out of a timeout error too', async () => {
54
+ const fetchFn = (async () => {
55
+ const error: NodeJS.ErrnoException = new Error('timeout');
56
+ error.code = 'ETIMEDOUT';
57
+ throw error;
58
+ }) as unknown as typeof fetch;
59
+
60
+ const error: TimeoutError = await request({
61
+ ...buildOptions(fetchFn),
62
+ autoRetry: false,
63
+ }).then(
64
+ () => {
65
+ throw new Error('expected the request to fail');
66
+ },
67
+ (error) => error,
68
+ );
69
+
70
+ expect(error).toBeInstanceOf(TimeoutError);
71
+ expect(error.request.headers.authorization).toBe(
72
+ '[REDACTED, ending in dddd]',
73
+ );
74
+ });
75
+
76
+ it('does not spill the failed call through incidental serialization', async () => {
77
+ const fetchFn = (async () =>
78
+ jsonResponse(422, {
79
+ data: [],
80
+ })) as unknown as typeof fetch;
81
+
82
+ const error: ApiError = await request({
83
+ ...buildOptions(fetchFn),
84
+ body: {
85
+ data: { attributes: { url: 'https://example.com/?token=s3cr3t' } },
86
+ },
87
+ }).then(
88
+ () => {
89
+ throw new Error('expected the request to fail');
90
+ },
91
+ (error) => error,
92
+ );
93
+
94
+ // `request` and `response` stay readable...
95
+ expect(error.request.url).toContain('/items/bad-id');
96
+ expect(error.response.status).toBe(422);
97
+
98
+ // ...but no longer travel by accident: this is what `console.error()`,
99
+ // `serialize-error` and most error trackers walk.
100
+ expect(Object.keys(error)).not.toContain('request');
101
+ expect(Object.keys(error)).not.toContain('response');
102
+ expect(JSON.stringify(error)).not.toContain('s3cr3t');
103
+ expect(JSON.stringify({ ...error })).not.toContain('s3cr3t');
104
+ });
105
+ });
package/src/errors.ts CHANGED
@@ -72,6 +72,19 @@ export type ApiErrorResponse = {
72
72
  const TIMEOUT_ERROR = Symbol.for('@datocms/rest-client-utils:TimeoutError');
73
73
  const API_ERROR = Symbol.for('@datocms/rest-client-utils:ApiError');
74
74
 
75
+ /**
76
+ * Hides properties from anything that walks own enumerable keys —
77
+ * `console.error()`, `JSON.stringify()`, object spread, `serialize-error`,
78
+ * error trackers. Reading `error.request` explicitly keeps working exactly as
79
+ * before; the data just stops travelling by accident, since the request and
80
+ * response of a failed call can carry credentials and other secrets.
81
+ */
82
+ function hideProperties(target: object, keys: string[]) {
83
+ for (const key of keys) {
84
+ Object.defineProperty(target, key, { enumerable: false });
85
+ }
86
+ }
87
+
75
88
  export type TimeoutErrorInitObject = {
76
89
  request: ApiErrorRequest;
77
90
  preCallStack?: string;
@@ -126,6 +139,8 @@ export class TimeoutError extends Error {
126
139
  this.request = initObject.request;
127
140
  this.preCallStack = initObject.preCallStack;
128
141
 
142
+ hideProperties(this, ['request', 'preCallStack']);
143
+
129
144
  this.message = `${initObject.request.method} ${initObject.request.url}: Timeout error`;
130
145
 
131
146
  if (this.preCallStack) {
@@ -187,6 +202,8 @@ export class ApiError extends Error {
187
202
  this.response = initObject.response;
188
203
  this.preCallStack = initObject.preCallStack;
189
204
 
205
+ hideProperties(this, ['request', 'response', 'preCallStack']);
206
+
190
207
  let message = `${initObject.request.method} ${initObject.request.url}: ${this.response.status} ${this.response.statusText}`;
191
208
 
192
209
  if (this.errors.length > 0) {
package/src/request.ts CHANGED
@@ -68,6 +68,36 @@ function headersToObject(headers: Headers): Record<string, string> {
68
68
  return result;
69
69
  }
70
70
 
71
+ /**
72
+ * Headers whose value must never end up inside an error object.
73
+ *
74
+ * Right now this client only ever sets one of them, but the list is the point:
75
+ * a header added here is redacted everywhere an error can reach.
76
+ */
77
+ const SENSITIVE_HEADERS = ['authorization'];
78
+
79
+ /**
80
+ * Returns a copy of the request headers with every sensitive value blanked out.
81
+ *
82
+ * Errors travel: they get logged, serialized, sent to error trackers, and — as
83
+ * we learned the hard way — occasionally echoed back to an HTTP client. The
84
+ * real headers are only ever handed to `fetch()`; what we keep on the error is
85
+ * this redacted copy.
86
+ */
87
+ function redactHeaders(
88
+ headers: Record<string, string>,
89
+ ): Record<string, string> {
90
+ return Object.fromEntries(
91
+ Object.entries(headers).map(([key, value]) =>
92
+ SENSITIVE_HEADERS.includes(key.toLowerCase())
93
+ ? // The last 4 characters are enough to tell two tokens apart while
94
+ // debugging, and useless to whoever gets hold of the log.
95
+ [key, `[REDACTED, ending in ${value.slice(-4)}]`]
96
+ : [key, value],
97
+ ),
98
+ );
99
+ }
100
+
71
101
  function buildApiErrorInitObject(
72
102
  method: string,
73
103
  url: string,
@@ -191,6 +221,8 @@ export async function request<T>(options: RequestOptions): Promise<T> {
191
221
  delete headers['user-agent'];
192
222
  }
193
223
 
224
+ const redactedHeaders = redactHeaders(headers);
225
+
194
226
  const baseUrl = options.baseUrl.replace(/\/$/, '');
195
227
  const body = options.body ? JSON.stringify(options.body, null, 2) : undefined;
196
228
 
@@ -206,7 +238,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
206
238
  if (logLevel >= LogLevel.BASIC) {
207
239
  log(`[${requestId}] ${options.method} ${url}`);
208
240
  if (logLevel >= LogLevel.BODY_AND_HEADERS) {
209
- for (const [key, value] of Object.entries(headers || {})) {
241
+ for (const [key, value] of Object.entries(redactedHeaders)) {
210
242
  log(`[${requestId}] ${key}: ${value}`);
211
243
  }
212
244
  }
@@ -215,6 +247,10 @@ export async function request<T>(options: RequestOptions): Promise<T> {
215
247
  }
216
248
  }
217
249
 
250
+ // Declared out here so that the `finally` below can always clear it: a timer
251
+ // left pending keeps Node's event loop alive for as long as it runs.
252
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
253
+
218
254
  try {
219
255
  const requestPromise = makeCancelablePromise(
220
256
  fetchFn(url, {
@@ -224,14 +260,12 @@ export async function request<T>(options: RequestOptions): Promise<T> {
224
260
  }),
225
261
  );
226
262
 
227
- const timeoutId = setTimeout(() => {
263
+ timeoutId = setTimeout(() => {
228
264
  requestPromise.cancel();
229
265
  }, options.requestTimeout || 30000);
230
266
 
231
267
  const response = await requestPromise;
232
268
 
233
- clearTimeout(timeoutId);
234
-
235
269
  const responseContentType = response.headers.get('Content-Type');
236
270
  const invalidContentType =
237
271
  responseContentType && !responseContentType.includes('application/json');
@@ -242,7 +276,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
242
276
  buildApiErrorInitObject(
243
277
  options.method,
244
278
  url,
245
- headers,
279
+ redactedHeaders,
246
280
  options.body,
247
281
  response,
248
282
  undefined,
@@ -308,7 +342,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
308
342
  buildApiErrorInitObjectFromJobResult(
309
343
  options.method,
310
344
  url,
311
- headers,
345
+ redactedHeaders,
312
346
  options.body,
313
347
  jobResult.status,
314
348
  jobResult.payload,
@@ -328,7 +362,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
328
362
  buildApiErrorInitObject(
329
363
  options.method,
330
364
  url,
331
- headers,
365
+ redactedHeaders,
332
366
  options.body,
333
367
  response,
334
368
  responseBody,
@@ -373,7 +407,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
373
407
  buildTimeoutErrorInitObject(
374
408
  options.method,
375
409
  url,
376
- headers,
410
+ redactedHeaders,
377
411
  options.body,
378
412
  preCallStack,
379
413
  ),
@@ -381,5 +415,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
381
415
  }
382
416
 
383
417
  throw error;
418
+ } finally {
419
+ clearTimeout(timeoutId);
384
420
  }
385
421
  }