@littlebearapps/outlook-assistant 3.11.1 → 3.12.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.
@@ -5,6 +5,87 @@ const https = require('https');
5
5
  const config = require('../config');
6
6
  const mockData = require('./mock-data');
7
7
 
8
+ /**
9
+ * Guard for caller-supplied full URLs (nextLink/deltaLink continuations).
10
+ * The bearer token is attached to every request, so only the configured
11
+ * Graph host over HTTPS on the default port is allowed (GHSA-mqfm-wfjq-jxq2).
12
+ * @param {string} url - Full URL about to be requested
13
+ * @throws {Error} If the URL is malformed or not the Graph host
14
+ */
15
+ function assertGraphUrl(url) {
16
+ const allowed = new URL(config.GRAPH_API_ENDPOINT);
17
+ let target;
18
+ try {
19
+ target = new URL(url);
20
+ } catch {
21
+ target = null;
22
+ }
23
+
24
+ const ok =
25
+ target &&
26
+ target.protocol === 'https:' &&
27
+ target.hostname === allowed.hostname &&
28
+ target.port === '' &&
29
+ target.username === '' &&
30
+ target.password === '';
31
+
32
+ if (!ok) {
33
+ throw new Error(
34
+ 'Refusing to call non-Graph URL: continuation links must be https://' +
35
+ `${allowed.hostname}/ (check the deltaToken or nextLink value)`
36
+ );
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Fully percent-decode a value (bounded), so `%2e`, `%252e` etc. are seen as
42
+ * the characters they eventually stand for. Malformed escapes stop decoding.
43
+ * @param {string} value
44
+ * @returns {string}
45
+ */
46
+ function decodeFully(value) {
47
+ let current = value;
48
+ for (let i = 0; i < 5; i++) {
49
+ let next;
50
+ try {
51
+ next = decodeURIComponent(current);
52
+ } catch {
53
+ return current;
54
+ }
55
+ if (next === current) return current;
56
+ current = next;
57
+ }
58
+ return current;
59
+ }
60
+
61
+ /**
62
+ * Is this path segment a dot segment (`.` or `..`) in any encoding?
63
+ * @param {string} segment
64
+ * @returns {boolean}
65
+ */
66
+ function isDotSegment(segment) {
67
+ const decoded = decodeFully(String(segment)).trim();
68
+ return decoded === '.' || decoded === '..';
69
+ }
70
+
71
+ /**
72
+ * Reject relative Graph resource paths containing dot segments. Caller-supplied
73
+ * IDs (message, folder, attachment, delta tokens) are interpolated into these
74
+ * paths, and URL normalisation would otherwise let `..` walk the request to a
75
+ * different Graph resource (another mailbox, another API version) than the
76
+ * tool intended. Legitimate Graph IDs never contain a bare `.`/`..` segment.
77
+ * @param {string} resourcePath - Relative path (query string, if any, ignored)
78
+ * @throws {Error} If any segment is `.` or `..` (literal or percent-encoded)
79
+ */
80
+ function assertSafeResourcePath(resourcePath) {
81
+ const pathOnly = String(resourcePath).split('?')[0];
82
+ if (pathOnly.split('/').some(isDotSegment)) {
83
+ throw new Error(
84
+ 'Invalid resource path: IDs must not contain "." or ".." path segments'
85
+ );
86
+ }
87
+ }
88
+
8
89
  /**
9
90
  * Makes a request to the Microsoft Graph API
10
91
  * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
@@ -36,9 +117,13 @@ async function callGraphAPI(
36
117
  let finalUrl;
37
118
  if (path.startsWith('http://') || path.startsWith('https://')) {
38
119
  // Path is already a full URL (from pagination nextLink)
120
+ assertGraphUrl(path);
39
121
  finalUrl = path;
40
122
  } else {
41
- // Build URL from path and queryParams
123
+ // Build URL from path and queryParams. Refuse dot segments before
124
+ // encoding: encodeURIComponent leaves `..` intact, and the URL parser
125
+ // would then resolve it to a different resource.
126
+ assertSafeResourcePath(path);
42
127
  // Encode path segments properly
43
128
  const encodedPath = path
44
129
  .split('/')
@@ -259,6 +344,12 @@ async function callGraphAPIBatch(accessToken, requests) {
259
344
  }));
260
345
  }
261
346
 
347
+ // Batch sub-request URLs are resolved by Graph itself — apply the same
348
+ // dot-segment guard as single requests.
349
+ for (const req of requests) {
350
+ assertSafeResourcePath(req.url);
351
+ }
352
+
262
353
  const batchPayload = {
263
354
  requests: requests.map((req) => ({
264
355
  id: req.id,
@@ -286,11 +377,12 @@ async function callGraphAPIBatch(accessToken, requests) {
286
377
  * In test mode (USE_TEST_MODE=true), returns mock MIME content instead of calling the real API.
287
378
  * @param {string} accessToken - The access token for authentication
288
379
  * @param {string} emailId - The email ID to export
380
+ * @param {string} [mailboxPrefix] - Resource prefix (`me` or `users/{email}`) for shared mailboxes. Defaults to `me`.
289
381
  * @returns {Promise<string>} - Raw MIME content as string
290
382
  * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
291
383
  * @throws {Error} If the HTTP status is outside 2xx or a network error occurs
292
384
  */
293
- async function callGraphAPIRaw(accessToken, emailId) {
385
+ async function callGraphAPIRaw(accessToken, emailId, mailboxPrefix = 'me') {
294
386
  // Test mode: return mock MIME content
295
387
  if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
296
388
  return mockData.getMockMimeContent
@@ -298,8 +390,21 @@ async function callGraphAPIRaw(accessToken, emailId) {
298
390
  : `MIME-Version: 1.0\nContent-Type: text/plain\n\nTest email content for ${emailId}`;
299
391
  }
300
392
 
393
+ // `emailId` is encoded as a single segment, but a bare `.`/`..` id would
394
+ // still be resolved as a dot segment — refuse it (and any in the prefix).
395
+ assertSafeResourcePath(`${mailboxPrefix}/messages`);
396
+ if (isDotSegment(emailId)) {
397
+ throw new Error(
398
+ 'Invalid resource path: IDs must not contain "." or ".." path segments'
399
+ );
400
+ }
401
+
301
402
  return new Promise((resolve, reject) => {
302
- const path = `me/messages/${encodeURIComponent(emailId)}/$value`;
403
+ const encodedPrefix = mailboxPrefix
404
+ .split('/')
405
+ .map((segment) => encodeURIComponent(segment))
406
+ .join('/');
407
+ const path = `${encodedPrefix}/messages/${encodeURIComponent(emailId)}/$value`;
303
408
  const finalUrl = `${config.GRAPH_API_ENDPOINT}${path}`;
304
409
 
305
410
  const options = {
@@ -401,6 +506,7 @@ async function callGraphAPIWithAuth(
401
506
  }
402
507
 
403
508
  module.exports = {
509
+ assertSafeResourcePath,
404
510
  callGraphAPI,
405
511
  callGraphAPIPaginated,
406
512
  callGraphAPIBatch,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Mailbox scoping helper.
3
+ *
4
+ * Every Graph path in this server is built as `${prefix}/...`. The prefix is
5
+ * `me` for the signed-in account, or `users/{email}` for a shared/delegated
6
+ * mailbox. Keeping the construction in one place is what lets the shared-mailbox
7
+ * parameter be threaded through readers, writers, and folder resolution without
8
+ * each call site re-deciding the shape.
9
+ */
10
+
11
+ // Pragmatic SMTP address / UPN shape — deliberately not full RFC 5322. The
12
+ // point is to keep caller input inside a single Graph path segment, so only
13
+ // printable ASCII is accepted: RFC 5322 `atext` in the local part minus `#`
14
+ // (a URL fragment delimiter), and dot-separated letters/digits/hyphens in the
15
+ // domain. No whitespace, control characters, `/ ? # % \`, or non-ASCII
16
+ // look-alikes (full-width `/`, zero-width spaces). The tool schemas advertise
17
+ // an email address only, so bare user GUIDs are not accepted.
18
+ const MAILBOX_PATTERN =
19
+ /^[A-Za-z0-9.!$&'*+=^_`{|}~-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
20
+
21
+ const config = require('../config');
22
+
23
+ const SHARED_MAILBOX_DISABLED_MESSAGE =
24
+ 'Shared-mailbox support is turned off. It is opt-in and work/school only: ' +
25
+ 'set OUTLOOK_SHARED_MAILBOX=read (read) or OUTLOOK_SHARED_MAILBOX=true ' +
26
+ '(read and organise) in the MCP server environment, restart the server, then ' +
27
+ 're-authenticate with `auth action=authenticate force=true` so the token ' +
28
+ 'carries the shared-mailbox scopes.';
29
+
30
+ /**
31
+ * Validate a mailbox and build its Graph resource prefix, WITHOUT checking
32
+ * whether shared-mailbox support is enabled. Only for paths that worked
33
+ * before the opt-in flag existed (access-shared-mailbox's direct read).
34
+ * @param {string|null} [mailbox] - Shared mailbox email address, or null/empty for the signed-in user
35
+ * @returns {string} - `me` or `users/{mailbox}`
36
+ * @throws {Error} If `mailbox` is non-empty but not a plausible email address
37
+ */
38
+ function validateMailboxPrefix(mailbox) {
39
+ const trimmed = typeof mailbox === 'string' ? mailbox.trim() : mailbox;
40
+ if (!trimmed) {
41
+ return 'me';
42
+ }
43
+ if (trimmed === 'me') {
44
+ return 'me';
45
+ }
46
+ if (!MAILBOX_PATTERN.test(trimmed)) {
47
+ throw new Error(
48
+ `Invalid mailbox "${mailbox}" — expected a shared mailbox email address (e.g. "team@contoso.com").`
49
+ );
50
+ }
51
+ // Return the address raw: encoding happens exactly once, in the Graph
52
+ // client (`callGraphAPI` / `callGraphAPIRaw` encode each path segment).
53
+ // Pre-encoding here double-encoded addresses like `team+archive@…` into
54
+ // `%252B`. The pattern above already confines the value to one segment.
55
+ return `users/${trimmed}`;
56
+ }
57
+
58
+ /**
59
+ * Build the Graph resource prefix for a mailbox. A non-`me` mailbox requires
60
+ * shared-mailbox support to be enabled (OUTLOOK_SHARED_MAILBOX).
61
+ * @param {string|null} [mailbox] - Shared mailbox email address, or null/empty for the signed-in user
62
+ * @returns {string} - `me` or `users/{mailbox}`
63
+ * @throws {Error} If `mailbox` is invalid, or shared-mailbox support is off
64
+ */
65
+ function buildMailboxPrefix(mailbox) {
66
+ const prefix = validateMailboxPrefix(mailbox);
67
+ if (prefix !== 'me' && config.SHARED_MAILBOX_MODE === 'off') {
68
+ throw new Error(SHARED_MAILBOX_DISABLED_MESSAGE);
69
+ }
70
+ return prefix;
71
+ }
72
+
73
+ module.exports = {
74
+ buildMailboxPrefix,
75
+ validateMailboxPrefix,
76
+ SHARED_MAILBOX_DISABLED_MESSAGE,
77
+ };
@@ -467,20 +467,54 @@ function truncateText(text, maxLength) {
467
467
  return `${text.substring(0, maxLength - 3)}...`;
468
468
  }
469
469
 
470
+ const HTML_ENTITIES = {
471
+ nbsp: ' ',
472
+ amp: '&',
473
+ lt: '<',
474
+ gt: '>',
475
+ quot: '"',
476
+ apos: "'",
477
+ '#39': "'",
478
+ };
479
+
480
+ /**
481
+ * Removes tags in one linear scan: `<…>` with no `<` inside is dropped
482
+ * whole, and any other `<` (nested fragments such as `<scr<script>ipt>`,
483
+ * or an unterminated tag) is dropped on its own, so no markup can
484
+ * reassemble. A scanner rather than a repeated regex replace keeps deeply
485
+ * nested input like `<<<…x…>>>` linear.
486
+ * @param {string} text - HTML fragment
487
+ * @returns {string} - Text with no `<` remaining
488
+ */
489
+ function removeTags(text) {
490
+ let out = '';
491
+ let i = 0;
492
+ while (i < text.length) {
493
+ const lt = text.indexOf('<', i);
494
+ if (lt === -1) {
495
+ out += text.slice(i);
496
+ break;
497
+ }
498
+ out += text.slice(i, lt);
499
+ // Scan only to the next '<' or '>', so each character is visited once.
500
+ let j = lt + 1;
501
+ while (j < text.length && text[j] !== '<' && text[j] !== '>') j++;
502
+ i = text[j] === '>' ? j + 1 : lt + 1;
503
+ }
504
+ return out;
505
+ }
506
+
470
507
  /**
471
- * Strips HTML tags (simple implementation)
508
+ * Strips HTML tags (simple implementation), in linear time.
509
+ * Entities are decoded after tag removal, in a single pass, so `&amp;lt;`
510
+ * becomes `&lt;`, not `<`.
472
511
  */
473
512
  function stripHtml(html) {
474
513
  if (!html) return '';
475
- return html
476
- .replace(/<br\s*\/?>/gi, '\n')
477
- .replace(/<\/p>/gi, '\n\n')
478
- .replace(/<[^>]*>/g, '')
479
- .replace(/&nbsp;/g, ' ')
480
- .replace(/&amp;/g, '&')
481
- .replace(/&lt;/g, '<')
482
- .replace(/&gt;/g, '>')
483
- .replace(/&quot;/g, '"')
514
+ return removeTags(
515
+ html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n\n')
516
+ )
517
+ .replace(/&(nbsp|amp|lt|gt|quot|apos|#39);/g, (_, e) => HTML_ENTITIES[e])
484
518
  .replace(/\n{3,}/g, '\n\n')
485
519
  .trim();
486
520
  }