@ni-c/imap-mcp 0.3.0 → 0.5.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/README.md +35 -10
- package/dist/analyze.d.ts +33 -3
- package/dist/analyze.js +135 -23
- package/dist/attachments.d.ts +12 -0
- package/dist/attachments.js +52 -5
- package/dist/config.d.ts +18 -0
- package/dist/config.js +147 -14
- package/dist/extract/child.d.ts +1 -0
- package/dist/extract/child.js +83 -0
- package/dist/extract/index.d.ts +41 -0
- package/dist/extract/index.js +183 -0
- package/dist/extract/ooxml.d.ts +35 -0
- package/dist/extract/ooxml.js +634 -0
- package/dist/extract/pdf.d.ts +62 -0
- package/dist/extract/pdf.js +539 -0
- package/dist/extract/types.d.ts +56 -0
- package/dist/extract/types.js +13 -0
- package/dist/imap.d.ts +48 -2
- package/dist/imap.js +133 -28
- package/dist/message.d.ts +11 -0
- package/dist/message.js +26 -3
- package/dist/output-schema.d.ts +1 -0
- package/dist/output-schema.js +6 -0
- package/dist/resources.js +10 -3
- package/dist/result.d.ts +7 -1
- package/dist/result.js +32 -6
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/server.js +15 -0
- package/dist/tools/read.js +473 -58
- package/dist/tools/write.js +18 -4
- package/package.json +11 -7
- package/dist/analyze.js.map +0 -1
- package/dist/attachments.js.map +0 -1
- package/dist/audit.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/download.js.map +0 -1
- package/dist/draft.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/imap.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/message.js.map +0 -1
- package/dist/output-schema.js.map +0 -1
- package/dist/resources.js.map +0 -1
- package/dist/result.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/stream.js.map +0 -1
- package/dist/tools/annotations.js.map +0 -1
- package/dist/tools/catalogue.js.map +0 -1
- package/dist/tools/read.js.map +0 -1
- package/dist/tools/write.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# imap-mcp
|
|
2
2
|
|
|
3
|
+
<!-- badges: start -->
|
|
4
|
+
|
|
3
5
|
[](https://github.com/ni-c/imap-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/ni-c/imap-mcp)
|
|
7
|
+
<a href="https://socket.dev/npm/package/@ni-c/imap-mcp"><img src="https://socket.dev/api/badge/npm/package/@ni-c/imap-mcp" alt="Socket supply-chain report" height="20"></a>
|
|
8
|
+
[](https://glama.ai/mcp/servers/ni-c/imap-mcp)
|
|
9
|
+
<br>
|
|
4
10
|
[](https://www.npmjs.com/package/@ni-c/imap-mcp)
|
|
5
|
-
[](https://imap-mcp.ni-c.de)
|
|
10
|
-
[](https://mcp-hub.ni-c.de)
|
|
11
|
+
[](https://github.com/ni-c/imap-mcp/pkgs/container/imap-mcp)
|
|
12
|
+
[](https://mcp-hub.ni-c.de)
|
|
13
|
+
<br>
|
|
14
|
+
[](https://imap-mcp.ni-c.de)
|
|
11
15
|
[](https://github.com/sponsors/ni-c)
|
|
16
|
+
<!-- badges: end -->
|
|
12
17
|
|
|
13
18
|
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for any IMAP
|
|
14
19
|
mailbox. It speaks IMAP rather than one vendor's API, so it works with whatever provider you
|
|
@@ -116,6 +121,7 @@ guard. See [Asking a person](https://imap-mcp.ni-c.de/guide/approval).
|
|
|
116
121
|
| `IMAP_MAX_MESSAGES` | no | `100` | Default page size |
|
|
117
122
|
| `IMAP_MAX_ATTACHMENT_BYTES` | no | `1048576` | Ceiling for returning an attachment inline |
|
|
118
123
|
| `IMAP_MAX_DOWNLOAD_BYTES` | no | `26214400` | Ceiling for writing one to disk |
|
|
124
|
+
| `IMAP_MAX_EXTRACT_BYTES` | no | `10485760` | Ceiling for reading a document's text; max `67108864` |
|
|
119
125
|
| `IMAP_ATTACHMENT_TYPES` | no | see below | Comma-separated content-type allowlist |
|
|
120
126
|
| `IMAP_DOWNLOAD_DIR` | no | — | Setting it allows saving attachments there |
|
|
121
127
|
| `IMAP_INSECURE_TLS` | no | `false` | Exactly `true` accepts a self-signed certificate |
|
|
@@ -143,6 +149,10 @@ IMAP_ALLOW_TOOLS=list_new_messages,get_message,move_messages
|
|
|
143
149
|
IMAP_DENY_TOOLS=delete_messages
|
|
144
150
|
```
|
|
145
151
|
|
|
152
|
+
One boundary the list cannot draw: `move_messages` copies as well as moves (`mode: "copy"`),
|
|
153
|
+
and the two are one tool. Denying `move_messages` removes both; there is no way to keep moving
|
|
154
|
+
and forbid copying, or the other way round. Both modes ask for confirmation.
|
|
155
|
+
|
|
146
156
|
An entry that matches no tool aborts startup and names it, so a typo cannot silently hide a
|
|
147
157
|
tool — an absent tool is not something anyone traces back to an environment variable. A
|
|
148
158
|
filtered tool is never registered, so it is absent from `tools/list` and unknown to
|
|
@@ -155,8 +165,11 @@ answer — its `/hub` endpoint replaces every server's tools with six meta-tools
|
|
|
155
165
|
The password is deleted from the process environment as soon as it is read, so it is not
|
|
156
166
|
visible to child processes or in `/proc/<pid>/environ`.
|
|
157
167
|
|
|
158
|
-
Without `IMAP_DOWNLOAD_DIR` this server never writes to the filesystem. The
|
|
159
|
-
separate on purpose
|
|
168
|
+
Without `IMAP_DOWNLOAD_DIR` this server never writes to the filesystem. The three size limits are
|
|
169
|
+
separate on purpose, because they answer three different questions:
|
|
170
|
+
`IMAP_MAX_ATTACHMENT_BYTES` protects the model's context window,
|
|
171
|
+
`IMAP_MAX_DOWNLOAD_BYTES` protects your disk, and `IMAP_MAX_EXTRACT_BYTES` bounds how much
|
|
172
|
+
hostile input one parser is handed. Raising any one of them is not a request to raise the others.
|
|
160
173
|
|
|
161
174
|
The server starts without credentials on purpose — it completes the handshake and lists its
|
|
162
175
|
tools, and every call then fails with setup instructions instead of reaching a server.
|
|
@@ -259,7 +272,7 @@ are in the [client guide](https://imap-mcp.ni-c.de/guide/clients#through-mcp-hub
|
|
|
259
272
|
| `list_messages` | Lists and searches: sender, recipient, subject, body, date range, flags |
|
|
260
273
|
| `list_new_messages` | Messages not handed over yet; marks them afterwards, `dry_run` to preview |
|
|
261
274
|
| `get_message` | Headers and body, fenced untrusted, plus the security assessment; `include_thread` |
|
|
262
|
-
| `get_attachments` | Without `part_id` lists them, with `part_id` reads or saves one
|
|
275
|
+
| `get_attachments` | Without `part_id` lists them, with `part_id` reads, extracts or saves one |
|
|
263
276
|
|
|
264
277
|
**Mailbox** — needs `IMAP_READ_ONLY=false`
|
|
265
278
|
|
|
@@ -326,7 +339,8 @@ size limit and magic-byte check as the tool — it is not a second, unguarded do
|
|
|
326
339
|
## Not exposed, on purpose
|
|
327
340
|
|
|
328
341
|
No sending, no SMTP, no raw IMAP passthrough, no `APPEND` of arbitrary MIME, no HTML
|
|
329
|
-
composition, no OAuth2
|
|
342
|
+
composition, no OAuth2, and **no OCR** — a scanned PDF has no text to extract and says so
|
|
343
|
+
rather than guessing. The first is the whole security argument (see `SECURITY.md`); the
|
|
330
344
|
second would make every guard here optional; the last is planned but needs a test account
|
|
331
345
|
before it ships.
|
|
332
346
|
|
|
@@ -346,6 +360,17 @@ knowing before concluding that a filtered install reaches less of the mailbox th
|
|
|
346
360
|
saving to disk, where it would be more dangerous, not less.
|
|
347
361
|
- **A `part_id` must come from a listing call**, so the body cannot be pulled out through the
|
|
348
362
|
attachment tool and escape its framing.
|
|
363
|
+
- **Documents are parsed in a process that can be killed.** `mode: "text"` reads a PDF or
|
|
364
|
+
Office file with a bundled PDF.js and a ZIP reader — the only place this server parses a
|
|
365
|
+
binary a stranger sent. It runs in a child process with a heap limit and a timeout, its
|
|
366
|
+
stdout discarded rather than shared with the JSON-RPC transport, PDF.js's `eval` support
|
|
367
|
+
off, compressed streams measured against a ceiling _before_ PDF.js inflates them, and an
|
|
368
|
+
entry allowlist that decides what is decompressed _before_ the buffer is sized. Nothing in
|
|
369
|
+
that path touches the network or the filesystem.
|
|
370
|
+
- **Extracted text says what it is.** Extraction returns every text-drawing instruction in a
|
|
371
|
+
file, including text set below one point or drawn in the colour of the paper, and returns
|
|
372
|
+
nothing that was drawn as a picture. The result says so above the fence, because "the
|
|
373
|
+
document says X" is otherwise a claim the user has no way to check.
|
|
349
374
|
- **Downloads cannot escape their directory.** The target comes only from the environment, the
|
|
350
375
|
filename is sanitised, the resolved path is re-checked, and the file is opened with `wx` and
|
|
351
376
|
mode `0600` — so nothing is overwritten and no planted symlink is followed.
|
package/dist/analyze.d.ts
CHANGED
|
@@ -23,13 +23,33 @@ export interface SecurityAssessment {
|
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Extracts readable text from HTML
|
|
26
|
+
* Extracts readable text from HTML — and from the XML inside an OOXML or
|
|
27
|
+
* OpenDocument attachment, which is the same problem with different tag names.
|
|
28
|
+
*
|
|
29
|
+
* Reused there rather than reimplemented, and that is a security decision, not
|
|
30
|
+
* a tidiness one. The obvious `document.xml` reader is
|
|
31
|
+
* `/<w:t[^>]*>([\s\S]*?)<\/w:t>/g` — which is the exact shape of the bug
|
|
32
|
+
* recorded above this function: *n* start tokens each paying for a scan that
|
|
33
|
+
* never finds its end. A `document.xml` of `'<w:t '` repeated 200 000 times is
|
|
34
|
+
* a tiny, deflate-friendly ZIP entry. This walk is immune for the reason it was
|
|
35
|
+
* written, so the second parser is the one not to write.
|
|
36
|
+
*
|
|
37
|
+
* It is also why nothing here parses XML properly. A real parser resolves
|
|
38
|
+
* entities, and would hand a mail attachment billion-laughs expansion and an
|
|
39
|
+
* `<!ENTITY … SYSTEM "file:///etc/passwd">` that reads a file. Those are not
|
|
40
|
+
* defended against below; they are simply not implemented. `&lol9;` comes out
|
|
41
|
+
* as six literal characters, and it must stay that way — reaching for
|
|
42
|
+
* `fast-xml-parser` here would reintroduce all three at once.
|
|
27
43
|
*
|
|
28
44
|
* Deliberately not `mailparser`'s own `text` fallback: that keeps content the
|
|
29
45
|
* recipient never sees. Anything hidden by inline CSS is a place to park an
|
|
30
46
|
* instruction meant only for the model, so those elements are dropped before
|
|
31
47
|
* the tags are stripped.
|
|
32
48
|
*
|
|
49
|
+
* `maxChars` overrides the input slice. The default suits a mail body; a
|
|
50
|
+
* document part needs more, because OOXML spends most of its bytes on
|
|
51
|
+
* formatting and the readable text is a small fraction of it.
|
|
52
|
+
*
|
|
33
53
|
* This is one pass over the input. The cursors below only ever move forward,
|
|
34
54
|
* which is the property that makes the whole function linear no matter what the
|
|
35
55
|
* sender writes: a start token that is never closed is answered once and then
|
|
@@ -40,7 +60,8 @@ export interface SecurityAssessment {
|
|
|
40
60
|
* for the same reason it always was: nothing downstream trusts the stripping,
|
|
41
61
|
* and the fencing in {@link wrapUntrusted} is what carries the weight.
|
|
42
62
|
*/
|
|
43
|
-
export declare function htmlToText(html: string): string;
|
|
63
|
+
export declare function htmlToText(html: string, maxChars?: number): string;
|
|
64
|
+
export declare function decodeCharacterReferences(text: string): string;
|
|
44
65
|
/**
|
|
45
66
|
* Removes the characters a human reader cannot see but the model can.
|
|
46
67
|
*
|
|
@@ -74,7 +95,16 @@ export declare function escapeInvisible(input: string): string;
|
|
|
74
95
|
* normalising would miss it.
|
|
75
96
|
*/
|
|
76
97
|
export declare function sanitizeText(input: string, maxChars?: number): string;
|
|
77
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Names of the injection shapes present in `text`.
|
|
100
|
+
*
|
|
101
|
+
* Runs in this process on text the sender wrote, up to a million characters
|
|
102
|
+
* of it for an extracted document — after the parser child has exited, so no
|
|
103
|
+
* timeout covers it. Every pattern above therefore has to be linear on a
|
|
104
|
+
* hostile repetition of its own trigger, and `analyze.test.ts` times each one
|
|
105
|
+
* on such input. A new pattern gets a line in that test before it gets a line
|
|
106
|
+
* in the list.
|
|
107
|
+
*/
|
|
78
108
|
export declare function detectSuspicious(text: string): string[];
|
|
79
109
|
/**
|
|
80
110
|
* Words that mix Latin with Cyrillic or Greek letters.
|
package/dist/analyze.js
CHANGED
|
@@ -45,9 +45,18 @@ const INJECTION_PATTERNS = [
|
|
|
45
45
|
'role-injection',
|
|
46
46
|
/(?:^|[-\u2014|>\])]\s{0,3})(system|assistant|developer)\s*:/im,
|
|
47
47
|
],
|
|
48
|
+
// Anchored at the start of the run. Without the lookbehind, `-{3,}` was
|
|
49
|
+
// tried from every position inside a run and backtracked once per possible
|
|
50
|
+
// length each time, which is quadratic on text that is nothing but hyphens:
|
|
51
|
+
// 40 000 of them took 1.5 s, and the million characters an extracted
|
|
52
|
+
// document may carry would have held this process — the whole server, its
|
|
53
|
+
// transport is stdio — for a quarter of an hour. A few kilobytes on the
|
|
54
|
+
// wire. With it, a run is one attempt from its first character and the
|
|
55
|
+
// positions inside it fail in constant time: a million hyphens in 5 ms,
|
|
56
|
+
// and a delimiter of any length is still found.
|
|
48
57
|
[
|
|
49
58
|
'fake-delimiter',
|
|
50
|
-
/(-{3,}|={3,}|#{3,})\s*(begin|end|system|instruction|prompt)/i,
|
|
59
|
+
/(?<![-=#])(-{3,}|={3,}|#{3,})\s*(begin|end|system|instruction|prompt)/i,
|
|
51
60
|
],
|
|
52
61
|
[
|
|
53
62
|
'tool-coercion',
|
|
@@ -115,7 +124,16 @@ const MAX_HIDDEN_ELEMENT_CHARS = 10_000;
|
|
|
115
124
|
*/
|
|
116
125
|
const CLOSER_SCAN_BUDGET_FACTOR = 4;
|
|
117
126
|
const CLOSER_SCAN_BUDGET_FLOOR = 100_000;
|
|
118
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Elements whose content the recipient never reads.
|
|
129
|
+
*
|
|
130
|
+
* The `w:`-prefixed names are WordprocessingML, not HTML: this walk is also how
|
|
131
|
+
* the text of a .docx attachment is read. A Word field code
|
|
132
|
+
* (`INCLUDEPICTURE "http://…"`, a DDE command) is markup the reader never sees
|
|
133
|
+
* for exactly the same reason a `<script>` body is, and `w:delText` is text a
|
|
134
|
+
* tracked change deleted — shown struck through at most, and not at all in the
|
|
135
|
+
* view a document is read in.
|
|
136
|
+
*/
|
|
119
137
|
const NON_CONTENT_TAGS = new Set([
|
|
120
138
|
'script',
|
|
121
139
|
'style',
|
|
@@ -123,8 +141,16 @@ const NON_CONTENT_TAGS = new Set([
|
|
|
123
141
|
'title',
|
|
124
142
|
'noscript',
|
|
125
143
|
'template',
|
|
144
|
+
'w:instrtext',
|
|
145
|
+
'w:deltext',
|
|
126
146
|
]);
|
|
127
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Closing tags that end a visual block, and so earn a line break.
|
|
149
|
+
*
|
|
150
|
+
* The namespaced names are the OOXML and OpenDocument paragraph, heading and
|
|
151
|
+
* row elements. Without them a whole .docx comes back as one line, because
|
|
152
|
+
* nothing else in a document part ever closes a block.
|
|
153
|
+
*/
|
|
128
154
|
const BLOCK_TAGS = new Set([
|
|
129
155
|
'p',
|
|
130
156
|
'div',
|
|
@@ -136,8 +162,19 @@ const BLOCK_TAGS = new Set([
|
|
|
136
162
|
'h4',
|
|
137
163
|
'h5',
|
|
138
164
|
'h6',
|
|
165
|
+
'w:p',
|
|
166
|
+
'w:tr',
|
|
167
|
+
'a:p',
|
|
168
|
+
'text:p',
|
|
169
|
+
'text:h',
|
|
170
|
+
'table:table-row',
|
|
139
171
|
]);
|
|
140
|
-
|
|
172
|
+
// The name may carry a namespace prefix and a hyphen: `</w:p>`, `</text:h>`,
|
|
173
|
+
// `</table:table-row>`. Without the colon and the hyphen this matched `w` for
|
|
174
|
+
// `</w:p>`, so every namespaced entry in the sets above would be dead code.
|
|
175
|
+
// HTML is unaffected — no HTML element name contains either character, and a
|
|
176
|
+
// custom element (`<my-widget>`) is in none of the sets under either reading.
|
|
177
|
+
const TAG_NAME = /^<\/?([A-Za-z][A-Za-z0-9:_.-]*)/;
|
|
141
178
|
const BR_TAG = /^<br\s*\/?$/i;
|
|
142
179
|
const STYLE_ATTRIBUTE = /style\s*=\s*("|')/gi;
|
|
143
180
|
const HIDDEN_VALUE = /display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0|font-size\s*:\s*0/i;
|
|
@@ -164,13 +201,33 @@ function hasHiddenStyle(tag) {
|
|
|
164
201
|
return false;
|
|
165
202
|
}
|
|
166
203
|
/**
|
|
167
|
-
* Extracts readable text from HTML
|
|
204
|
+
* Extracts readable text from HTML — and from the XML inside an OOXML or
|
|
205
|
+
* OpenDocument attachment, which is the same problem with different tag names.
|
|
206
|
+
*
|
|
207
|
+
* Reused there rather than reimplemented, and that is a security decision, not
|
|
208
|
+
* a tidiness one. The obvious `document.xml` reader is
|
|
209
|
+
* `/<w:t[^>]*>([\s\S]*?)<\/w:t>/g` — which is the exact shape of the bug
|
|
210
|
+
* recorded above this function: *n* start tokens each paying for a scan that
|
|
211
|
+
* never finds its end. A `document.xml` of `'<w:t '` repeated 200 000 times is
|
|
212
|
+
* a tiny, deflate-friendly ZIP entry. This walk is immune for the reason it was
|
|
213
|
+
* written, so the second parser is the one not to write.
|
|
214
|
+
*
|
|
215
|
+
* It is also why nothing here parses XML properly. A real parser resolves
|
|
216
|
+
* entities, and would hand a mail attachment billion-laughs expansion and an
|
|
217
|
+
* `<!ENTITY … SYSTEM "file:///etc/passwd">` that reads a file. Those are not
|
|
218
|
+
* defended against below; they are simply not implemented. `&lol9;` comes out
|
|
219
|
+
* as six literal characters, and it must stay that way — reaching for
|
|
220
|
+
* `fast-xml-parser` here would reintroduce all three at once.
|
|
168
221
|
*
|
|
169
222
|
* Deliberately not `mailparser`'s own `text` fallback: that keeps content the
|
|
170
223
|
* recipient never sees. Anything hidden by inline CSS is a place to park an
|
|
171
224
|
* instruction meant only for the model, so those elements are dropped before
|
|
172
225
|
* the tags are stripped.
|
|
173
226
|
*
|
|
227
|
+
* `maxChars` overrides the input slice. The default suits a mail body; a
|
|
228
|
+
* document part needs more, because OOXML spends most of its bytes on
|
|
229
|
+
* formatting and the readable text is a small fraction of it.
|
|
230
|
+
*
|
|
174
231
|
* This is one pass over the input. The cursors below only ever move forward,
|
|
175
232
|
* which is the property that makes the whole function linear no matter what the
|
|
176
233
|
* sender writes: a start token that is never closed is answered once and then
|
|
@@ -181,8 +238,8 @@ function hasHiddenStyle(tag) {
|
|
|
181
238
|
* for the same reason it always was: nothing downstream trusts the stripping,
|
|
182
239
|
* and the fencing in {@link wrapUntrusted} is what carries the weight.
|
|
183
240
|
*/
|
|
184
|
-
export function htmlToText(html) {
|
|
185
|
-
const source = html.slice(0,
|
|
241
|
+
export function htmlToText(html, maxChars = MAX_HTML_CHARS) {
|
|
242
|
+
const source = html.slice(0, maxChars);
|
|
186
243
|
const out = [];
|
|
187
244
|
// Forward-only cursors. Each call may advance them, never rewind them, so
|
|
188
245
|
// across the whole document each scans the input at most once — the same
|
|
@@ -281,16 +338,47 @@ export function htmlToText(html) {
|
|
|
281
338
|
}
|
|
282
339
|
i = gt + 1;
|
|
283
340
|
}
|
|
284
|
-
return (out
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
341
|
+
return decodeCharacterReferences(out.join(''));
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The character references a mail client decodes, decoded the way it does.
|
|
345
|
+
*
|
|
346
|
+
* One alternation, one pass. It used to be six `replace` calls in sequence,
|
|
347
|
+
* and a sequence decodes twice: `&#104;` became `h` in the hex pass
|
|
348
|
+
* and `h` in the decimal pass, a character no client ever shows. And the digit
|
|
349
|
+
* runs were bounded (`{1,7}`) with the semicolon required, while the HTML
|
|
350
|
+
* tokenizer reads *every* digit and takes the semicolon as optional — so
|
|
351
|
+
* `h` reached the model as eleven literal characters and the
|
|
352
|
+
* recipient as an `h`. Here a numeric reference is any digit run, with or
|
|
353
|
+
* without its semicolon; the named ones are the five HTML mail and OOXML use.
|
|
354
|
+
*
|
|
355
|
+
* Out-of-range values, zero and surrogates decode to U+FFFD, which is what a
|
|
356
|
+
* browser renders — a character, and not `''`, which would make `�` an
|
|
357
|
+
* invisible separator inside a word.
|
|
358
|
+
*/
|
|
359
|
+
const CHARACTER_REFERENCE = /&(?:#[xX]([0-9a-fA-F]+);?|#([0-9]+);?|(nbsp|lt|gt|quot|amp|apos);)/g;
|
|
360
|
+
export function decodeCharacterReferences(text) {
|
|
361
|
+
return text.replace(CHARACTER_REFERENCE, (_match, hex, decimal, name) => hex !== undefined
|
|
362
|
+
? fromCodePoint(parseInt(hex, 16))
|
|
363
|
+
: decimal !== undefined
|
|
364
|
+
? fromCodePoint(Number(decimal))
|
|
365
|
+
: (NAMED_REFERENCES.get(String(name).toLowerCase()) ?? _match));
|
|
366
|
+
}
|
|
367
|
+
const NAMED_REFERENCES = new Map([
|
|
368
|
+
['nbsp', ' '],
|
|
369
|
+
['lt', '<'],
|
|
370
|
+
['gt', '>'],
|
|
371
|
+
['quot', '"'],
|
|
372
|
+
['amp', '&'],
|
|
373
|
+
['apos', "'"],
|
|
374
|
+
]);
|
|
375
|
+
/** One character from a numeric reference, U+FFFD where no client has one. */
|
|
376
|
+
function fromCodePoint(value) {
|
|
377
|
+
if (!Number.isInteger(value) || value < 1 || value > 0x10ffff)
|
|
378
|
+
return String.fromCodePoint(0xfffd);
|
|
379
|
+
if (value >= 0xd800 && value <= 0xdfff)
|
|
380
|
+
return String.fromCodePoint(0xfffd);
|
|
381
|
+
return String.fromCodePoint(value);
|
|
294
382
|
}
|
|
295
383
|
/**
|
|
296
384
|
* Removes the characters a human reader cannot see but the model can.
|
|
@@ -312,8 +400,13 @@ export function stripInvisible(input) {
|
|
|
312
400
|
* not the `Archive` they know.
|
|
313
401
|
*/
|
|
314
402
|
export function escapeInvisible(input) {
|
|
315
|
-
|
|
316
|
-
|
|
403
|
+
return input
|
|
404
|
+
.replace(INVISIBLE_CHARS, escapeCodePoint)
|
|
405
|
+
.replace(CONTROL_CHARS, escapeCodePoint);
|
|
406
|
+
}
|
|
407
|
+
/** One character as a Unicode escape, for `escapeInvisible`. */
|
|
408
|
+
function escapeCodePoint(match) {
|
|
409
|
+
return `\\u${match.codePointAt(0).toString(16).padStart(4, '0')}`;
|
|
317
410
|
}
|
|
318
411
|
/**
|
|
319
412
|
* Normalises text before it reaches the model: Unicode-folded, stripped of the
|
|
@@ -334,11 +427,24 @@ export function sanitizeText(input, maxChars = MAX_BODY_CHARS) {
|
|
|
334
427
|
.replace(/[ \t]+/g, ' ')
|
|
335
428
|
.replace(/\n{3,}/g, '\n\n')
|
|
336
429
|
.trim();
|
|
337
|
-
|
|
430
|
+
// `toWellFormed` after the cut, which can split a surrogate pair — and on
|
|
431
|
+
// the whole string either way, because a lone surrogate can arrive decoded
|
|
432
|
+
// out of a header. JSON carries one as an escape and a Python client then
|
|
433
|
+
// fails to encode it; U+FFFD is the honest rendering.
|
|
434
|
+
return (normalized.length > maxChars
|
|
338
435
|
? `${normalized.slice(0, maxChars)}\n… (truncated at ${maxChars} characters)`
|
|
339
|
-
: normalized;
|
|
436
|
+
: normalized).toWellFormed();
|
|
340
437
|
}
|
|
341
|
-
/**
|
|
438
|
+
/**
|
|
439
|
+
* Names of the injection shapes present in `text`.
|
|
440
|
+
*
|
|
441
|
+
* Runs in this process on text the sender wrote, up to a million characters
|
|
442
|
+
* of it for an extracted document — after the parser child has exited, so no
|
|
443
|
+
* timeout covers it. Every pattern above therefore has to be linear on a
|
|
444
|
+
* hostile repetition of its own trigger, and `analyze.test.ts` times each one
|
|
445
|
+
* on such input. A new pattern gets a line in that test before it gets a line
|
|
446
|
+
* in the list.
|
|
447
|
+
*/
|
|
342
448
|
export function detectSuspicious(text) {
|
|
343
449
|
return INJECTION_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
|
|
344
450
|
}
|
|
@@ -401,7 +507,13 @@ export function parseAuthResults(header, trustedAuthservId) {
|
|
|
401
507
|
const match = new RegExp(`\\b${name}=([a-z]+)`, 'i').exec(topmost);
|
|
402
508
|
return match?.[1]?.toLowerCase() ?? 'unknown';
|
|
403
509
|
};
|
|
404
|
-
|
|
510
|
+
// Bounded like a hostname, which is what an authserv-id is. Unbounded, the
|
|
511
|
+
// id went into the metadata block beside the fence — the one part of a
|
|
512
|
+
// `get_message` answer with its own budget and nothing array-shaped to
|
|
513
|
+
// shrink — and a header of sixty thousand letters made the whole message
|
|
514
|
+
// unreadable through this server. The verdict is what matters; an id past
|
|
515
|
+
// this length is not an id, and the header is reported as forgeable.
|
|
516
|
+
const authservId = /^\s*([A-Za-z0-9._-]{1,253})(?![A-Za-z0-9._-])/.exec(topmost ?? '')?.[1];
|
|
405
517
|
return {
|
|
406
518
|
spf: read('spf'),
|
|
407
519
|
dkim: read('dkim'),
|
package/dist/attachments.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { MessageStructureObject } from 'imapflow';
|
|
|
10
10
|
* about reading the two declarations side by side says which is which.
|
|
11
11
|
*/
|
|
12
12
|
export declare const EXECUTABLE_EXTENSIONS: Set<string>;
|
|
13
|
+
export declare function isMediaType(value: string): boolean;
|
|
13
14
|
export interface AttachmentCandidate {
|
|
14
15
|
partId: string;
|
|
15
16
|
filename: string;
|
|
@@ -24,7 +25,18 @@ export interface AttachmentCandidate {
|
|
|
24
25
|
}
|
|
25
26
|
export interface AttachmentPolicy {
|
|
26
27
|
allowedTypes: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Size ceiling for the destination this check is for.
|
|
30
|
+
*
|
|
31
|
+
* Per destination, because there are three of them and they answer different
|
|
32
|
+
* questions: what may enter the model's context, what may be written to disk,
|
|
33
|
+
* what a parser may be handed. The caller picks the number; naming it here
|
|
34
|
+
* keeps the refusal pointing at the variable an operator would actually
|
|
35
|
+
* change.
|
|
36
|
+
*/
|
|
27
37
|
maxBytes: number;
|
|
38
|
+
/** Environment variable behind {@link maxBytes}, for the refusal text. */
|
|
39
|
+
maxBytesName: string;
|
|
28
40
|
}
|
|
29
41
|
/**
|
|
30
42
|
* Strips a filename down to something safe to print and to reason about.
|
package/dist/attachments.js
CHANGED
|
@@ -117,6 +117,22 @@ export const EXECUTABLE_EXTENSIONS = new Set([
|
|
|
117
117
|
* extensions render it as `invoice.pdf`.
|
|
118
118
|
*/
|
|
119
119
|
const DOUBLE_EXTENSION_BAIT = /\.(pdf|docx?|xlsx?|pptx?|txt|csv|jpe?g|png|gif|zip|rtf|odt|ods)\.[a-z0-9]{1,5}$/i;
|
|
120
|
+
/**
|
|
121
|
+
* The shape of a media type — RFC 6838's token characters, bounded on both
|
|
122
|
+
* sides of the slash.
|
|
123
|
+
*
|
|
124
|
+
* The declared type of a part is the sender's string, and it used to travel
|
|
125
|
+
* as it came: into `content_type`, into the refusal note that quotes it, and
|
|
126
|
+
* from there into the `get_message` metadata block outside the fence and into
|
|
127
|
+
* error results the budget never measures. A hundred kilobytes of it in one
|
|
128
|
+
* `Content-Type` header made the message unreadable. Anything that does not
|
|
129
|
+
* match here is not a media type, is never on the allowlist, and is reported
|
|
130
|
+
* as `application/octet-stream` with a note that says so without quoting it.
|
|
131
|
+
*/
|
|
132
|
+
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]{1,127}\/[a-z0-9!#$&^_.+-]{1,127}$/;
|
|
133
|
+
export function isMediaType(value) {
|
|
134
|
+
return MEDIA_TYPE.test(value);
|
|
135
|
+
}
|
|
120
136
|
/**
|
|
121
137
|
* Strips a filename down to something safe to print and to reason about.
|
|
122
138
|
*
|
|
@@ -127,11 +143,30 @@ const DOUBLE_EXTENSION_BAIT = /\.(pdf|docx?|xlsx?|pptx?|txt|csv|jpe?g|png|gif|zi
|
|
|
127
143
|
export function sanitizeFilename(raw) {
|
|
128
144
|
if (raw === undefined || raw.trim() === '')
|
|
129
145
|
return '(unnamed)';
|
|
146
|
+
// Two orderings matter here, and both were wrong.
|
|
147
|
+
//
|
|
148
|
+
// The trim runs *before* the leading dots are taken off, and again after. The
|
|
149
|
+
// other way round, one space defeated the rule: ` .bashrc` has no dot in
|
|
150
|
+
// first position when the strip runs, and the trim that followed exposed it,
|
|
151
|
+
// so the name reached the model looking like a dotfile after all.
|
|
152
|
+
//
|
|
153
|
+
// And the strip never takes the dot that carries the extension. It used to:
|
|
154
|
+
// `.exe` came out as `exe`, `extensionOf` then answered `''`, and an empty
|
|
155
|
+
// extension makes `checkPolicy` skip the executable check entirely rather
|
|
156
|
+
// than fail it — so the blocklist was bypassed by naming the attachment
|
|
157
|
+
// `.exe`. That is the same failure `appref-ms` caused, reached by a different
|
|
158
|
+
// route. The lookahead keeps the leading dots whenever removing them would
|
|
159
|
+
// consume the last one; a name that has another dot further along loses them
|
|
160
|
+
// as before.
|
|
130
161
|
const cleaned = defuseAutoFetch(stripInvisible(raw.normalize('NFKC')))
|
|
131
162
|
.replace(/[/\\]/g, '_')
|
|
132
|
-
.
|
|
163
|
+
.trim()
|
|
164
|
+
.replace(/^\.+(?=.*\.)/, '')
|
|
133
165
|
.trim();
|
|
134
|
-
|
|
166
|
+
// A name of nothing but dots is not a name. It carries no extension either,
|
|
167
|
+
// so keeping it buys the check above nothing and only puts `...` in front of
|
|
168
|
+
// a reader.
|
|
169
|
+
if (cleaned === '' || /^\.+$/.test(cleaned))
|
|
135
170
|
return '(unnamed)';
|
|
136
171
|
return cleaned.length > MAX_FILENAME_LENGTH
|
|
137
172
|
? `${cleaned.slice(0, MAX_FILENAME_LENGTH)}…`
|
|
@@ -178,7 +213,10 @@ function walk(node, depth, found) {
|
|
|
178
213
|
walk(child, depth + 1, found);
|
|
179
214
|
return;
|
|
180
215
|
}
|
|
181
|
-
const
|
|
216
|
+
const declaredType = (node.type ?? 'application/octet-stream').toLowerCase();
|
|
217
|
+
const type = isMediaType(declaredType)
|
|
218
|
+
? declaredType
|
|
219
|
+
: 'application/octet-stream';
|
|
182
220
|
const disposition = node.disposition?.toLowerCase();
|
|
183
221
|
const declaredName = node.dispositionParameters?.filename ?? node.parameters?.name;
|
|
184
222
|
// A part is an attachment when it says so, or when it carries a filename, or
|
|
@@ -191,6 +229,9 @@ function walk(node, depth, found) {
|
|
|
191
229
|
return;
|
|
192
230
|
const filename = sanitizeFilename(declaredName);
|
|
193
231
|
const notes = [];
|
|
232
|
+
if (type !== declaredType) {
|
|
233
|
+
notes.push('the declared content type is not a valid media type and is reported as application/octet-stream');
|
|
234
|
+
}
|
|
194
235
|
if (declaredName !== undefined && DOUBLE_EXTENSION_BAIT.test(declaredName)) {
|
|
195
236
|
notes.push('filename has a double extension — it renders as a document but is not one');
|
|
196
237
|
}
|
|
@@ -198,7 +239,13 @@ function walk(node, depth, found) {
|
|
|
198
239
|
partId: node.part,
|
|
199
240
|
filename,
|
|
200
241
|
contentType: type,
|
|
201
|
-
size
|
|
242
|
+
// imapflow reads the size with `Number(value) || 0`, so a server that
|
|
243
|
+
// writes `1e400` hands over Infinity — which JSON renders as null, and
|
|
244
|
+
// null fails the `size: number` the output schema promises for the whole
|
|
245
|
+
// listing. A size that is not a safe non-negative integer is unknown.
|
|
246
|
+
size: Number.isSafeInteger(node.size) && node.size >= 0
|
|
247
|
+
? node.size
|
|
248
|
+
: undefined,
|
|
202
249
|
disposition,
|
|
203
250
|
allowed: true,
|
|
204
251
|
notes,
|
|
@@ -225,7 +272,7 @@ export function checkPolicy(candidate, policy) {
|
|
|
225
272
|
}
|
|
226
273
|
if (candidate.size !== undefined && candidate.size > policy.maxBytes) {
|
|
227
274
|
allowed = false;
|
|
228
|
-
notes.push(`refused: declared size ${candidate.size} exceeds
|
|
275
|
+
notes.push(`refused: declared size ${candidate.size} exceeds ${policy.maxBytesName} (${policy.maxBytes})`);
|
|
229
276
|
}
|
|
230
277
|
return { ...candidate, allowed, notes };
|
|
231
278
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -31,9 +31,27 @@ export interface ImapConfig {
|
|
|
31
31
|
* Where attachments may be written. Unset means this server never touches the
|
|
32
32
|
* filesystem — setting it is the opt-in, and it is the only source of the
|
|
33
33
|
* target directory. A caller cannot choose where bytes from a stranger land.
|
|
34
|
+
*
|
|
35
|
+
* Stored as the resolved real path of a directory that existed at startup.
|
|
36
|
+
* The value is printed by `get_server_info` and by every attachment listing,
|
|
37
|
+
* and `IMAP_DOWNLOAD_DIR` sits a few lines below `IMAP_PASSWORD` in every
|
|
38
|
+
* compose file — so a value that is not a directory is refused before it can
|
|
39
|
+
* be printed anywhere, and the refusal describes it by length, never by
|
|
40
|
+
* content.
|
|
34
41
|
*/
|
|
35
42
|
downloadDir: string | undefined;
|
|
36
43
|
maxDownloadBytes: number;
|
|
44
|
+
/**
|
|
45
|
+
* Ceiling on the bytes handed to the text-extraction worker.
|
|
46
|
+
*
|
|
47
|
+
* A third limit because it answers a third question. `maxAttachmentBytes`
|
|
48
|
+
* bounds what may enter the model's context — extraction does not, since only
|
|
49
|
+
* the extracted text comes back and `max_chars` bounds that. `maxDownloadBytes`
|
|
50
|
+
* bounds what may be written to the filesystem. This one bounds how much
|
|
51
|
+
* hostile input a parser is asked to chew on inside a thread that can be
|
|
52
|
+
* terminated, which is a memory question and neither of the other two.
|
|
53
|
+
*/
|
|
54
|
+
maxExtractBytes: number;
|
|
37
55
|
}
|
|
38
56
|
export interface Config {
|
|
39
57
|
imap: ImapConfig;
|