@stll/docx-utils 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +90 -11
- package/dist/index.js +486 -9
- package/package.json +12 -7
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import JSZip from "jszip";
|
|
|
6
6
|
* Canonical source for namespace constants shared across
|
|
7
7
|
* the editor (folio) and the backend (api).
|
|
8
8
|
*/
|
|
9
|
-
declare const OOXML_NS: {
|
|
9
|
+
export declare const OOXML_NS: {
|
|
10
10
|
readonly w: "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
11
11
|
readonly a: "http://schemas.openxmlformats.org/drawingml/2006/main";
|
|
12
12
|
readonly r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
@@ -27,30 +27,109 @@ declare const OOXML_NS: {
|
|
|
27
27
|
};
|
|
28
28
|
type OoxmlPrefix = keyof typeof OOXML_NS;
|
|
29
29
|
//#endregion
|
|
30
|
+
//#region src/archive.d.ts
|
|
31
|
+
/** Maximum bytes any single archive entry may decompress to. */
|
|
32
|
+
export declare const DOCX_MAX_ENTRY_BYTES: number;
|
|
33
|
+
/** Maximum cumulative uncompressed bytes the archive may yield. */
|
|
34
|
+
export declare const DOCX_MAX_TOTAL_BYTES: number;
|
|
35
|
+
/**
|
|
36
|
+
* Maximum number of entries an archive may declare. Real DOCX/XLSX
|
|
37
|
+
* documents have well under 200; orders of magnitude above that
|
|
38
|
+
* indicate a hostile archive.
|
|
39
|
+
*/
|
|
40
|
+
export declare const DOCX_MAX_ENTRIES = 4096;
|
|
41
|
+
declare const DocxArchiveError_base: import("better-result").TaggedErrorClass<"DocxArchiveError">;
|
|
42
|
+
export declare class DocxArchiveError extends DocxArchiveError_base<{
|
|
43
|
+
message: string;
|
|
44
|
+
reason: "load-failed" | "too-many-entries" | "entry-too-large" | "total-too-large" | "invalid-entry-name";
|
|
45
|
+
cause?: unknown;
|
|
46
|
+
}> {}
|
|
47
|
+
type ArchiveOptions = {
|
|
48
|
+
maxEntryBytes?: number;
|
|
49
|
+
maxTotalBytes?: number;
|
|
50
|
+
maxEntries?: number;
|
|
51
|
+
};
|
|
52
|
+
type DocxArchive = {
|
|
53
|
+
/**
|
|
54
|
+
* The underlying JSZip instance. Safe for write operations
|
|
55
|
+
* (`zip.file(path, value)`, `zip.generateAsync(...)`); for reads,
|
|
56
|
+
* prefer the bounded helpers on this object.
|
|
57
|
+
*/
|
|
58
|
+
zip: JSZip;
|
|
59
|
+
/**
|
|
60
|
+
* Read an entry as UTF-8 text. Returns null if the entry is not in
|
|
61
|
+
* the archive. Throws `DocxArchiveError` if the read would cross
|
|
62
|
+
* the per-entry or cumulative archive cap.
|
|
63
|
+
*/
|
|
64
|
+
readEntryString: (path: string) => Promise<string | null>;
|
|
65
|
+
/** As {@link readEntryString} but returns the raw bytes. */
|
|
66
|
+
readEntryUint8: (path: string) => Promise<Uint8Array | null>;
|
|
67
|
+
};
|
|
68
|
+
/** Load and validate an archive, preserving the promise-based ZIP API. */
|
|
69
|
+
export declare const loadDocx: (buffer: ArrayBuffer | Uint8Array | Buffer, options?: ArchiveOptions) => Promise<JSZip>;
|
|
70
|
+
export declare const loadDocxArchive: (buffer: ArrayBuffer | Uint8Array | Buffer, options?: ArchiveOptions) => Promise<DocxArchive>;
|
|
71
|
+
//#endregion
|
|
30
72
|
//#region src/zip.d.ts
|
|
31
73
|
/** Standard DOCX compression options */
|
|
32
|
-
declare const DOCX_COMPRESSION: {
|
|
74
|
+
export declare const DOCX_COMPRESSION: {
|
|
33
75
|
type: "arraybuffer";
|
|
34
76
|
compression: "DEFLATE";
|
|
35
77
|
compressionOptions: {
|
|
36
78
|
level: number;
|
|
37
79
|
};
|
|
38
80
|
};
|
|
39
|
-
/** Load a DOCX file (ArrayBuffer) into a JSZip instance */
|
|
40
|
-
declare const loadDocx: (buffer: ArrayBuffer) => Promise<JSZip>;
|
|
41
81
|
/** Extract a text file from a ZIP */
|
|
42
|
-
declare const extractText: (zip: JSZip, path: string) => Promise<string | null>;
|
|
82
|
+
export declare const extractText: (zip: JSZip, path: string) => Promise<string | null>;
|
|
43
83
|
/** Extract a binary file from a ZIP */
|
|
44
|
-
declare const extractBinary: (zip: JSZip, path: string) => Promise<ArrayBuffer | null>;
|
|
84
|
+
export declare const extractBinary: (zip: JSZip, path: string) => Promise<ArrayBuffer | null>;
|
|
45
85
|
/** Repack a ZIP to ArrayBuffer with standard DOCX compression */
|
|
46
|
-
declare const repackZip: (zip: JSZip) => Promise<ArrayBuffer>;
|
|
86
|
+
export declare const repackZip: (zip: JSZip) => Promise<ArrayBuffer>;
|
|
47
87
|
//#endregion
|
|
48
88
|
//#region src/relationships.d.ts
|
|
49
89
|
/** Find the next available rId in a relationships XML string */
|
|
50
|
-
declare const findNextRId: (relsXml: string) => string;
|
|
90
|
+
export declare const findNextRId: (relsXml: string) => string;
|
|
51
91
|
/** Ensure a content type entry exists in [Content_Types].xml */
|
|
52
|
-
declare const ensureContentType: (contentTypesXml: string, partName: string, contentType: string) => string;
|
|
92
|
+
export declare const ensureContentType: (contentTypesXml: string, partName: string, contentType: string) => string;
|
|
53
93
|
/** Ensure a relationship entry exists */
|
|
54
|
-
declare const ensureRelationship: (relsXml: string, rId: string, type: string, target: string) => string;
|
|
94
|
+
export declare const ensureRelationship: (relsXml: string, rId: string, type: string, target: string) => string;
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/attached-template.d.ts
|
|
97
|
+
export declare const ATTACHED_TEMPLATE_SECURITY_RULE: "ooxml_attached_template";
|
|
98
|
+
export declare const ATTACHED_TEMPLATE_TARGET_KIND: {
|
|
99
|
+
readonly local: "local";
|
|
100
|
+
readonly network: "network";
|
|
101
|
+
readonly unknown: "unknown";
|
|
102
|
+
};
|
|
103
|
+
type AttachedTemplateTargetKind = (typeof ATTACHED_TEMPLATE_TARGET_KIND)[keyof typeof ATTACHED_TEMPLATE_TARGET_KIND];
|
|
104
|
+
type AttachedTemplateRelationshipFinding = {
|
|
105
|
+
rule: typeof ATTACHED_TEMPLATE_SECURITY_RULE;
|
|
106
|
+
targetKind: AttachedTemplateTargetKind;
|
|
107
|
+
};
|
|
108
|
+
type SanitizedAttachedTemplateRelationships = {
|
|
109
|
+
findings: readonly AttachedTemplateRelationshipFinding[];
|
|
110
|
+
removedRelationshipIds: readonly string[];
|
|
111
|
+
sourcePartPath: string | null;
|
|
112
|
+
xml: string;
|
|
113
|
+
};
|
|
114
|
+
type SanitizedAttachedTemplateSource = {
|
|
115
|
+
removed: number;
|
|
116
|
+
xml: string;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Classify where Word would resolve an attached template without retaining or
|
|
120
|
+
* returning the target itself. Template targets can contain usernames, host
|
|
121
|
+
* names, and internal paths, none of which belong in findings or telemetry.
|
|
122
|
+
*/
|
|
123
|
+
export declare const classifyAttachedTemplateTarget: (target: string | null) => AttachedTemplateTargetKind;
|
|
124
|
+
/** Resolve `word/_rels/settings.xml.rels` to its owning `word/settings.xml`. */
|
|
125
|
+
export declare const relationshipSourcePartPath: (relationshipsPartPath: string) => string | null;
|
|
126
|
+
export declare const isOpcRelationshipPartPath: (path: string) => boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Find and remove every attached-template relationship in one `.rels` part.
|
|
129
|
+
* Unchanged XML is returned byte-for-byte when no relationship is present.
|
|
130
|
+
*/
|
|
131
|
+
export declare const sanitizeAttachedTemplateRelationships: (xml: string, relationshipsPartPath: string) => SanitizedAttachedTemplateRelationships;
|
|
132
|
+
/** Remove WordprocessingML references to the relationships removed above. */
|
|
133
|
+
export declare const sanitizeAttachedTemplateSource: (xml: string, removedRelationshipIds: readonly string[]) => SanitizedAttachedTemplateSource;
|
|
55
134
|
//#endregion
|
|
56
|
-
export
|
|
135
|
+
export type { ArchiveOptions, AttachedTemplateRelationshipFinding, AttachedTemplateTargetKind, DocxArchive, OoxmlPrefix };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { Result, TaggedError } from "better-result";
|
|
1
2
|
import JSZip from "jszip";
|
|
3
|
+
import * as slimdom from "slimdom";
|
|
2
4
|
//#region src/namespaces.ts
|
|
3
5
|
/**
|
|
4
6
|
* Common OOXML namespace URIs.
|
|
@@ -26,6 +28,351 @@ const OOXML_NS = {
|
|
|
26
28
|
pr: "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
27
29
|
};
|
|
28
30
|
//#endregion
|
|
31
|
+
//#region src/archive.ts
|
|
32
|
+
/** Bounded metadata validation and sequential reads over OOXML archives. */
|
|
33
|
+
/** Maximum bytes any single archive entry may decompress to. */
|
|
34
|
+
const DOCX_MAX_ENTRY_BYTES = 134217728;
|
|
35
|
+
/** Maximum cumulative uncompressed bytes the archive may yield. */
|
|
36
|
+
const DOCX_MAX_TOTAL_BYTES = 268435456;
|
|
37
|
+
/**
|
|
38
|
+
* Maximum number of entries an archive may declare. Real DOCX/XLSX
|
|
39
|
+
* documents have well under 200; orders of magnitude above that
|
|
40
|
+
* indicate a hostile archive.
|
|
41
|
+
*/
|
|
42
|
+
const DOCX_MAX_ENTRIES = 4096;
|
|
43
|
+
var DocxArchiveError = class extends TaggedError("DocxArchiveError") {};
|
|
44
|
+
const isStreamingEntry = (entry) => "internalStream" in entry && typeof entry.internalStream === "function";
|
|
45
|
+
const readEntryBounded = async (entry, { maxEntryBytes, remainingBytes, onChunk }) => await new Promise((resolve, reject) => {
|
|
46
|
+
if (!isStreamingEntry(entry)) {
|
|
47
|
+
reject(new DocxArchiveError({
|
|
48
|
+
message: "Failed to read DOCX entry",
|
|
49
|
+
reason: "load-failed"
|
|
50
|
+
}));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const stream = entry.internalStream("uint8array");
|
|
54
|
+
let bytes = 0;
|
|
55
|
+
let settled = false;
|
|
56
|
+
stream.on("data", (chunk) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
bytes += chunk.byteLength;
|
|
59
|
+
if (bytes > maxEntryBytes || bytes > remainingBytes) {
|
|
60
|
+
settled = true;
|
|
61
|
+
stream.pause();
|
|
62
|
+
reject(new DocxArchiveError({
|
|
63
|
+
message: "DOCX entry exceeds its decompression budget",
|
|
64
|
+
reason: bytes > maxEntryBytes ? "entry-too-large" : "total-too-large"
|
|
65
|
+
}));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
onChunk?.(chunk);
|
|
69
|
+
});
|
|
70
|
+
stream.on("error", (cause) => {
|
|
71
|
+
if (!settled) {
|
|
72
|
+
settled = true;
|
|
73
|
+
reject(new DocxArchiveError({
|
|
74
|
+
message: "Failed to read DOCX entry",
|
|
75
|
+
reason: "load-failed",
|
|
76
|
+
cause
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
stream.on("end", () => {
|
|
81
|
+
if (!settled) {
|
|
82
|
+
settled = true;
|
|
83
|
+
resolve(bytes);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
stream.resume();
|
|
87
|
+
});
|
|
88
|
+
const invalid = () => Result.err(new DocxArchiveError({
|
|
89
|
+
message: "DOCX archive contains invalid entry metadata",
|
|
90
|
+
reason: "load-failed"
|
|
91
|
+
}));
|
|
92
|
+
const limitError = (reason) => Result.err(new DocxArchiveError({
|
|
93
|
+
message: "DOCX archive exceeds its budget",
|
|
94
|
+
reason
|
|
95
|
+
}));
|
|
96
|
+
const readZip64Directory = (view, locator) => {
|
|
97
|
+
if (view.getUint32(locator, true) !== 117853008 || view.getUint32(locator + 4, true) !== 0 || view.getUint32(locator + 16, true) !== 1) return invalid();
|
|
98
|
+
const recordOffset = view.getBigUint64(locator + 8, true);
|
|
99
|
+
if (recordOffset > BigInt(locator - 56)) return invalid();
|
|
100
|
+
const record = Number(recordOffset);
|
|
101
|
+
if (record < 0 || record + 56 > view.byteLength || view.getUint32(record, true) !== 101075792 || view.getBigUint64(record + 4, true) !== 44n || record + 56 !== locator) return invalid();
|
|
102
|
+
if (view.getUint32(record + 16, true) !== 0 || view.getUint32(record + 20, true) !== 0) return invalid();
|
|
103
|
+
const zipCount = view.getBigUint64(record + 32, true);
|
|
104
|
+
const zipDiskCount = view.getBigUint64(record + 24, true);
|
|
105
|
+
const zipSize = view.getBigUint64(record + 40, true);
|
|
106
|
+
const zipStart = view.getBigUint64(record + 48, true);
|
|
107
|
+
return Result.ok({
|
|
108
|
+
count: zipCount,
|
|
109
|
+
diskCount: zipDiskCount,
|
|
110
|
+
size: zipSize,
|
|
111
|
+
start: zipStart,
|
|
112
|
+
directoryEnd: record
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
const readDirectory = (view) => {
|
|
116
|
+
const bytes = Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
|
117
|
+
const end = bytes.lastIndexOf(new Uint8Array([
|
|
118
|
+
80,
|
|
119
|
+
75,
|
|
120
|
+
5,
|
|
121
|
+
6
|
|
122
|
+
]));
|
|
123
|
+
if (end < Math.max(0, view.byteLength - 65535 - 22) || end > view.byteLength - 22 || end + 22 + view.getUint16(end + 20, true) !== view.byteLength) return invalid();
|
|
124
|
+
if (view.getUint16(end + 4, true) !== 0 || view.getUint16(end + 6, true) !== 0) return invalid();
|
|
125
|
+
let count = BigInt(view.getUint16(end + 10, true));
|
|
126
|
+
let diskCount = BigInt(view.getUint16(end + 8, true));
|
|
127
|
+
let size = BigInt(view.getUint32(end + 12, true));
|
|
128
|
+
let start = BigInt(view.getUint32(end + 16, true));
|
|
129
|
+
let directoryEnd = end;
|
|
130
|
+
const locator = bytes.lastIndexOf(new Uint8Array([
|
|
131
|
+
80,
|
|
132
|
+
75,
|
|
133
|
+
6,
|
|
134
|
+
7
|
|
135
|
+
]));
|
|
136
|
+
const needsZip64 = count === 65535n || diskCount === 65535n || size === 4294967295n || start === 4294967295n;
|
|
137
|
+
if (!needsZip64 && locator !== -1) return invalid();
|
|
138
|
+
if (needsZip64) {
|
|
139
|
+
if (locator === -1 || locator !== end - 20) return invalid();
|
|
140
|
+
const zip64 = readZip64Directory(view, locator);
|
|
141
|
+
if (Result.isError(zip64)) return zip64;
|
|
142
|
+
const actual = zip64.value;
|
|
143
|
+
if ([
|
|
144
|
+
[
|
|
145
|
+
count,
|
|
146
|
+
actual.count,
|
|
147
|
+
65535n
|
|
148
|
+
],
|
|
149
|
+
[
|
|
150
|
+
diskCount,
|
|
151
|
+
actual.diskCount,
|
|
152
|
+
65535n
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
size,
|
|
156
|
+
actual.size,
|
|
157
|
+
4294967295n
|
|
158
|
+
],
|
|
159
|
+
[
|
|
160
|
+
start,
|
|
161
|
+
actual.start,
|
|
162
|
+
4294967295n
|
|
163
|
+
]
|
|
164
|
+
].some(([declared, value, sentinel]) => declared !== sentinel && declared !== value)) return invalid();
|
|
165
|
+
({count, diskCount, size, start, directoryEnd} = actual);
|
|
166
|
+
}
|
|
167
|
+
if (count !== diskCount || start + size !== BigInt(directoryEnd) || start > BigInt(directoryEnd)) return invalid();
|
|
168
|
+
return Result.ok({
|
|
169
|
+
count,
|
|
170
|
+
diskCount,
|
|
171
|
+
size,
|
|
172
|
+
start,
|
|
173
|
+
directoryEnd
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
const readEntrySizes = ({ view, offset, extraStart, extraEndOffset }) => {
|
|
177
|
+
let declared = BigInt(view.getUint32(offset + 24, true));
|
|
178
|
+
let compressed = BigInt(view.getUint32(offset + 20, true));
|
|
179
|
+
let localOffset = BigInt(view.getUint32(offset + 42, true));
|
|
180
|
+
let extra = extraStart;
|
|
181
|
+
let zip64Seen = false;
|
|
182
|
+
while (extra < extraEndOffset) {
|
|
183
|
+
if (extra + 4 > extraEndOffset) return invalid();
|
|
184
|
+
const tag = view.getUint16(extra, true);
|
|
185
|
+
const length = view.getUint16(extra + 2, true);
|
|
186
|
+
const payload = extra + 4;
|
|
187
|
+
if (payload + length > extraEndOffset) return invalid();
|
|
188
|
+
if (tag === 1) {
|
|
189
|
+
if (zip64Seen) return invalid();
|
|
190
|
+
zip64Seen = true;
|
|
191
|
+
if (length < (declared === 4294967295n ? 8 : 0) + (compressed === 4294967295n ? 8 : 0) + (localOffset === 4294967295n ? 8 : 0)) return invalid();
|
|
192
|
+
let field = payload;
|
|
193
|
+
if (declared === 4294967295n) {
|
|
194
|
+
declared = view.getBigUint64(field, true);
|
|
195
|
+
field += 8;
|
|
196
|
+
}
|
|
197
|
+
if (compressed === 4294967295n) {
|
|
198
|
+
compressed = view.getBigUint64(field, true);
|
|
199
|
+
field += 8;
|
|
200
|
+
}
|
|
201
|
+
if (localOffset === 4294967295n) localOffset = view.getBigUint64(field, true);
|
|
202
|
+
}
|
|
203
|
+
extra = payload + length;
|
|
204
|
+
}
|
|
205
|
+
if (!zip64Seen && (declared === 4294967295n || compressed === 4294967295n || localOffset === 4294967295n)) return invalid();
|
|
206
|
+
return Result.ok({
|
|
207
|
+
declared,
|
|
208
|
+
compressed,
|
|
209
|
+
localOffset
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const readEntryMetadata = ({ bytes, view, offset, start, directoryEnd }) => {
|
|
213
|
+
if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 33639248) return invalid();
|
|
214
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
215
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
216
|
+
const commentLength = view.getUint16(offset + 32, true);
|
|
217
|
+
const next = offset + 46 + nameLength + extraLength + commentLength;
|
|
218
|
+
if (next > directoryEnd || nameLength === 0 || view.getUint16(offset + 34, true) !== 0) return invalid();
|
|
219
|
+
const name = Buffer.from(bytes.subarray(offset + 46, offset + 46 + nameLength)).toString("hex");
|
|
220
|
+
const sizes = readEntrySizes({
|
|
221
|
+
view,
|
|
222
|
+
offset,
|
|
223
|
+
extraStart: offset + 46 + nameLength,
|
|
224
|
+
extraEndOffset: offset + 46 + nameLength + extraLength
|
|
225
|
+
});
|
|
226
|
+
if (Result.isError(sizes)) return sizes;
|
|
227
|
+
const { declared, compressed, localOffset } = sizes.value;
|
|
228
|
+
if (compressed > start || localOffset + 30n > start) return invalid();
|
|
229
|
+
const local = Number(localOffset);
|
|
230
|
+
if (local < 0 || local + 30 > bytes.length || view.getUint32(local, true) !== 67324752) return invalid();
|
|
231
|
+
const localNameLength = view.getUint16(local + 26, true);
|
|
232
|
+
if (localOffset + BigInt(30 + localNameLength + view.getUint16(local + 28, true)) + compressed > start || localNameLength !== nameLength || Buffer.from(bytes.subarray(local + 30, local + 30 + localNameLength)).toString("hex") !== name) return invalid();
|
|
233
|
+
const directory = bytes[offset + 46 + nameLength - 1] === 47 || Math.floor(view.getUint32(offset + 38, true) / 16) % 2 === 1;
|
|
234
|
+
return Result.ok({
|
|
235
|
+
next,
|
|
236
|
+
name,
|
|
237
|
+
declared,
|
|
238
|
+
directory
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
const preflightArchive = (bytes, { maxEntries, maxEntryBytes, maxTotalBytes }) => {
|
|
242
|
+
if ([
|
|
243
|
+
maxEntries,
|
|
244
|
+
maxEntryBytes,
|
|
245
|
+
maxTotalBytes
|
|
246
|
+
].some((limit) => !Number.isSafeInteger(limit) || limit < 0)) return invalid();
|
|
247
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
248
|
+
const metadata = readDirectory(view);
|
|
249
|
+
if (Result.isError(metadata)) return metadata;
|
|
250
|
+
const { count, start, directoryEnd } = metadata.value;
|
|
251
|
+
let offset = Number(start);
|
|
252
|
+
let records = 0;
|
|
253
|
+
let total = 0n;
|
|
254
|
+
const names = /* @__PURE__ */ new Set();
|
|
255
|
+
while (offset < directoryEnd) {
|
|
256
|
+
if (++records > maxEntries) return limitError("too-many-entries");
|
|
257
|
+
const entry = readEntryMetadata({
|
|
258
|
+
bytes,
|
|
259
|
+
view,
|
|
260
|
+
offset,
|
|
261
|
+
start,
|
|
262
|
+
directoryEnd
|
|
263
|
+
});
|
|
264
|
+
if (Result.isError(entry)) return entry;
|
|
265
|
+
const { name, declared, directory, next } = entry.value;
|
|
266
|
+
if (names.has(name)) return invalid();
|
|
267
|
+
names.add(name);
|
|
268
|
+
if (!directory) {
|
|
269
|
+
if (declared > BigInt(maxEntryBytes)) return limitError("entry-too-large");
|
|
270
|
+
total += declared;
|
|
271
|
+
if (total > BigInt(maxTotalBytes)) return limitError("total-too-large");
|
|
272
|
+
}
|
|
273
|
+
offset = next;
|
|
274
|
+
}
|
|
275
|
+
if (BigInt(records) !== count) return invalid();
|
|
276
|
+
return Result.ok();
|
|
277
|
+
};
|
|
278
|
+
const loadDocxResult = async (buffer, options = {}) => {
|
|
279
|
+
const maxEntryBytes = options.maxEntryBytes ?? 134217728;
|
|
280
|
+
const maxTotalBytes = options.maxTotalBytes ?? 268435456;
|
|
281
|
+
const maxEntries = options.maxEntries ?? 4096;
|
|
282
|
+
if (buffer.byteLength > 268435456) return Result.err(new DocxArchiveError({
|
|
283
|
+
message: "DOCX archive exceeds the input byte limit",
|
|
284
|
+
reason: "total-too-large"
|
|
285
|
+
}));
|
|
286
|
+
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
|
|
287
|
+
const preflight = preflightArchive(bytes, {
|
|
288
|
+
maxEntries,
|
|
289
|
+
maxEntryBytes,
|
|
290
|
+
maxTotalBytes
|
|
291
|
+
});
|
|
292
|
+
if (Result.isError(preflight)) return preflight;
|
|
293
|
+
const loaded = await Result.tryPromise({
|
|
294
|
+
try: async () => await JSZip.loadAsync(buffer),
|
|
295
|
+
catch: (cause) => new DocxArchiveError({
|
|
296
|
+
message: "Failed to parse DOCX archive",
|
|
297
|
+
reason: "load-failed",
|
|
298
|
+
cause
|
|
299
|
+
})
|
|
300
|
+
});
|
|
301
|
+
if (Result.isError(loaded)) return loaded;
|
|
302
|
+
const zip = loaded.value;
|
|
303
|
+
for (const entry of Object.values(zip.files)) {
|
|
304
|
+
if (entry.dir) continue;
|
|
305
|
+
const originalName = entry.unsafeOriginalName ?? entry.name;
|
|
306
|
+
if (originalName.startsWith("/") || /^[a-z]:/iu.test(originalName) || originalName.includes("\\") || originalName.split("/").includes("..")) return Result.err(new DocxArchiveError({
|
|
307
|
+
message: "DOCX archive contains an invalid entry name",
|
|
308
|
+
reason: "invalid-entry-name"
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
let validatedBytes = 0;
|
|
312
|
+
for (const entry of Object.values(zip.files)) {
|
|
313
|
+
if (entry.dir) continue;
|
|
314
|
+
const remainingBytes = maxTotalBytes - validatedBytes;
|
|
315
|
+
const read = await Result.tryPromise({
|
|
316
|
+
try: async () => await readEntryBounded(entry, {
|
|
317
|
+
maxEntryBytes,
|
|
318
|
+
remainingBytes
|
|
319
|
+
}),
|
|
320
|
+
catch: (cause) => cause instanceof DocxArchiveError ? cause : new DocxArchiveError({
|
|
321
|
+
message: "Failed to read DOCX entry",
|
|
322
|
+
reason: "load-failed",
|
|
323
|
+
cause
|
|
324
|
+
})
|
|
325
|
+
});
|
|
326
|
+
if (Result.isError(read)) return read;
|
|
327
|
+
validatedBytes += read.value;
|
|
328
|
+
}
|
|
329
|
+
return Result.ok(zip);
|
|
330
|
+
};
|
|
331
|
+
/** Load and validate an archive, preserving the promise-based ZIP API. */
|
|
332
|
+
const loadDocx = async (buffer, options = {}) => {
|
|
333
|
+
const result = await loadDocxResult(buffer, options);
|
|
334
|
+
if (Result.isError(result)) return await Promise.reject(result.error);
|
|
335
|
+
return result.value;
|
|
336
|
+
};
|
|
337
|
+
const loadDocxArchive = async (buffer, options = {}) => {
|
|
338
|
+
const maxEntryBytes = options.maxEntryBytes ?? 134217728;
|
|
339
|
+
const maxTotalBytes = options.maxTotalBytes ?? 268435456;
|
|
340
|
+
const zip = await loadDocx(buffer, options);
|
|
341
|
+
let totalRead = 0;
|
|
342
|
+
let readChain = Promise.resolve();
|
|
343
|
+
const readEntry = async (path) => {
|
|
344
|
+
const work = async () => {
|
|
345
|
+
const entry = zip.file(path);
|
|
346
|
+
if (!entry) return null;
|
|
347
|
+
const remaining = maxTotalBytes - totalRead;
|
|
348
|
+
const chunks = [];
|
|
349
|
+
const size = await readEntryBounded(entry, {
|
|
350
|
+
maxEntryBytes,
|
|
351
|
+
remainingBytes: remaining,
|
|
352
|
+
onChunk: (chunk) => {
|
|
353
|
+
chunks.push(chunk);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
totalRead += size;
|
|
357
|
+
return Buffer.concat(chunks, size);
|
|
358
|
+
};
|
|
359
|
+
const next = readChain.then(work, work);
|
|
360
|
+
readChain = next.then(() => void 0, () => void 0);
|
|
361
|
+
return await next;
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
zip,
|
|
365
|
+
async readEntryString(path) {
|
|
366
|
+
const buf = await readEntry(path);
|
|
367
|
+
return buf === null ? null : buf.toString("utf-8");
|
|
368
|
+
},
|
|
369
|
+
async readEntryUint8(path) {
|
|
370
|
+
const buf = await readEntry(path);
|
|
371
|
+
return buf === null ? null : new Uint8Array(buf);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
//#endregion
|
|
29
376
|
//#region src/zip.ts
|
|
30
377
|
/** Standard DOCX compression options */
|
|
31
378
|
const DOCX_COMPRESSION = {
|
|
@@ -33,8 +380,6 @@ const DOCX_COMPRESSION = {
|
|
|
33
380
|
compression: "DEFLATE",
|
|
34
381
|
compressionOptions: { level: 6 }
|
|
35
382
|
};
|
|
36
|
-
/** Load a DOCX file (ArrayBuffer) into a JSZip instance */
|
|
37
|
-
const loadDocx = async (buffer) => await JSZip.loadAsync(buffer);
|
|
38
383
|
/** Extract a text file from a ZIP */
|
|
39
384
|
const extractText = async (zip, path) => {
|
|
40
385
|
const file = zip.file(path);
|
|
@@ -57,15 +402,24 @@ const repackZip = async (zip) => await zip.generateAsync(DOCX_COMPRESSION);
|
|
|
57
402
|
* entities produced for the other characters.
|
|
58
403
|
*/
|
|
59
404
|
const escapeXmlAttribute = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
405
|
+
const relationshipIds = (relsXml) => {
|
|
406
|
+
const document = slimdom.parseXmlDocument(relsXml);
|
|
407
|
+
const ids = /* @__PURE__ */ new Set();
|
|
408
|
+
for (const element of document.getElementsByTagNameNS("*", "Relationship")) {
|
|
409
|
+
const id = element.getAttribute("Id");
|
|
410
|
+
if (id !== null) ids.add(id);
|
|
411
|
+
}
|
|
412
|
+
return ids;
|
|
413
|
+
};
|
|
60
414
|
/** Find the next available rId in a relationships XML string */
|
|
61
415
|
const findNextRId = (relsXml) => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const n =
|
|
416
|
+
let max = 0n;
|
|
417
|
+
for (const id of relationshipIds(relsXml)) {
|
|
418
|
+
if (!/^rId\d+$/u.test(id)) continue;
|
|
419
|
+
const n = BigInt(id.slice(3));
|
|
66
420
|
if (n > max) max = n;
|
|
67
421
|
}
|
|
68
|
-
return `rId${max +
|
|
422
|
+
return `rId${max + 1n}`;
|
|
69
423
|
};
|
|
70
424
|
/** Ensure a content type entry exists in [Content_Types].xml */
|
|
71
425
|
const ensureContentType = (contentTypesXml, partName, contentType) => {
|
|
@@ -77,9 +431,132 @@ const ensureContentType = (contentTypesXml, partName, contentType) => {
|
|
|
77
431
|
/** Ensure a relationship entry exists */
|
|
78
432
|
const ensureRelationship = (relsXml, rId, type, target) => {
|
|
79
433
|
const escapedRId = escapeXmlAttribute(rId);
|
|
80
|
-
if (relsXml.
|
|
434
|
+
if (relationshipIds(relsXml).has(rId)) return relsXml;
|
|
81
435
|
const rel = `<Relationship Id="${escapedRId}" Type="${escapeXmlAttribute(type)}" Target="${escapeXmlAttribute(target)}"/>`;
|
|
82
436
|
return relsXml.replace("</Relationships>", () => `${rel}\n</Relationships>`);
|
|
83
437
|
};
|
|
84
438
|
//#endregion
|
|
85
|
-
|
|
439
|
+
//#region src/attached-template.ts
|
|
440
|
+
const ATTACHED_TEMPLATE_SECURITY_RULE = "ooxml_attached_template";
|
|
441
|
+
const ATTACHED_TEMPLATE_TARGET_KIND = {
|
|
442
|
+
local: "local",
|
|
443
|
+
network: "network",
|
|
444
|
+
unknown: "unknown"
|
|
445
|
+
};
|
|
446
|
+
const PACKAGE_RELATIONSHIPS_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships";
|
|
447
|
+
const ATTACHED_TEMPLATE_RELATIONSHIP_TYPES = ["http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate", "http://purl.oclc.org/ooxml/officeDocument/relationships/attachedTemplate"];
|
|
448
|
+
const WORDPROCESSINGML_NAMESPACES = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
|
|
449
|
+
const OFFICE_RELATIONSHIPS_NAMESPACES = ["http://schemas.openxmlformats.org/officeDocument/2006/relationships", "http://purl.oclc.org/ooxml/officeDocument/relationships"];
|
|
450
|
+
const RELATIONSHIPS_SUFFIX = ".rels";
|
|
451
|
+
const RELATIONSHIPS_DIRECTORY = "_rels";
|
|
452
|
+
const ROOT_RELATIONSHIPS_PATH = "_rels/.rels";
|
|
453
|
+
const WINDOWS_DRIVE_PATH_RE = /^[a-z]:[\\/]/iu;
|
|
454
|
+
const UNC_PATH_RE = /^(?:\\\\|\/\/)[^\\/]/u;
|
|
455
|
+
const isAttachedTemplateRelationship = (element) => {
|
|
456
|
+
if (element.localName !== "Relationship" || element.namespaceURI !== PACKAGE_RELATIONSHIPS_NAMESPACE) return false;
|
|
457
|
+
const type = element.getAttribute("Type")?.trim();
|
|
458
|
+
return ATTACHED_TEMPLATE_RELATIONSHIP_TYPES.some((attachedTemplateType) => attachedTemplateType === type);
|
|
459
|
+
};
|
|
460
|
+
const descendantElements = (root) => {
|
|
461
|
+
const elements = [];
|
|
462
|
+
const pending = [...root.childNodes];
|
|
463
|
+
while (pending.length > 0) {
|
|
464
|
+
const node = pending.pop();
|
|
465
|
+
if (node === void 0) break;
|
|
466
|
+
if (node instanceof slimdom.Element) elements.push(node);
|
|
467
|
+
for (const child of node.childNodes) pending.push(child);
|
|
468
|
+
}
|
|
469
|
+
return elements;
|
|
470
|
+
};
|
|
471
|
+
/**
|
|
472
|
+
* Classify where Word would resolve an attached template without retaining or
|
|
473
|
+
* returning the target itself. Template targets can contain usernames, host
|
|
474
|
+
* names, and internal paths, none of which belong in findings or telemetry.
|
|
475
|
+
*/
|
|
476
|
+
const classifyAttachedTemplateTarget = (target) => {
|
|
477
|
+
const normalized = target?.trim();
|
|
478
|
+
if (!normalized || normalized.includes("\0")) return ATTACHED_TEMPLATE_TARGET_KIND.unknown;
|
|
479
|
+
if (UNC_PATH_RE.test(normalized)) return ATTACHED_TEMPLATE_TARGET_KIND.network;
|
|
480
|
+
if (WINDOWS_DRIVE_PATH_RE.test(normalized)) return ATTACHED_TEMPLATE_TARGET_KIND.local;
|
|
481
|
+
if (!URL.canParse(normalized)) return ATTACHED_TEMPLATE_TARGET_KIND.local;
|
|
482
|
+
const url = new URL(normalized);
|
|
483
|
+
if (url.protocol !== "file:") return ATTACHED_TEMPLATE_TARGET_KIND.network;
|
|
484
|
+
if (url.pathname.startsWith("//")) return ATTACHED_TEMPLATE_TARGET_KIND.network;
|
|
485
|
+
return url.hostname === "" || url.hostname.toLowerCase() === "localhost" ? ATTACHED_TEMPLATE_TARGET_KIND.local : ATTACHED_TEMPLATE_TARGET_KIND.network;
|
|
486
|
+
};
|
|
487
|
+
/** Resolve `word/_rels/settings.xml.rels` to its owning `word/settings.xml`. */
|
|
488
|
+
const relationshipSourcePartPath = (relationshipsPartPath) => {
|
|
489
|
+
if (relationshipsPartPath.includes("\\") || relationshipsPartPath.startsWith("/")) return null;
|
|
490
|
+
if (relationshipsPartPath === ROOT_RELATIONSHIPS_PATH) return null;
|
|
491
|
+
const segments = relationshipsPartPath.split("/");
|
|
492
|
+
if (segments.length < 2 || segments.some((segment) => segment === "" || segment === "." || segment === "..")) return null;
|
|
493
|
+
const relationshipDirectory = segments.at(-2);
|
|
494
|
+
const relationshipFileName = segments.at(-1);
|
|
495
|
+
if (relationshipDirectory !== RELATIONSHIPS_DIRECTORY || relationshipFileName === void 0 || !relationshipFileName.endsWith(RELATIONSHIPS_SUFFIX)) return null;
|
|
496
|
+
const sourceFileName = relationshipFileName.slice(0, -5);
|
|
497
|
+
if (sourceFileName === "") return null;
|
|
498
|
+
return [...segments.slice(0, -2), sourceFileName].join("/");
|
|
499
|
+
};
|
|
500
|
+
const isOpcRelationshipPartPath = (path) => path === ROOT_RELATIONSHIPS_PATH || relationshipSourcePartPath(path) !== null;
|
|
501
|
+
/**
|
|
502
|
+
* Find and remove every attached-template relationship in one `.rels` part.
|
|
503
|
+
* Unchanged XML is returned byte-for-byte when no relationship is present.
|
|
504
|
+
*/
|
|
505
|
+
const sanitizeAttachedTemplateRelationships = (xml, relationshipsPartPath) => {
|
|
506
|
+
const document = slimdom.parseXmlDocument(xml);
|
|
507
|
+
const relationships = descendantElements(document).filter(isAttachedTemplateRelationship);
|
|
508
|
+
if (relationships.length === 0) return {
|
|
509
|
+
findings: [],
|
|
510
|
+
removedRelationshipIds: [],
|
|
511
|
+
sourcePartPath: relationshipSourcePartPath(relationshipsPartPath),
|
|
512
|
+
xml
|
|
513
|
+
};
|
|
514
|
+
const findings = relationships.map((relationship) => ({
|
|
515
|
+
rule: ATTACHED_TEMPLATE_SECURITY_RULE,
|
|
516
|
+
targetKind: classifyAttachedTemplateTarget(relationship.getAttribute("Target"))
|
|
517
|
+
}));
|
|
518
|
+
const removedRelationshipIds = relationships.flatMap((relationship) => {
|
|
519
|
+
const id = relationship.getAttribute("Id")?.trim();
|
|
520
|
+
return id ? [id] : [];
|
|
521
|
+
});
|
|
522
|
+
for (const relationship of relationships) relationship.remove();
|
|
523
|
+
return {
|
|
524
|
+
findings,
|
|
525
|
+
removedRelationshipIds,
|
|
526
|
+
sourcePartPath: relationshipSourcePartPath(relationshipsPartPath),
|
|
527
|
+
xml: slimdom.serializeToWellFormedString(document)
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
const relationshipReferenceId = (element) => {
|
|
531
|
+
for (const namespace of OFFICE_RELATIONSHIPS_NAMESPACES) {
|
|
532
|
+
const id = element.getAttributeNS(namespace, "id");
|
|
533
|
+
if (id !== null) return id;
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
};
|
|
537
|
+
/** Remove WordprocessingML references to the relationships removed above. */
|
|
538
|
+
const sanitizeAttachedTemplateSource = (xml, removedRelationshipIds) => {
|
|
539
|
+
if (removedRelationshipIds.length === 0) return {
|
|
540
|
+
removed: 0,
|
|
541
|
+
xml
|
|
542
|
+
};
|
|
543
|
+
const document = slimdom.parseXmlDocument(xml);
|
|
544
|
+
const root = document.documentElement;
|
|
545
|
+
if (root?.localName !== "settings" || !WORDPROCESSINGML_NAMESPACES.some((namespace) => namespace === root.namespaceURI)) return {
|
|
546
|
+
removed: 0,
|
|
547
|
+
xml
|
|
548
|
+
};
|
|
549
|
+
const relationshipIds = new Set(removedRelationshipIds);
|
|
550
|
+
const references = descendantElements(document).filter((element) => element.localName === "attachedTemplate" && element.namespaceURI === root.namespaceURI && relationshipIds.has(relationshipReferenceId(element) ?? ""));
|
|
551
|
+
if (references.length === 0) return {
|
|
552
|
+
removed: 0,
|
|
553
|
+
xml
|
|
554
|
+
};
|
|
555
|
+
for (const reference of references) reference.remove();
|
|
556
|
+
return {
|
|
557
|
+
removed: references.length,
|
|
558
|
+
xml: slimdom.serializeToWellFormedString(document)
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
//#endregion
|
|
562
|
+
export { ATTACHED_TEMPLATE_SECURITY_RULE, ATTACHED_TEMPLATE_TARGET_KIND, DOCX_COMPRESSION, DOCX_MAX_ENTRIES, DOCX_MAX_ENTRY_BYTES, DOCX_MAX_TOTAL_BYTES, DocxArchiveError, OOXML_NS, classifyAttachedTemplateTarget, ensureContentType, ensureRelationship, extractBinary, extractText, findNextRId, isOpcRelationshipPartPath, loadDocx, loadDocxArchive, relationshipSourcePartPath, repackZip, sanitizeAttachedTemplateRelationships, sanitizeAttachedTemplateSource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/docx-utils",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Low-level helpers for reading and writing DOCX/OOXML zip packages: text and binary extraction, relationship and content-type management, and namespace constants.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"docx",
|
|
@@ -41,18 +41,23 @@
|
|
|
41
41
|
"pack:dry-run": "bun pm pack --dry-run",
|
|
42
42
|
"test": "bun test src",
|
|
43
43
|
"typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
|
|
44
|
-
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --
|
|
44
|
+
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --type-aware --type-check packages/docx-utils",
|
|
45
45
|
"lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/docx-utils",
|
|
46
|
-
"format": "oxfmt .",
|
|
47
|
-
"prepack": "bun run build"
|
|
46
|
+
"format": "bun ../../scripts/run-oxfmt.ts .",
|
|
47
|
+
"prepack": "bun run build",
|
|
48
|
+
"test:property": "bun test --preload @stll/property-testing/preload $(grep -rlF 'fc.assert' src --include='*.test.ts')"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
|
-
"
|
|
51
|
+
"better-result": "3.0.1",
|
|
52
|
+
"jszip": "3.10.2",
|
|
53
|
+
"slimdom": "^4.3.5"
|
|
51
54
|
},
|
|
52
55
|
"devDependencies": {
|
|
56
|
+
"@stll/property-testing": "0.0.0",
|
|
53
57
|
"@stll/typescript-config": "0.0.0",
|
|
54
|
-
"
|
|
55
|
-
"
|
|
58
|
+
"bun-types": "1.4.2",
|
|
59
|
+
"fast-check": "^4.10.1",
|
|
60
|
+
"tsdown": "0.23.0"
|
|
56
61
|
},
|
|
57
62
|
"main": "./dist/index.js",
|
|
58
63
|
"types": "./dist/index.d.ts"
|