@doist/todoist-sdk 10.3.2 → 10.3.3

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.
@@ -7,6 +7,70 @@ const file_response_1 = require("../utils/file-response");
7
7
  const multipart_upload_1 = require("../utils/multipart-upload");
8
8
  const validators_1 = require("../utils/validators");
9
9
  const base_client_1 = require("./base-client");
10
+ /**
11
+ * First-party origin that serves attachment downloads behind Bearer auth.
12
+ * It redirects to the CDN; on the cross-origin hop the runtime drops the
13
+ * Authorization header, so the token never reaches the CDN.
14
+ */
15
+ const AUTHENTICATED_ATTACHMENT_HOST = 'files.todoist.com';
16
+ /**
17
+ * CDN origins that serve attachments via pre-signed URLs. These are
18
+ * third-party infrastructure and must never receive the auth token.
19
+ */
20
+ const CDN_ATTACHMENT_HOSTS = new Set(['todoist.b-cdn.net', 'd1ysz50cxb9zwl.cloudfront.net']);
21
+ const MAX_ATTACHMENT_REDIRECTS = 5;
22
+ /**
23
+ * Builds the request headers for an attachment fetch, attaching the Bearer
24
+ * token only when the URL is on the first-party authenticated host.
25
+ */
26
+ function buildAttachmentAuthHeaders(url, authToken) {
27
+ return new URL(url).hostname === AUTHENTICATED_ATTACHMENT_HOST
28
+ ? { Authorization: `Bearer ${authToken}` }
29
+ : {};
30
+ }
31
+ /**
32
+ * Fetches an attachment through a caller-supplied `customFetch` while
33
+ * guaranteeing the auth token is never sent across origins.
34
+ *
35
+ * A `customFetch` is an arbitrary implementation that may not honour the Fetch
36
+ * standard's rule of stripping `Authorization` on cross-origin redirects (older
37
+ * `node-fetch`, for example, does not). Because `files.todoist.com` redirects
38
+ * downloads to the CDN, a naive implementation could forward the token there.
39
+ * We therefore drive redirects with `redirect: 'manual'` and follow them
40
+ * ourselves, dropping the auth header whenever the origin changes.
41
+ *
42
+ * Implementations that hide the redirect behind an opaque response (status `0`,
43
+ * e.g. undici and browsers) can't be followed manually; for those we re-issue
44
+ * with normal redirect following and rely on the Fetch standard's cross-origin
45
+ * auth stripping, which those runtimes implement.
46
+ */
47
+ async function customFetchFollowingRedirects(customFetch, startUrl, authToken) {
48
+ let currentUrl = startUrl;
49
+ let headers = buildAttachmentAuthHeaders(currentUrl, authToken);
50
+ for (let redirects = 0; redirects < MAX_ATTACHMENT_REDIRECTS; redirects++) {
51
+ const response = await customFetch(currentUrl, {
52
+ method: 'GET',
53
+ headers,
54
+ redirect: 'manual',
55
+ });
56
+ const location = response.headers['location'];
57
+ const isReadableRedirect = response.status >= 300 && response.status < 400 && Boolean(location);
58
+ if (!isReadableRedirect) {
59
+ // Opaque redirect (the impl hid the Location, e.g. undici): re-issue
60
+ // with normal following and trust the runtime to strip auth cross-origin.
61
+ if (response.status === 0) {
62
+ return customFetch(currentUrl, { method: 'GET', headers });
63
+ }
64
+ return response;
65
+ }
66
+ const nextUrl = new URL(location, currentUrl);
67
+ if (nextUrl.origin !== new URL(currentUrl).origin) {
68
+ headers = {};
69
+ }
70
+ currentUrl = nextUrl.toString();
71
+ }
72
+ throw new Error('Too many redirects while fetching attachment');
73
+ }
10
74
  /**
11
75
  * Internal sub-client handling upload + attachment endpoints
12
76
  * (file upload, upload delete, attachment view).
@@ -57,22 +121,30 @@ class UploadClient extends base_client_1.BaseClient {
57
121
  }
58
122
  fileUrl = commentOrUrl.fileAttachment.fileUrl;
59
123
  }
60
- // Validate the URL belongs to Todoist to prevent leaking the auth token
124
+ // Only allow known attachment origins, and only attach the auth token to the
125
+ // first-party authenticated host. This prevents the token leaking to other
126
+ // todoist.com hosts (e.g. api.todoist.com) or to third-party CDN infrastructure.
61
127
  const urlHostname = new URL(fileUrl).hostname;
62
- if (!urlHostname.endsWith('.todoist.com')) {
63
- throw new Error('Attachment URLs must be on a todoist.com domain');
128
+ const isAuthenticatedHost = urlHostname === AUTHENTICATED_ATTACHMENT_HOST;
129
+ if (!isAuthenticatedHost && !CDN_ATTACHMENT_HOSTS.has(urlHostname)) {
130
+ throw new Error('Attachment URLs must be on a known Todoist attachment host');
64
131
  }
65
- const fetchOptions = {
66
- method: 'GET',
67
- headers: { Authorization: `Bearer ${this.authToken}` },
68
- };
69
132
  if (this.customFetch) {
70
- const response = await this.customFetch(fileUrl, fetchOptions);
133
+ // customFetch may not strip auth on cross-origin redirects, so we
134
+ // follow the files.todoist.com -> CDN hop ourselves to avoid leaking
135
+ // the token. See customFetchFollowingRedirects for details.
136
+ const response = await customFetchFollowingRedirects(this.customFetch, fileUrl, this.authToken);
71
137
  if (!response.ok) {
72
138
  throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
73
139
  }
74
140
  return (0, file_response_1.wrapAsFileResponse)(response, 'viewAttachment');
75
141
  }
142
+ // Built-in fetch (undici) strips Authorization on cross-origin redirects
143
+ // per the Fetch standard, so the files.todoist.com -> CDN hop is safe here.
144
+ const fetchOptions = {
145
+ method: 'GET',
146
+ headers: isAuthenticatedHost ? { Authorization: `Bearer ${this.authToken}` } : {},
147
+ };
76
148
  const response = await fetch(fileUrl, fetchOptions);
77
149
  if (!response.ok) {
78
150
  throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
@@ -862,7 +862,7 @@ class TodoistApi {
862
862
  * @example
863
863
  * ```typescript
864
864
  * await api.deleteUpload({
865
- * fileUrl: 'https://cdn.todoist.com/...'
865
+ * fileUrl: 'https://files.todoist.com/...'
866
866
  * })
867
867
  * ```
868
868
  */
