superdoc-macros 0.7.0 → 0.8.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 +97 -3
- package/dist/import/cfb.d.ts +44 -0
- package/dist/import/cfb.js +378 -0
- package/dist/import/errors.d.ts +21 -0
- package/dist/import/errors.js +20 -0
- package/dist/import/ms-ovba.d.ts +20 -0
- package/dist/import/ms-ovba.js +201 -0
- package/dist/import/vba.d.ts +132 -0
- package/dist/import/vba.js +785 -0
- package/dist/import/zip.d.ts +35 -0
- package/dist/import/zip.js +253 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/manager.d.ts +7 -1
- package/dist/manager.js +21 -6
- package/dist/messages.d.ts +6 -0
- package/dist/messages.js +2 -0
- package/dist/recorder/recorder.d.ts +7 -0
- package/dist/recorder/recorder.js +24 -9
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -9,10 +9,11 @@ Three capabilities, in the spirit of Word macros:
|
|
|
9
9
|
| **Scripted macros** | User-written JavaScript macros that run in a real sandbox (an isolated iframe) against a small, safe document API |
|
|
10
10
|
| **Macro recorder** | "Record → work normally → stop → replay" — records commands and typing, like Word's recorder |
|
|
11
11
|
| **Snippets (AutoText)** | Templates with variables (`{{date}}`, `{{selection}}`…), keyboard shortcuts, and auto-expansion while typing (type a trigger word + space) |
|
|
12
|
+
| **VBA import (read-only)** | Reads the macros already inside a `.docm` and shows the user their real source, so they can port it. Nothing is executed |
|
|
12
13
|
|
|
13
14
|
Plus: persistence (localStorage or custom storage), JSON import/export, keyboard shortcut binding, and localizable runtime messages (English by default, Hebrew locale included).
|
|
14
15
|
|
|
15
|
-
> **A note on VBA:** the toolkit does not execute VBA
|
|
16
|
+
> **A note on VBA:** the toolkit does not *execute* VBA — there is no VBA engine in the browser, and running document-supplied code is not something it will grow into. What it does instead is two things: it lets a user **see** the macros a `.docm` already contains (section 4), and it provides a parallel, JavaScript-based macro system to rewrite them in.
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
18
19
|
|
|
@@ -87,7 +88,9 @@ If you must waive isolation (e.g. a CSP that blocks `srcdoc`), switch to the dir
|
|
|
87
88
|
## 2. Macro recorder
|
|
88
89
|
|
|
89
90
|
```ts
|
|
90
|
-
kit.startRecording()
|
|
91
|
+
if (!kit.startRecording()) {
|
|
92
|
+
throw new Error('A macro is running or an unsaved recording is pending');
|
|
93
|
+
}
|
|
91
94
|
// the user works normally: typing, formatting, lists...
|
|
92
95
|
const recording = kit.stopRecording('Standard intro', 'Ctrl+Alt+1');
|
|
93
96
|
|
|
@@ -97,6 +100,8 @@ await kit.replayRecording(recording.id);
|
|
|
97
100
|
|
|
98
101
|
The recorder captures **commands and typing**, not caret positions — exactly like Word's recorder: replay applies wherever the caret stands. Consecutive keystrokes coalesce into one step, `undo`/`redo` are not recorded (configurable via `RecorderOptions`), and recordings persist as clean JSON that can be exported and shared. `updateRecording({id, name?, shortcut?})` renames a recording or edits its shortcut.
|
|
99
102
|
|
|
103
|
+
Finalization is loss-aware. A command payload that cannot be stored faithfully is reported instead of silently omitted; `stopRecording()` keeps the stopped capture pending until it is saved or explicitly cancelled. A mixed capture requires `{ allowIncomplete: true }` after the host has obtained user consent, while a capture containing no replayable step is rejected as `recording-uncapturable`. Storage, capacity, and validation failures are retryable: fix the problem and call `stopRecording()` again. Starting a new recording while one is pending is rejected, preventing accidental loss.
|
|
104
|
+
|
|
100
105
|
## 3. Snippets and auto-text
|
|
101
106
|
|
|
102
107
|
```ts
|
|
@@ -114,6 +119,93 @@ await kit.expandSnippet(id); // or expand explicitly / via the shortcut
|
|
|
114
119
|
|
|
115
120
|
Built-in variables: `{{date}}`, `{{time}}`, `{{datetime}}` (formatted with the browser locale, or an explicit `locale` option), `{{selection}}`. Any other name resolves from the `variables` passed to `expandSnippet`; a variable with no value stays visible in the text.
|
|
116
121
|
|
|
122
|
+
## 4. Reading the VBA in an existing `.docm`
|
|
123
|
+
|
|
124
|
+
A user who has relied on a macro-enabled document for years should not be told their macros are simply gone. This reads the macro project out of the package and hands back each module's real source text:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { extractVbaFromDocx } from 'superdoc-macros';
|
|
128
|
+
|
|
129
|
+
const result = await extractVbaFromDocx(fileBytes); // Uint8Array of the .docm
|
|
130
|
+
|
|
131
|
+
if (!result.ok) {
|
|
132
|
+
// 'no-macros' is the ordinary answer for a document without any.
|
|
133
|
+
console.log(result.reason, result.message);
|
|
134
|
+
} else {
|
|
135
|
+
for (const module of result.project.modules) {
|
|
136
|
+
console.log(module.name, module.kind, module.source);
|
|
137
|
+
}
|
|
138
|
+
for (const warning of result.project.warnings) {
|
|
139
|
+
console.log(warning.code, warning.message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Hebrew and other non-Latin sources come back correct: VBA stores text in the project's code page (windows-1255 for Hebrew), and that is honoured rather than assumed to be UTF-8.
|
|
145
|
+
|
|
146
|
+
When the answer decides something other than what to display — which extension to save under, say — ask the cheap question instead. `findVbaPart` reads only the relationship and content-type parts, decompresses nothing from the project itself, and never throws:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { findVbaPart } from 'superdoc-macros';
|
|
150
|
+
|
|
151
|
+
const part = await findVbaPart(fileBytes); // 'word/vbaProject.bin' | null
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Use this rather than "did extraction succeed", because the two answer different questions: a project too damaged to read is still a macro project, and a document carrying one must keep its `.docm` extension either way. For the same reason a failed extraction still reports `partName` whenever a macro part was located.
|
|
155
|
+
|
|
156
|
+
Verified against genuine Office output, not only synthetic files — Word-authored `.docm` documents (including a Cyrillic, code-page-1251 project, and projects carrying `Tools ▸ References` entries) come back with their real module names, kinds and recorded-macro source intact. The test suite pins this with a real macro project as a fixture, alongside synthetic ones covering both container layouts, a Hebrew code page, and the record whose declared length the format itself gets wrong.
|
|
157
|
+
|
|
158
|
+
### This does not run anything, and it is not wired to anything that does
|
|
159
|
+
|
|
160
|
+
The security position is deliberate and worth stating plainly, because VBA in a document is code that arrived from outside:
|
|
161
|
+
|
|
162
|
+
- **Nothing executes.** There is no interpreter, no transpiler, no `eval`. The functions decode bytes into strings.
|
|
163
|
+
- **The extracted `source` is data, not code.** Never pass it to a script runner or to `new Function`: VBA is not JavaScript, so anything that did run would be attacker-chosen text reaching a JavaScript parser.
|
|
164
|
+
- **Extraction is deliberately *not* a `MacroKit` method.** It creates no saved macro, binds no shortcut, and writes nothing to storage. Turning an extracted module into something runnable stays an explicit human decision, and the API shape says so.
|
|
165
|
+
- **Auto-run entry points are reported, never honoured.** Word runs `AutoOpen`, `Document_Open` and friends on its own, which is exactly why they are a long-standing malware vector. They are listed in `project.autoRunProcedures` and raise an `auto-run-macros` warning so a host can tell the user what the original document did. `scanForAutoRunProcedures(source)` is exported for the same check on VBA from anywhere else.
|
|
166
|
+
|
|
167
|
+
### Failure is a result, not an exception
|
|
168
|
+
|
|
169
|
+
`extractVbaFromDocx` and `extractVbaProject` never throw — not on a truncated file, not on a hostile one. Every outcome is a result carrying a stable `reason` (`no-macros`, `not-a-package`, `not-a-vba-project`, `too-large`, `unsupported`, `unreadable`).
|
|
170
|
+
|
|
171
|
+
Partial success is reported honestly rather than silently: a module that cannot be decoded appears in `warnings` with its name instead of vanishing from the list. Each warning carries a stable `code` to switch on (the `message` is English):
|
|
172
|
+
|
|
173
|
+
| `code` | Meaning |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| `auto-run-macros` | The document defines macros Word would run on open. Read for review; not executed |
|
|
176
|
+
| `module-unreadable` | A named module could not be decoded and was skipped |
|
|
177
|
+
| `module-truncated` | A module's source was longer than the cap and was cut |
|
|
178
|
+
| `declared-modules-missing` | The project declares modules the directory walk did not produce — the list you have is incomplete, and these are the names |
|
|
179
|
+
| `incomplete-directory` | The macro directory ended unexpectedly; modules may be missing |
|
|
180
|
+
| `no-modules` | The project has no readable modules |
|
|
181
|
+
| `unknown-code-page` | The declared code page is unavailable here; text fell back to windows-1252 and non-Latin characters may be wrong |
|
|
182
|
+
| `module-limit` / `total-size-limit` | A `VBA_LIMITS` cap stopped the read |
|
|
183
|
+
|
|
184
|
+
The gap-reporting codes exist because a plausible-looking short list is worse than a short list with an explanation. `declared-modules-missing` is the strongest of them: it cross-checks the modules found against the project's own manifest, so a malformed record that desynchronizes the directory walk surfaces as a named gap rather than a module that quietly is not there.
|
|
185
|
+
|
|
186
|
+
### Bounded against a file built to hurt you
|
|
187
|
+
|
|
188
|
+
Every reader is bounded (`VBA_LIMITS`, `CFB_LIMITS`, `ZIP_LIMITS`) and every chain walk is loop-guarded, so a container whose allocation table points in a circle terminates, and a part declaring an absurd size is refused.
|
|
189
|
+
|
|
190
|
+
Counting structural items is not enough on its own, though, and that is the part worth knowing about. A caller's real exposure is in quantities *derived* from the file, which a small input can inflate enormously — so those are metered too:
|
|
191
|
+
|
|
192
|
+
- **Path length and nesting depth**, not just entry count. Storages nested one inside the next make each path longer than the last, so retained path strings grow with the square of the entry count.
|
|
193
|
+
- **Bytes decompression actually produces**, not the size a header claims. The ZIP reader enforces its cap while inflating, so a part that declares a kilobyte and expands to a gigabyte is abandoned rather than buffered and then rejected.
|
|
194
|
+
- **Warning count**, keyed on modules *considered* rather than modules successfully read — otherwise a project whose every stream fails emits one warning per record.
|
|
195
|
+
- **Regex exposure.** XML is scanned with `indexOf`, not with a `<Tag[^>]*>` pattern, which backtracks quadratically on input containing no `>` at all. On a 260 KB input the difference measured 3 ms against 1.7 s, and it grows with the square.
|
|
196
|
+
|
|
197
|
+
These are regression-tested, not just asserted: a deterministic corruption sweep over the container header and allocation tables, a deeply nested directory, XML crafted to force backtracking, a declared-size lie over expanding data, and a project declaring more modules than it has streams.
|
|
198
|
+
|
|
199
|
+
No new dependencies: the OLE container reader, the ZIP reader and the [MS-OVBA] decompressor are part of the package. Inflation is delegated to the platform's own `DecompressionStream` — [Baseline across browsers since 2023](https://web.dev/blog/compressionstreams), and present in Node 18+ — so the decompression itself runs in audited native code rather than a hand-rolled inflater. An environment without it gets `reason: 'unsupported'`, not a crash.
|
|
200
|
+
|
|
201
|
+
### Macros survive a save
|
|
202
|
+
|
|
203
|
+
Independently of the above: a macro-enabled document that SuperDoc opens and exports **keeps its macro project intact**. Verified end-to-end against the SuperDoc v2 engine — open a `.docm`, edit the body, export, and `word/vbaProject.bin` comes back byte-identical, with the `macroEnabled` content type and the `vbaProject` relationship preserved, in all three export modes.
|
|
204
|
+
|
|
205
|
+
Two things are the host's job:
|
|
206
|
+
- **Save with the right extension.** A file carrying a macro project must stay `.docm`/`.dotm`; handing Word a `.docx` with a `vbaProject` part inside makes it complain.
|
|
207
|
+
- **Don't rebuild the package by hand.** If you ever do, copy `word/vbaProject.bin`, `word/vbaData.xml`, the `vbaProject` relationship and the `[Content_Types].xml` override across from the original.
|
|
208
|
+
|
|
117
209
|
## Localization
|
|
118
210
|
|
|
119
211
|
Runtime messages (failures shown to end users) default to English. A host with a localized UI swaps them once at startup:
|
|
@@ -156,7 +248,9 @@ The whole toolkit works against a single `MacroHost` interface (commands, text i
|
|
|
156
248
|
|
|
157
249
|
## Known limitations
|
|
158
250
|
|
|
159
|
-
- No VBA execution. `.docm` files open normally
|
|
251
|
+
- No VBA execution. `.docm` files open normally and their macros can be *read* (section 4), but nothing runs them — by design, not for want of trying.
|
|
252
|
+
- VBA import reads code modules. It does not reconstruct UserForm layouts (only a form's code-behind), and it does not read the `vbaData.xml` keyboard-shortcut map — a `.docm`'s macro key bindings are not imported.
|
|
253
|
+
- VBA import does not open ZIP64 or password-protected packages; both are refused with `reason: 'unsupported'` rather than guessed at.
|
|
160
254
|
- The recorder does not capture caret movement or mouse selection (as in Word — replay acts from the current caret).
|
|
161
255
|
- `deleteBackward` and full-document text use the engine's internal view (ProseMirror) — available in the browser, not headless.
|
|
162
256
|
- The `eval` runner's time cap cannot stop an infinite synchronous loop (the iframe runner's can).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare const CFB_LIMITS: {
|
|
2
|
+
/** Whole-container size. A `vbaProject.bin` is normally tens of kilobytes. */
|
|
3
|
+
readonly maxFileBytes: number;
|
|
4
|
+
/** Directory entries, i.e. streams plus storages. */
|
|
5
|
+
readonly maxEntries: 10000;
|
|
6
|
+
/** Sectors in one chain — the ceiling on any single stream's length. */
|
|
7
|
+
readonly maxChainSectors: 200000;
|
|
8
|
+
/** One stream's declared size. */
|
|
9
|
+
readonly maxStreamBytes: number;
|
|
10
|
+
/**
|
|
11
|
+
* Nesting depth of the directory tree, and the length of a resulting path.
|
|
12
|
+
*
|
|
13
|
+
* These are not cosmetic. Counting entries alone bounds how many paths
|
|
14
|
+
* exist but not how long they are, and the two multiply: 10,000 storages
|
|
15
|
+
* nested one inside the next, with 31-character names, describe paths whose
|
|
16
|
+
* lengths run 32, 64, 96 … — over a gigabyte of retained strings from a
|
|
17
|
+
* container that compresses to a few kilobytes. A real VBA project nests
|
|
18
|
+
* one storage deep.
|
|
19
|
+
*/
|
|
20
|
+
readonly maxDepth: 32;
|
|
21
|
+
readonly maxPathLength: 1024;
|
|
22
|
+
};
|
|
23
|
+
export type CfbEntryType = 'root' | 'storage' | 'stream';
|
|
24
|
+
export interface CfbEntry {
|
|
25
|
+
/** Absolute path inside the container, e.g. `/VBA/dir`. Root is `/`. */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
/** The entry's own name, e.g. `dir`. */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly type: CfbEntryType;
|
|
30
|
+
/** Declared length in bytes. `0` for storages. */
|
|
31
|
+
readonly size: number;
|
|
32
|
+
}
|
|
33
|
+
export interface CfbContainer {
|
|
34
|
+
/** Every reachable entry, storages included, in directory-tree order. */
|
|
35
|
+
readonly entries: readonly CfbEntry[];
|
|
36
|
+
/**
|
|
37
|
+
* The bytes of one stream, by exact path.
|
|
38
|
+
*
|
|
39
|
+
* @throws {VbaParseError} when the path is absent, names a storage, or the
|
|
40
|
+
* stream's allocation chain is malformed.
|
|
41
|
+
*/
|
|
42
|
+
readStream(path: string): Uint8Array;
|
|
43
|
+
}
|
|
44
|
+
export declare function readCfb(bytes: Uint8Array): CfbContainer;
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read-only reader for MS-CFB (the OLE "compound file" container).
|
|
3
|
+
*
|
|
4
|
+
* `vbaProject.bin` is not XML — it is a small filesystem in a file, with a
|
|
5
|
+
* sector allocation table, a directory tree, and a second allocation table
|
|
6
|
+
* for streams under 4096 bytes. VBA sources live in streams inside it, so
|
|
7
|
+
* reaching them means walking that structure.
|
|
8
|
+
*
|
|
9
|
+
* Written by hand rather than pulled from a package, for two reasons. The
|
|
10
|
+
* toolkit ships with no runtime dependencies, and this is the one place
|
|
11
|
+
* user-supplied bytes are parsed — a job worth owning outright, kept
|
|
12
|
+
* read-only (nothing here can write a CFB file) and defensive throughout:
|
|
13
|
+
*
|
|
14
|
+
* - Every sector offset is bounds-checked before it is read.
|
|
15
|
+
* - Every chain walk tracks visited sectors, so a file whose allocation
|
|
16
|
+
* table points in a circle terminates instead of hanging.
|
|
17
|
+
* - Entry counts, chain lengths and stream sizes are capped.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately unimplemented: writing, encryption, and CFB v4 files whose
|
|
20
|
+
* header declares a sector size other than 512 or 4096 bytes.
|
|
21
|
+
*/
|
|
22
|
+
import { VbaParseError } from './errors.js';
|
|
23
|
+
export const CFB_LIMITS = {
|
|
24
|
+
/** Whole-container size. A `vbaProject.bin` is normally tens of kilobytes. */
|
|
25
|
+
maxFileBytes: 64 * 1024 * 1024,
|
|
26
|
+
/** Directory entries, i.e. streams plus storages. */
|
|
27
|
+
maxEntries: 10_000,
|
|
28
|
+
/** Sectors in one chain — the ceiling on any single stream's length. */
|
|
29
|
+
maxChainSectors: 200_000,
|
|
30
|
+
/** One stream's declared size. */
|
|
31
|
+
maxStreamBytes: 32 * 1024 * 1024,
|
|
32
|
+
/**
|
|
33
|
+
* Nesting depth of the directory tree, and the length of a resulting path.
|
|
34
|
+
*
|
|
35
|
+
* These are not cosmetic. Counting entries alone bounds how many paths
|
|
36
|
+
* exist but not how long they are, and the two multiply: 10,000 storages
|
|
37
|
+
* nested one inside the next, with 31-character names, describe paths whose
|
|
38
|
+
* lengths run 32, 64, 96 … — over a gigabyte of retained strings from a
|
|
39
|
+
* container that compresses to a few kilobytes. A real VBA project nests
|
|
40
|
+
* one storage deep.
|
|
41
|
+
*/
|
|
42
|
+
maxDepth: 32,
|
|
43
|
+
maxPathLength: 1_024,
|
|
44
|
+
};
|
|
45
|
+
const SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
46
|
+
const HEADER_BYTES = 512;
|
|
47
|
+
const DIRECTORY_ENTRY_BYTES = 128;
|
|
48
|
+
/** Sector ids above this are sentinels (end-of-chain, free, FAT, DIFAT). */
|
|
49
|
+
const MAX_REGULAR_SECTOR = 0xfffffffa;
|
|
50
|
+
/** DIFAT entries per header. */
|
|
51
|
+
const HEADER_DIFAT_ENTRIES = 109;
|
|
52
|
+
const HEADER_DIFAT_OFFSET = 76;
|
|
53
|
+
export function readCfb(bytes) {
|
|
54
|
+
if (bytes.length > CFB_LIMITS.maxFileBytes) {
|
|
55
|
+
throw new VbaParseError('too-large', 'OLE container: file exceeds the size cap');
|
|
56
|
+
}
|
|
57
|
+
if (bytes.length < HEADER_BYTES) {
|
|
58
|
+
throw new VbaParseError('malformed', 'OLE container: file is shorter than its header');
|
|
59
|
+
}
|
|
60
|
+
for (let i = 0; i < SIGNATURE.length; i += 1) {
|
|
61
|
+
if (bytes[i] !== SIGNATURE[i]) {
|
|
62
|
+
throw new VbaParseError('malformed', 'OLE container: wrong signature');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
66
|
+
const u32 = (offset) => {
|
|
67
|
+
if (offset < 0 || offset + 4 > bytes.length) {
|
|
68
|
+
throw new VbaParseError('malformed', 'OLE container: read past the end of the file');
|
|
69
|
+
}
|
|
70
|
+
return view.getUint32(offset, true);
|
|
71
|
+
};
|
|
72
|
+
const sectorShift = view.getUint16(30, true);
|
|
73
|
+
if (sectorShift !== 9 && sectorShift !== 12) {
|
|
74
|
+
throw new VbaParseError('unsupported', `OLE container: unsupported sector size (2^${sectorShift})`);
|
|
75
|
+
}
|
|
76
|
+
const miniSectorShift = view.getUint16(32, true);
|
|
77
|
+
if (miniSectorShift !== 6) {
|
|
78
|
+
throw new VbaParseError('unsupported', `OLE container: unsupported mini sector size (2^${miniSectorShift})`);
|
|
79
|
+
}
|
|
80
|
+
const sectorSize = 1 << sectorShift;
|
|
81
|
+
const miniSectorSize = 1 << miniSectorShift;
|
|
82
|
+
const fatSectorCount = u32(44);
|
|
83
|
+
const firstDirectorySector = u32(48);
|
|
84
|
+
const miniStreamCutoff = u32(56);
|
|
85
|
+
const firstMiniFatSector = u32(60);
|
|
86
|
+
const miniFatSectorCount = u32(64);
|
|
87
|
+
const firstDifatSector = u32(68);
|
|
88
|
+
const difatSectorCount = u32(72);
|
|
89
|
+
const entriesPerSector = sectorSize / 4;
|
|
90
|
+
/** Byte offset of a sector. The header occupies the space of sector -1. */
|
|
91
|
+
const sectorOffset = (sector) => {
|
|
92
|
+
const offset = (sector + 1) * sectorSize;
|
|
93
|
+
if (sector < 0 || offset + sectorSize > bytes.length) {
|
|
94
|
+
throw new VbaParseError('malformed', `OLE container: sector ${sector} lies outside the file`);
|
|
95
|
+
}
|
|
96
|
+
return offset;
|
|
97
|
+
};
|
|
98
|
+
/* ---------- allocation tables ---------- */
|
|
99
|
+
// How many sectors the file could possibly hold. Declared counts are just
|
|
100
|
+
// claims — a crafted header can say the allocation table spans four billion
|
|
101
|
+
// sectors — so every table is also bounded by what the file physically has
|
|
102
|
+
// room for. Without this, an attacker sizes our memory use, not us.
|
|
103
|
+
const sectorsInFile = Math.max(1, Math.ceil(bytes.length / sectorSize));
|
|
104
|
+
// The allocation table can never usefully describe more sectors than the
|
|
105
|
+
// file holds, whatever its header claims. Deriving the ceiling first lets
|
|
106
|
+
// the DIFAT walk below stop collecting as soon as it has enough, instead of
|
|
107
|
+
// gathering millions of sector numbers it will then discard.
|
|
108
|
+
const fatLimit = Math.min(Math.max(fatSectorCount, 1) * entriesPerSector,
|
|
109
|
+
// One full table sector of slack past the last real sector, so a valid
|
|
110
|
+
// file is never trimmed while a lying one cannot inflate the table.
|
|
111
|
+
sectorsInFile + entriesPerSector);
|
|
112
|
+
const fatSectorLimit = Math.ceil(fatLimit / entriesPerSector) + 1;
|
|
113
|
+
// The DIFAT is the index *of* the allocation table: the first 109 entries
|
|
114
|
+
// live in the header, the rest in a chain of their own sectors.
|
|
115
|
+
const fatSectors = [];
|
|
116
|
+
for (let i = 0; i < HEADER_DIFAT_ENTRIES && fatSectors.length < fatSectorLimit; i += 1) {
|
|
117
|
+
fatSectors.push(u32(HEADER_DIFAT_OFFSET + i * 4));
|
|
118
|
+
}
|
|
119
|
+
let difatSector = firstDifatSector;
|
|
120
|
+
const seenDifat = new Set();
|
|
121
|
+
while (difatSector <= MAX_REGULAR_SECTOR &&
|
|
122
|
+
fatSectors.length < fatSectorLimit &&
|
|
123
|
+
seenDifat.size <= Math.min(difatSectorCount, sectorsInFile) + 1) {
|
|
124
|
+
if (seenDifat.has(difatSector)) {
|
|
125
|
+
throw new VbaParseError('malformed', 'OLE container: DIFAT chain loops');
|
|
126
|
+
}
|
|
127
|
+
seenDifat.add(difatSector);
|
|
128
|
+
const base = sectorOffset(difatSector);
|
|
129
|
+
for (let i = 0; i < entriesPerSector - 1 && fatSectors.length < fatSectorLimit; i += 1) {
|
|
130
|
+
fatSectors.push(u32(base + i * 4));
|
|
131
|
+
}
|
|
132
|
+
difatSector = u32(base + (entriesPerSector - 1) * 4);
|
|
133
|
+
}
|
|
134
|
+
const fat = [];
|
|
135
|
+
for (const sector of fatSectors) {
|
|
136
|
+
if (sector > MAX_REGULAR_SECTOR)
|
|
137
|
+
continue;
|
|
138
|
+
if (fat.length >= fatLimit)
|
|
139
|
+
break;
|
|
140
|
+
const base = sectorOffset(sector);
|
|
141
|
+
for (let i = 0; i < entriesPerSector; i += 1)
|
|
142
|
+
fat.push(u32(base + i * 4));
|
|
143
|
+
}
|
|
144
|
+
if (fat.length === 0) {
|
|
145
|
+
throw new VbaParseError('malformed', 'OLE container: empty allocation table');
|
|
146
|
+
}
|
|
147
|
+
/** The sector ids of one chain, in order. */
|
|
148
|
+
const chain = (start) => {
|
|
149
|
+
const sectors = [];
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
let sector = start;
|
|
152
|
+
while (sector <= MAX_REGULAR_SECTOR) {
|
|
153
|
+
if (seen.has(sector)) {
|
|
154
|
+
throw new VbaParseError('malformed', 'OLE container: sector chain loops');
|
|
155
|
+
}
|
|
156
|
+
if (sectors.length >= CFB_LIMITS.maxChainSectors) {
|
|
157
|
+
throw new VbaParseError('too-large', 'OLE container: sector chain exceeds the length cap');
|
|
158
|
+
}
|
|
159
|
+
seen.add(sector);
|
|
160
|
+
sectors.push(sector);
|
|
161
|
+
const next = fat[sector];
|
|
162
|
+
if (next === undefined) {
|
|
163
|
+
// A chain running off the end of the table is a truncated file. The
|
|
164
|
+
// sectors gathered so far are still real, so stop rather than fail:
|
|
165
|
+
// callers slice to the declared size and will notice a short read.
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
sector = next;
|
|
169
|
+
}
|
|
170
|
+
return sectors;
|
|
171
|
+
};
|
|
172
|
+
const readChain = (start, byteCount) => {
|
|
173
|
+
if (byteCount > CFB_LIMITS.maxStreamBytes) {
|
|
174
|
+
throw new VbaParseError('too-large', 'OLE container: stream exceeds the size cap');
|
|
175
|
+
}
|
|
176
|
+
if (byteCount === 0)
|
|
177
|
+
return new Uint8Array(0);
|
|
178
|
+
// The chain is walked and measured *before* the buffer exists. A declared
|
|
179
|
+
// size the chain cannot supply is the signature of a truncated file, and
|
|
180
|
+
// allocating tens of megabytes only to discover that is work an attacker
|
|
181
|
+
// gets to request for free.
|
|
182
|
+
const sectors = chain(start);
|
|
183
|
+
if (sectors.length * sectorSize < byteCount) {
|
|
184
|
+
throw new VbaParseError('malformed', 'OLE container: stream is shorter than its declared size');
|
|
185
|
+
}
|
|
186
|
+
const out = new Uint8Array(byteCount);
|
|
187
|
+
let written = 0;
|
|
188
|
+
for (const sector of sectors) {
|
|
189
|
+
if (written >= byteCount)
|
|
190
|
+
break;
|
|
191
|
+
const base = sectorOffset(sector);
|
|
192
|
+
const take = Math.min(sectorSize, byteCount - written);
|
|
193
|
+
out.set(bytes.subarray(base, base + take), written);
|
|
194
|
+
written += take;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
198
|
+
/* ---------- directory ---------- */
|
|
199
|
+
const directorySectors = chain(firstDirectorySector);
|
|
200
|
+
const entriesPerDirectorySector = sectorSize / DIRECTORY_ENTRY_BYTES;
|
|
201
|
+
const raw = [];
|
|
202
|
+
for (const sector of directorySectors) {
|
|
203
|
+
const base = sectorOffset(sector);
|
|
204
|
+
for (let i = 0; i < entriesPerDirectorySector; i += 1) {
|
|
205
|
+
if (raw.length >= CFB_LIMITS.maxEntries) {
|
|
206
|
+
throw new VbaParseError('too-large', 'OLE container: too many directory entries');
|
|
207
|
+
}
|
|
208
|
+
const at = base + i * DIRECTORY_ENTRY_BYTES;
|
|
209
|
+
const nameByteLength = view.getUint16(at + 64, true);
|
|
210
|
+
const objectType = view.getUint8(at + 66);
|
|
211
|
+
// Names are UTF-16LE with a null terminator counted in the length.
|
|
212
|
+
const usable = Math.max(0, Math.min(nameByteLength, 64) - 2);
|
|
213
|
+
let name = '';
|
|
214
|
+
for (let c = 0; c + 1 < usable; c += 2) {
|
|
215
|
+
name += String.fromCharCode(view.getUint16(at + c, true));
|
|
216
|
+
}
|
|
217
|
+
const low = u32(at + 120);
|
|
218
|
+
const high = u32(at + 124);
|
|
219
|
+
// MS-CFB names this case explicitly: older writers left the high half
|
|
220
|
+
// of a v3 stream size uninitialized, and it "is recommended that
|
|
221
|
+
// parsers ignore the most significant 32 bits of this field in version
|
|
222
|
+
// 3 compound files". Honouring the garbage instead turns an otherwise
|
|
223
|
+
// valid file into an oversized stream and loses the whole project — so
|
|
224
|
+
// v3 uses the low word, full stop, and only v4 reads both halves.
|
|
225
|
+
const size = sectorShift === 9 ? low : low + high * 0x1_0000_0000;
|
|
226
|
+
raw.push({
|
|
227
|
+
name,
|
|
228
|
+
type: objectType === 5 ? 'root' : objectType === 1 ? 'storage' : objectType === 2 ? 'stream' : 'unallocated',
|
|
229
|
+
left: u32(at + 68),
|
|
230
|
+
right: u32(at + 72),
|
|
231
|
+
child: u32(at + 76),
|
|
232
|
+
startSector: u32(at + 116),
|
|
233
|
+
size,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const root = raw[0];
|
|
238
|
+
if (!root || root.type !== 'root') {
|
|
239
|
+
throw new VbaParseError('malformed', 'OLE container: missing root directory entry');
|
|
240
|
+
}
|
|
241
|
+
// The directory is a red-black tree: `child` descends into a storage,
|
|
242
|
+
// `left`/`right` are siblings at the same level. Walked with an explicit
|
|
243
|
+
// stack — a degenerate tree would blow a recursive walk's call stack.
|
|
244
|
+
const entries = [];
|
|
245
|
+
const byPath = new Map();
|
|
246
|
+
const visited = new Set();
|
|
247
|
+
const pending = [
|
|
248
|
+
{ id: root.child, prefix: '', depth: 1 },
|
|
249
|
+
];
|
|
250
|
+
while (pending.length > 0) {
|
|
251
|
+
const next = pending.pop();
|
|
252
|
+
if (!next)
|
|
253
|
+
break;
|
|
254
|
+
const { id, prefix, depth } = next;
|
|
255
|
+
if (id > MAX_REGULAR_SECTOR || visited.has(id))
|
|
256
|
+
continue;
|
|
257
|
+
visited.add(id);
|
|
258
|
+
const entry = raw[id];
|
|
259
|
+
if (!entry || entry.type === 'unallocated')
|
|
260
|
+
continue;
|
|
261
|
+
// Depth and path length are capped together. Entry *count* alone does
|
|
262
|
+
// not bound the size of the path strings this loop retains: storages
|
|
263
|
+
// nested one inside the next make each path longer than the last, so the
|
|
264
|
+
// total is quadratic in the entry count. See `CFB_LIMITS.maxDepth`.
|
|
265
|
+
if (depth > CFB_LIMITS.maxDepth) {
|
|
266
|
+
throw new VbaParseError('too-large', 'OLE container: directory nests deeper than the cap');
|
|
267
|
+
}
|
|
268
|
+
const path = `${prefix}/${entry.name}`;
|
|
269
|
+
if (path.length > CFB_LIMITS.maxPathLength) {
|
|
270
|
+
throw new VbaParseError('too-large', 'OLE container: directory path exceeds the length cap');
|
|
271
|
+
}
|
|
272
|
+
if (entry.type === 'storage' || entry.type === 'stream') {
|
|
273
|
+
entries.push({ path, name: entry.name, type: entry.type, size: entry.type === 'stream' ? entry.size : 0 });
|
|
274
|
+
// First writer wins: a file with duplicate paths cannot make a later
|
|
275
|
+
// entry shadow the one already reported.
|
|
276
|
+
if (!byPath.has(path))
|
|
277
|
+
byPath.set(path, entry);
|
|
278
|
+
}
|
|
279
|
+
if (entry.type === 'storage')
|
|
280
|
+
pending.push({ id: entry.child, prefix: path, depth: depth + 1 });
|
|
281
|
+
pending.push({ id: entry.left, prefix, depth });
|
|
282
|
+
pending.push({ id: entry.right, prefix, depth });
|
|
283
|
+
}
|
|
284
|
+
/* ---------- streams ---------- */
|
|
285
|
+
/**
|
|
286
|
+
* The mini stream and its allocation table, built once.
|
|
287
|
+
*
|
|
288
|
+
* The outcome is cached either way — a *failure* included. Rebuilding it per
|
|
289
|
+
* call would re-run a multi-megabyte zero-filled allocation for every
|
|
290
|
+
* stream in a container whose root entry is malformed, turning one bad
|
|
291
|
+
* header into thousands of repeats of the same doomed work.
|
|
292
|
+
*/
|
|
293
|
+
let mini = null;
|
|
294
|
+
const miniContext = () => {
|
|
295
|
+
if (mini === null) {
|
|
296
|
+
try {
|
|
297
|
+
const stream = readChain(root.startSector, Math.min(root.size, CFB_LIMITS.maxStreamBytes));
|
|
298
|
+
const table = [];
|
|
299
|
+
// The mini table indexes 64-byte mini sectors inside the mini stream,
|
|
300
|
+
// *not* full sectors of the file. Bounding it by the file's sector
|
|
301
|
+
// count — the ceiling that is right for the FAT — is off by the ratio
|
|
302
|
+
// between the two, and silently truncates the table: on a small
|
|
303
|
+
// container that caps it at 256 entries, so a project with more than
|
|
304
|
+
// ~16 KB of small streams loses modules. Almost everything in a real
|
|
305
|
+
// vbaProject.bin is a mini stream, so this bound has to be the right
|
|
306
|
+
// dimension.
|
|
307
|
+
const limit = Math.min(Math.max(miniFatSectorCount, 1) * entriesPerSector, Math.ceil(stream.length / miniSectorSize) + entriesPerSector);
|
|
308
|
+
for (const sector of chain(firstMiniFatSector)) {
|
|
309
|
+
if (table.length >= limit)
|
|
310
|
+
break;
|
|
311
|
+
const base = sectorOffset(sector);
|
|
312
|
+
for (let i = 0; i < entriesPerSector; i += 1)
|
|
313
|
+
table.push(u32(base + i * 4));
|
|
314
|
+
}
|
|
315
|
+
mini = { ok: true, stream, table };
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
mini = {
|
|
319
|
+
ok: false,
|
|
320
|
+
error: error instanceof VbaParseError
|
|
321
|
+
? error
|
|
322
|
+
: new VbaParseError('malformed', 'OLE container: unreadable mini stream'),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!mini.ok)
|
|
327
|
+
throw mini.error;
|
|
328
|
+
return { stream: mini.stream, table: mini.table };
|
|
329
|
+
};
|
|
330
|
+
const readMini = (start, byteCount) => {
|
|
331
|
+
const { stream: container, table } = miniContext();
|
|
332
|
+
const out = new Uint8Array(byteCount);
|
|
333
|
+
let written = 0;
|
|
334
|
+
let sector = start;
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
while (sector <= MAX_REGULAR_SECTOR && written < byteCount) {
|
|
337
|
+
if (seen.has(sector)) {
|
|
338
|
+
throw new VbaParseError('malformed', 'OLE container: mini sector chain loops');
|
|
339
|
+
}
|
|
340
|
+
seen.add(sector);
|
|
341
|
+
const base = sector * miniSectorSize;
|
|
342
|
+
if (base + miniSectorSize > container.length) {
|
|
343
|
+
throw new VbaParseError('malformed', 'OLE container: mini sector lies outside the mini stream');
|
|
344
|
+
}
|
|
345
|
+
const take = Math.min(miniSectorSize, byteCount - written);
|
|
346
|
+
out.set(container.subarray(base, base + take), written);
|
|
347
|
+
written += take;
|
|
348
|
+
const next = table[sector];
|
|
349
|
+
if (next === undefined)
|
|
350
|
+
break;
|
|
351
|
+
sector = next;
|
|
352
|
+
}
|
|
353
|
+
if (written < byteCount) {
|
|
354
|
+
throw new VbaParseError('malformed', 'OLE container: mini stream is shorter than its declared size');
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
entries,
|
|
360
|
+
readStream(path) {
|
|
361
|
+
const entry = byPath.get(path);
|
|
362
|
+
if (!entry) {
|
|
363
|
+
throw new VbaParseError('malformed', `OLE container: no such stream (${path})`);
|
|
364
|
+
}
|
|
365
|
+
if (entry.type !== 'stream') {
|
|
366
|
+
throw new VbaParseError('malformed', `OLE container: ${path} is a storage, not a stream`);
|
|
367
|
+
}
|
|
368
|
+
if (entry.size > CFB_LIMITS.maxStreamBytes) {
|
|
369
|
+
throw new VbaParseError('too-large', `OLE container: ${path} exceeds the stream size cap`);
|
|
370
|
+
}
|
|
371
|
+
if (entry.size === 0)
|
|
372
|
+
return new Uint8Array(0);
|
|
373
|
+
return entry.size < miniStreamCutoff
|
|
374
|
+
? readMini(entry.startSector, entry.size)
|
|
375
|
+
: readChain(entry.startSector, entry.size);
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error type the binary readers raise.
|
|
3
|
+
*
|
|
4
|
+
* The readers parse files that arrive from users — a `.docm` someone was
|
|
5
|
+
* emailed. Every structural surprise must land as a typed, catchable failure
|
|
6
|
+
* rather than a `RangeError` from a stray offset, so the public extraction
|
|
7
|
+
* API can convert it into a message and never throw at its caller.
|
|
8
|
+
*/
|
|
9
|
+
export type VbaParseErrorCode =
|
|
10
|
+
/** The bytes do not match the format, or a structure points outside the file. */
|
|
11
|
+
'malformed'
|
|
12
|
+
/** Structurally valid but beyond a safety cap — see the `*_LIMITS` objects. */
|
|
13
|
+
| 'too-large'
|
|
14
|
+
/** A format feature this reader deliberately does not implement. */
|
|
15
|
+
| 'unsupported';
|
|
16
|
+
export declare class VbaParseError extends Error {
|
|
17
|
+
readonly code: VbaParseErrorCode;
|
|
18
|
+
constructor(code: VbaParseErrorCode, message: string);
|
|
19
|
+
}
|
|
20
|
+
/** Whether an unknown thrown value is one of ours. */
|
|
21
|
+
export declare function isVbaParseError(error: unknown): error is VbaParseError;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error type the binary readers raise.
|
|
3
|
+
*
|
|
4
|
+
* The readers parse files that arrive from users — a `.docm` someone was
|
|
5
|
+
* emailed. Every structural surprise must land as a typed, catchable failure
|
|
6
|
+
* rather than a `RangeError` from a stray offset, so the public extraction
|
|
7
|
+
* API can convert it into a message and never throw at its caller.
|
|
8
|
+
*/
|
|
9
|
+
export class VbaParseError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'VbaParseError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Whether an unknown thrown value is one of ours. */
|
|
18
|
+
export function isVbaParseError(error) {
|
|
19
|
+
return error instanceof VbaParseError;
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const OVBA_LIMITS: {
|
|
2
|
+
/**
|
|
3
|
+
* Cap on the decompressed size of a single stream. Real VBA modules are
|
|
4
|
+
* kilobytes; megabytes already means something is wrong.
|
|
5
|
+
*/
|
|
6
|
+
readonly maxDecompressedBytes: 20000000;
|
|
7
|
+
};
|
|
8
|
+
export interface DecompressOvbaOptions {
|
|
9
|
+
/** Where the CompressedContainer starts. Module streams prefix it with a header. */
|
|
10
|
+
offset?: number;
|
|
11
|
+
/** Output cap in bytes. Default: `OVBA_LIMITS.maxDecompressedBytes`. */
|
|
12
|
+
maxOutput?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Decompresses one CompressedContainer.
|
|
16
|
+
*
|
|
17
|
+
* @throws {VbaParseError} on a malformed container or one whose output would
|
|
18
|
+
* exceed the cap. Never throws anything else.
|
|
19
|
+
*/
|
|
20
|
+
export declare function decompressOvba(source: Uint8Array, options?: DecompressOvbaOptions): Uint8Array;
|