@littlebearapps/outlook-assistant 3.11.1 → 3.11.2

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/README.md CHANGED
@@ -164,7 +164,7 @@ npx @littlebearapps/outlook-assistant
164
164
  To check which version you have, or to see the available options:
165
165
 
166
166
  ```bash
167
- outlook-assistant --version # prints e.g. 3.11.1
167
+ outlook-assistant --version # prints e.g. 3.11.2
168
168
  outlook-assistant --help # usage, options and key environment variables
169
169
  ```
170
170
 
@@ -10,6 +10,65 @@ const _config = require('../config'); // Reserved for future use
10
10
  const { callGraphAPI } = require('../utils/graph-api');
11
11
  const { ensureAuthenticated } = require('../auth');
12
12
 
13
+ const MAX_FILENAME_LENGTH = 200;
14
+
15
+ /**
16
+ * Reduce a sender-controlled attachment name to a safe basename.
17
+ * Strips any directory part (either separator), control and reserved
18
+ * characters, and leading dots, then caps the length while keeping the
19
+ * extension. Falls back to "attachment" when nothing usable remains.
20
+ * @param {string} name - Attachment name from Graph metadata
21
+ * @returns {string} - Filename safe to join onto an output directory
22
+ */
23
+ function safeAttachmentFilename(name) {
24
+ const base = String(name || '')
25
+ .split(/[\\/]/)
26
+ .pop()
27
+ // eslint-disable-next-line no-control-regex
28
+ .replace(/[\x00-\x1f\x7f]/g, '')
29
+ .replace(/[<>:"|?*]/g, '_')
30
+ .trim()
31
+ .replace(/^\.+/, '');
32
+
33
+ if (!base) return 'attachment';
34
+ if (base.length <= MAX_FILENAME_LENGTH) return base;
35
+
36
+ const ext = path.extname(base).slice(0, 20);
37
+ return base.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext;
38
+ }
39
+
40
+ /**
41
+ * Write a buffer into outputDir without ever overwriting an existing entry
42
+ * or following a symlink: `wx` fails on any existing path (including a
43
+ * dangling symlink), so collisions get a numbered suffix instead.
44
+ * @param {string} outputDir - Target directory
45
+ * @param {string} filename - Safe basename from safeAttachmentFilename
46
+ * @param {Buffer} buffer - File contents
47
+ * @returns {string} - Absolute path actually written
48
+ */
49
+ function writeUniqueFile(outputDir, filename, buffer) {
50
+ const root = path.resolve(outputDir);
51
+ const ext = path.extname(filename);
52
+ const stem = filename.slice(0, filename.length - ext.length);
53
+
54
+ for (let i = 0; i < 1000; i++) {
55
+ const candidate = path.join(
56
+ root,
57
+ i === 0 ? filename : `${stem}-${i}${ext}`
58
+ );
59
+ if (path.dirname(candidate) !== root) {
60
+ throw new Error('Refusing to write attachment outside outputDir');
61
+ }
62
+ try {
63
+ fs.writeFileSync(candidate, buffer, { flag: 'wx' });
64
+ return candidate;
65
+ } catch (error) {
66
+ if (error.code !== 'EEXIST') throw error;
67
+ }
68
+ }
69
+ throw new Error(`Too many files named ${filename} in ${root}`);
70
+ }
71
+
13
72
  /**
14
73
  * List attachments for a specific email
15
74
  * @param {object} args - Tool arguments
@@ -177,13 +236,18 @@ async function handleDownloadAttachment(args) {
177
236
  // of cwd so attachments don't silently land in the source tree
178
237
  // when the caller forgets to pass outputDir. Auto-create the
179
238
  // target directory.
239
+ // The filename is sender-controlled (GHSA-755c-c45g-69rv): reduce it
240
+ // to a safe basename and never overwrite or follow a symlink.
180
241
  const outputDir = savePath || os.tmpdir();
181
242
  fs.mkdirSync(outputDir, { recursive: true });
182
- const outputPath = path.join(outputDir, filename);
183
243
 
184
244
  // Decode base64 and save to file
185
245
  const buffer = Buffer.from(contentBytes, 'base64');
186
- fs.writeFileSync(outputPath, buffer);
246
+ const outputPath = writeUniqueFile(
247
+ outputDir,
248
+ safeAttachmentFilename(filename),
249
+ buffer
250
+ );
187
251
 
188
252
  const sizeKB = (buffer.length / 1024).toFixed(1);
189
253
 
@@ -15,6 +15,7 @@ const { getEmailFields } = require('../utils/field-presets');
15
15
  const {
16
16
  formatEmailContent,
17
17
  formatEmailsAsCSV,
18
+ stripHtml,
18
19
  VERBOSITY,
19
20
  } = require('../utils/response-formatter');
20
21
  // Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
@@ -542,16 +543,7 @@ async function handleExportConversation(args) {
542
543
  // Body content
543
544
  if (msg.body?.content) {
544
545
  if (msg.body.contentType === 'html') {
545
- // Simple HTML to text conversion
546
- const text = msg.body.content
547
- .replace(/<br\s*\/?>/gi, '\n')
548
- .replace(/<\/p>/gi, '\n\n')
549
- .replace(/<[^>]+>/g, '')
550
- .replace(/&nbsp;/g, ' ')
551
- .replace(/&lt;/g, '<')
552
- .replace(/&gt;/g, '>')
553
- .replace(/&amp;/g, '&');
554
- mdContent.push(text.trim());
546
+ mdContent.push(stripHtml(msg.body.content));
555
547
  } else {
556
548
  mdContent.push(msg.body.content);
557
549
  }
package/llms.txt CHANGED
@@ -79,6 +79,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
79
79
  - [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/faq.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
80
80
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
81
81
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
82
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
82
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2), HTML-to-text entity double-decoding fixed, `npm audit` at 0. Preceded by v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
83
83
  - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.2 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
84
84
  - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.11.1",
3
+ "version": "3.11.2",
4
4
  "mcpName": "io.github.littlebearapps/outlook-assistant",
5
5
  "description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
6
6
  "main": "index.js",
@@ -100,8 +100,8 @@
100
100
  "minimatch": ">=3.1.3",
101
101
  "hono": "^4.13.7",
102
102
  "@hono/node-server": "^1.19.17",
103
- "fast-uri": "^3.1.7",
104
- "ip-address": "^10.7.0",
103
+ "fast-uri": "^3.1.8",
104
+ "ip-address": "^10.7.2",
105
105
  "qs": "^6.16.0",
106
106
  "body-parser": "^2.3.0"
107
107
  }
@@ -5,6 +5,38 @@ 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
+
8
40
  /**
9
41
  * Makes a request to the Microsoft Graph API
10
42
  * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
@@ -36,6 +68,7 @@ async function callGraphAPI(
36
68
  let finalUrl;
37
69
  if (path.startsWith('http://') || path.startsWith('https://')) {
38
70
  // Path is already a full URL (from pagination nextLink)
71
+ assertGraphUrl(path);
39
72
  finalUrl = path;
40
73
  } else {
41
74
  // Build URL from path and queryParams
@@ -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
  }