@@ -876,9 +876,14 @@ class TodoistApi {
876
876
  * or a direct file URL string. Returns the raw Response object so the caller
877
877
  * can read the body in the appropriate format (.arrayBuffer(), .text(), etc.).
878
878
  *
879
+ * Only known Todoist attachment hosts are allowed: the authenticated
880
+ * `files.todoist.com` origin (which receives the Bearer token) and the
881
+ * attachment CDN origins (fetched without the token). Other URLs are rejected.
882
+ *
879
883
  * @param commentOrUrl - A Comment object with a file attachment, or a file URL string.
880
884
  * @returns The raw fetch Response for the file content.
881
- * @throws Error if a Comment is provided without a file attachment or file URL.
885
+ * @throws Error if a Comment is provided without a file attachment or file URL,
886
+ * or if the URL is not on a known Todoist attachment host.
882
887
  *
883
888
  * @example
884
889
  * ```typescript
@@ -4,6 +4,70 @@ import { wrapAsFileResponse } from '../utils/file-response.js';
4
4
  import { uploadMultipartFile } from '../utils/multipart-upload.js';
5
5
  import { validateAttachment } from '../utils/validators.js';
6
6
  import { BaseClient } from './base-client.js';
7
+ /**
8
+ * First-party origin that serves attachment downloads behind Bearer auth.
9
+ * It redirects to the CDN; on the cross-origin hop the runtime drops the
10
+ * Authorization header, so the token never reaches the CDN.
11
+ */
12
+ const AUTHENTICATED_ATTACHMENT_HOST = 'files.todoist.com';
13
+ /**
14
+ * CDN origins that serve attachments via pre-signed URLs. These are
15
+ * third-party infrastructure and must never receive the auth token.
16
+ */
17
+ const CDN_ATTACHMENT_HOSTS = new Set(['todoist.b-cdn.net', 'd1ysz50cxb9zwl.cloudfront.net']);
18
+ const MAX_ATTACHMENT_REDIRECTS = 5;
19
+ /**
20
+ * Builds the request headers for an attachment fetch, attaching the Bearer
21
+ * token only when the URL is on the first-party authenticated host.
22
+ */
23
+ function buildAttachmentAuthHeaders(url, authToken) {
24
+ return new URL(url).hostname === AUTHENTICATED_ATTACHMENT_HOST
25
+ ? { Authorization: `Bearer ${authToken}` }
26
+ : {};
27
+ }
28
+ /**
29
+ * Fetches an attachment through a caller-supplied `customFetch` while
30
+ * guaranteeing the auth token is never sent across origins.
31
+ *
32
+ * A `customFetch` is an arbitrary implementation that may not honour the Fetch
33
+ * standard's rule of stripping `Authorization` on cross-origin redirects (older
34
+ * `node-fetch`, for example, does not). Because `files.todoist.com` redirects
35
+ * downloads to the CDN, a naive implementation could forward the token there.
36
+ * We therefore drive redirects with `redirect: 'manual'` and follow them
37
+ * ourselves, dropping the auth header whenever the origin changes.
38
+ *
39
+ * Implementations that hide the redirect behind an opaque response (status `0`,
40
+ * e.g. undici and browsers) can't be followed manually; for those we re-issue
41
+ * with normal redirect following and rely on the Fetch standard's cross-origin
42
+ * auth stripping, which those runtimes implement.
43
+ */
44
+ async function customFetchFollowingRedirects(customFetch, startUrl, authToken) {
45
+ let currentUrl = startUrl;
46
+ let headers = buildAttachmentAuthHeaders(currentUrl, authToken);
47
+ for (let redirects = 0; redirects < MAX_ATTACHMENT_REDIRECTS; redirects++) {
48
+ const response = await customFetch(currentUrl, {
49
+ method: 'GET',
50
+ headers,
51
+ redirect: 'manual',
52
+ });
53
+ const location = response.headers['location'];
54
+ const isReadableRedirect = response.status >= 300 && response.status < 400 && Boolean(location);
55
+ if (!isReadableRedirect) {
56
+ // Opaque redirect (the impl hid the Location, e.g. undici): re-issue
57
+ // with normal following and trust the runtime to strip auth cross-origin.
58
+ if (response.status === 0) {
59
+ return customFetch(currentUrl, { method: 'GET', headers });
60
+ }
61
+ return response;
62
+ }
63
+ const nextUrl = new URL(location, currentUrl);
64
+ if (nextUrl.origin !== new URL(currentUrl).origin) {
65
+ headers = {};
66
+ }
67
+ currentUrl = nextUrl.toString();
68
+ }
69
+ throw new Error('Too many redirects while fetching attachment');
70
+ }
7
71
  /**
8
72
  * Internal sub-client handling upload + attachment endpoints
9
73
  * (file upload, upload delete, attachment view).
@@ -54,22 +118,30 @@ export class UploadClient extends BaseClient {
54
118
  }
55
119
  fileUrl = commentOrUrl.fileAttachment.fileUrl;
56
120
  }
57
- // Validate the URL belongs to Todoist to prevent leaking the auth token
121
+ // Only allow known attachment origins, and only attach the auth token to the
122
+ // first-party authenticated host. This prevents the token leaking to other
123
+ // todoist.com hosts (e.g. api.todoist.com) or to third-party CDN infrastructure.
58
124
  const urlHostname = new URL(fileUrl).hostname;
59
- if (!urlHostname.endsWith('.todoist.com')) {
60
- throw new Error('Attachment URLs must be on a todoist.com domain');
125
+ const isAuthenticatedHost = urlHostname === AUTHENTICATED_ATTACHMENT_HOST;
126
+ if (!isAuthenticatedHost && !CDN_ATTACHMENT_HOSTS.has(urlHostname)) {
127
+ throw new Error('Attachment URLs must be on a known Todoist attachment host');
61
128
  }
62
- const fetchOptions = {
63
- method: 'GET',
64
- headers: { Authorization: `Bearer ${this.authToken}` },
65
- };
66
129
  if (this.customFetch) {
67
- const response = await this.customFetch(fileUrl, fetchOptions);
130
+ // customFetch may not strip auth on cross-origin redirects, so we
131
+ // follow the files.todoist.com -> CDN hop ourselves to avoid leaking
132
+ // the token. See customFetchFollowingRedirects for details.
133
+ const response = await customFetchFollowingRedirects(this.customFetch, fileUrl, this.authToken);
68
134
  if (!response.ok) {
69
135
  throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
70
136
  }
71
137
  return wrapAsFileResponse(response, 'viewAttachment');
72
138
  }
139
+ // Built-in fetch (undici) strips Authorization on cross-origin redirects
140
+ // per the Fetch standard, so the files.todoist.com -> CDN hop is safe here.
141
+ const fetchOptions = {
142
+ method: 'GET',
143
+ headers: isAuthenticatedHost ? { Authorization: `Bearer ${this.authToken}` } : {},
144
+ };
73
145
  const response = await fetch(fileUrl, fetchOptions);
74
146
  if (!response.ok) {
75
147
  throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
@@ -859,7 +859,7 @@ export class TodoistApi {
859
859
  * @example
860
860
  * ```typescript
861
861
  * await api.deleteUpload({
862
- * fileUrl: 'https://cdn.todoist.com/...'
862
+ * fileUrl: 'https://files.todoist.com/...'
863
863
  * })
864
864
  * ```
865
865
  */
@@ -873,9 +873,14 @@ export class TodoistApi {
873
873
  * or a direct file URL string. Returns the raw Response object so the caller
874
874
  * can read the body in the appropriate format (.arrayBuffer(), .text(), etc.).
875
875
  *
876
+ * Only known Todoist attachment hosts are allowed: the authenticated
877
+ * `files.todoist.com` origin (which receives the Bearer token) and the
878
+ * attachment CDN origins (fetched without the token). Other URLs are rejected.
879
+ *
876
880
  * @param commentOrUrl - A Comment object with a file attachment, or a file URL string.
877
881
  * @returns The raw fetch Response for the file content.
878
- * @throws Error if a Comment is provided without a file attachment or file URL.
882
+ * @throws Error if a Comment is provided without a file attachment or file URL,
883
+ * or if the URL is not on a known Todoist attachment host.
879
884
  *
880
885
  * @example
881
886
  * ```typescript
@@ -722,7 +722,7 @@ export declare class TodoistApi {
722
722
  * @example
723
723
  * ```typescript
724
724
  * await api.deleteUpload({
725
- * fileUrl: 'https://cdn.todoist.com/...'
725
+ * fileUrl: 'https://files.todoist.com/...'
726
726
  * })
727
727
  * ```
728
728
  */
@@ -734,9 +734,14 @@ export declare class TodoistApi {
734
734
  * or a direct file URL string. Returns the raw Response object so the caller
735
735
  * can read the body in the appropriate format (.arrayBuffer(), .text(), etc.).
736
736
  *
737
+ * Only known Todoist attachment hosts are allowed: the authenticated
738
+ * `files.todoist.com` origin (which receives the Bearer token) and the
739
+ * attachment CDN origins (fetched without the token). Other URLs are rejected.
740
+ *
737
741
  * @param commentOrUrl - A Comment object with a file attachment, or a file URL string.
738
742
  * @returns The raw fetch Response for the file content.
739
- * @throws Error if a Comment is provided without a file attachment or file URL.
743
+ * @throws Error if a Comment is provided without a file attachment or file URL,
744
+ * or if the URL is not on a known Todoist attachment host.
740
745
  *
741
746
  * @example
742
747
  * ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/todoist-sdk",
3
- "version": "10.3.2",
3
+ "version": "10.3.3",
4
4
  "description": "The official TypeScript SDK for the Todoist REST API.",
5
5
  "author": "Doist developers",
6
6
  "repository": "https://github.com/Doist/todoist-sdk-typescript",