url-sanitizer 5.2.2 → 5.3.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/package.json CHANGED
@@ -25,43 +25,36 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "dompurify": "^3.4.14",
29
- "jsdom": "^30.0.1"
28
+ "dompurify": "^3.4.16",
29
+ "jsdom": "^30.1.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@braintree/sanitize-url": "^7.1.2",
33
33
  "@types/jsdom": "^30.0.0",
34
- "@types/node": "^26.4.1",
34
+ "@types/node": "^26.6.3",
35
35
  "c8": "^12.0.0",
36
36
  "chai": "^6.2.2",
37
37
  "commander": "^15.0.0",
38
38
  "copyfiles-ng": "^2.4.7",
39
39
  "esbuild": "^0.28.2",
40
- "eslint": "^10.9.1",
40
+ "eslint": "^10.11.0",
41
41
  "eslint-config-prettier": "^10.1.8",
42
- "eslint-plugin-jsdoc": "^64.3.5",
42
+ "eslint-plugin-jsdoc": "^64.5.4",
43
43
  "eslint-plugin-prettier": "^5.5.6",
44
- "eslint-plugin-regexp": "^3.3.0",
45
- "eslint-plugin-unicorn": "^74.0.0",
46
- "fast-check": "^4.9.0",
44
+ "eslint-plugin-regexp": "^3.3.1",
45
+ "eslint-plugin-unicorn": "^76.0.0",
46
+ "fast-check": "^4.10.2",
47
47
  "globals": "^17.12.0",
48
48
  "http-server": "^14.1.1",
49
49
  "mitata": "^1.0.34",
50
- "mocha": "^12.0.0",
50
+ "mocha": "^12.0.2",
51
51
  "neostandard": "^0.14.0-next.1",
52
52
  "papaparse": "^5.7.0",
53
- "prettier": "^3.9.6",
53
+ "prettier": "^3.9.9",
54
54
  "sinon": "^22.1.0",
55
55
  "strict-url-sanitise": "^0.0.1",
56
56
  "typescript": "^7.0.2",
57
- "undici": "^8.10.1"
58
- },
59
- "overrides": {
60
- "mocha": {
61
- "diff": "^8.0.3",
62
- "js-yaml": "^4.3.1",
63
- "serialize-javascript": "^7.0.7"
64
- }
57
+ "undici": "^8.11.2"
65
58
  },
66
59
  "allowScripts": {
67
60
  "esbuild@0.28.2": true
@@ -87,5 +80,5 @@
87
80
  "engines": {
88
81
  "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
89
82
  },
90
- "version": "5.2.2"
83
+ "version": "5.3.0"
91
84
  }
package/src/index.js CHANGED
@@ -42,6 +42,7 @@ import { URLSanitizer } from './mjs/sanitize.js';
42
42
  /**
43
43
  * The sanitization options.
44
44
  * @typedef {object} SanitizeOptions
45
+ * @property {AbortSignal} [signal] - An AbortSignal to cancel the operation.
45
46
  * @property {string[]} [allow] - The array of schemes to allow.
46
47
  * @property {string[]} [deny] - The array of schemes to deny. Takes precedence over `allow`.
47
48
  * @property {string[]} [only] - The array of specific schemes to allow. Takes precedence over `allow` and `deny`.
@@ -1 +1 @@
1
- ["aaa","aaas","about","acap","acct","acd","acr","adiumxtra","adt","aet","afp","afs","agtp","aim","amss","android","appdata","apt","ar","ari","ark","ars","at","attachment","aw","barion","beshare","bitcoin","bitcoincash","bl","blob","bluetooth","bolo","brid","browserext","cabal","caip","calculator","callto","cap","cast","casts","chrome","chrome-extension","cid","cm","coap","coaps","com-eventbrite-attendee","content","content-type","crid","cstr","cttps","cvs","dab","dat","data","dav","dhttp","diaspora","dict","did","dilithium3","dis","dlna-playcontainer","dlna-playsingle","dnp","dns","dntp","doi","donau","dpp","drm","dtmi","dtn","dvb","dvx","dweb","ed2k","eid","elsi","embedded","ens","esim","ethereum","example","ez","facetime","feed","feedready","fido","file","finger","first-run-pen-experience","fish","fm","ftp","fuchsia-pkg","gcx","gdsi","geo","gg","git","gitoid","gizmoproject","go","gopher","graph","gtalk","h323","ham","hcap","hcp","hs20","http","https","hxxp","hxxps","hydrazone","hyper","i0","iax","ibi","ibi-","icap","icon","ilstring","im","imap","info","interaction","iotdisco","ipfs","ipn","ipns","ipp","ipps","irc","irc6","ircs","iris","iris.beep","iris.lwz","iris.xpc","iris.xpcs","isostore","itms","jabber","jar","jms","keyparc","lastfm","lbry","ldap","ldaps","leaptofrogans","lid","linkid","lorawan","lpa","lvlt","machineProvisioningProgressReporter","magnet","mailto","maps","market","matrix","mdoc","mdoc-openid4vp","message","microsoft.windows.camera","microsoft.windows.camera.multipicker","microsoft.windows.camera.picker","mid","mms","mongodb","moz","moz-extension","mqtt","mqtts","ms-access","ms-appinstaller","ms-browser-extension","ms-calculator","ms-drive-to","ms-enrollment","ms-excel","ms-eyecontrolspeech","ms-gamebarservices","ms-gamingoverlay","ms-getoffice","ms-help","ms-infopath","ms-inputapp","ms-launchremotedesktop","ms-lockscreencomponent-config","ms-media-stream-id","ms-meetnow","ms-mixedrealitycapture","ms-mobileplans","ms-newsandinterests","ms-officeapp","ms-people","ms-personacard","ms-powerpoint","ms-project","ms-publisher","ms-recall","ms-remotedesktop","ms-remotedesktop-launch","ms-restoretabcompanion","ms-screenclip","ms-screensketch","ms-search","ms-search-repair","ms-secondary-screen-controller","ms-secondary-screen-setup","ms-settings","ms-settings-airplanemode","ms-settings-bluetooth","ms-settings-camera","ms-settings-cellular","ms-settings-cloudstorage","ms-settings-connectabledevices","ms-settings-displays-topology","ms-settings-emailandaccounts","ms-settings-language","ms-settings-location","ms-settings-lock","ms-settings-nfctransactions","ms-settings-notifications","ms-settings-power","ms-settings-privacy","ms-settings-proximity","ms-settings-screenrotation","ms-settings-wifi","ms-settings-workplace","ms-spd","ms-stickers","ms-sttoverlay","ms-transit-to","ms-useractivityset","ms-uup","ms-virtualtouchpad","ms-visio","ms-walk-to","ms-whiteboard","ms-whiteboard-cmd","ms-widgetboard","ms-widgets","ms-word","msnim","msrp","msrps","mss","mt","mtqp","mtrust","mumble","mupdate","musik","mvn","mvrp","mvrps","news","nfs","ni","nih","nntp","nostr","notes","npamp","num","ocf","oid","onenote","onenote-cmd","opaquelocktoken","openid","openpgp4fpr","otpauth","palm","paparazzi","payto","pkcs11","pkg","platform","pop","pres","proxy","psyc","pttp","pwid","qb","query","quic-transport","redis","rediss","reload","res","resource","rmi","rsync","rtmfp","rtmp","rtsp","rtsps","rtspu","sarif","secondlife","secret-token","service","session","sftp","sgn","shc","shelter","sieve","simpleledger","simplex","sip","sips","skype","smb","smp","sms","smtp","snmp","soap.beep","soap.beeps","soldat","spacify","spiffe","spotify","ssb","ssh","sss","starknet","steam","stun","stuns","submit","svn","swh","swid","swidpath","tag","taler","teamspeak","teapot","teapots","tel","teliaeid","telnet","tftp","things","thismessage","tip","tn3270","tool","tttps","turn","turns","tv","udp","unreal","upn","urn","ust","ut2004","uuaid","uuid-in-package","v-event","vemmi","ventrilo","ves","view-source","vnc","vscode","vscode-insiders","vsls","w3","wasm","wasm-js","wcr","web3","webcal","wifi","ws","wss","wtai","wyciwyg","xcompute","xcon","xcon-userid","xfire","xftp","xmlrpc.beep","xmlrpc.beeps","xmpp","xrcp","xri","ymsgr","z39.50r","z39.50s","ztdnaid"]
1
+ ["aaa","aaas","about","acap","acct","acd","ace","acr","adiumxtra","adt","aet","afp","afs","agtp","aim","amss","android","appdata","apt","ar","ari","ark","ars","at","attachment","aw","barion","beshare","bitcoin","bitcoincash","bl","blob","bluetooth","bolo","brid","browserext","cabal","caip","calculator","callto","cap","cast","casts","chrome","chrome-extension","cid","cm","coap","coaps","com-eventbrite-attendee","content","content-type","crid","cstr","cttps","cvs","dab","dat","data","dav","dhttp","diaspora","dict","did","dilithium3","dis","dlna-playcontainer","dlna-playsingle","dnp","dns","dntp","doi","donau","dpp","drm","dtmi","dtn","dvb","dvx","dweb","ed2k","eid","elsi","embedded","ens","esim","ethereum","example","ez","facetime","feed","feedready","fido","file","finger","first-run-pen-experience","fish","fm","ftp","fuchsia-pkg","gcx","gdsi","geo","gg","git","gitoid","gizmoproject","go","gopher","graph","gtalk","h323","ham","hcap","hcp","hs20","http","https","hxxp","hxxps","hydrazone","hyper","i0","iax","ibi","ibi-","icap","icon","ilstring","im","imap","info","interaction","iotdisco","ipfs","ipn","ipns","ipp","ipps","irc","irc6","ircs","iris","iris.beep","iris.lwz","iris.xpc","iris.xpcs","isostore","itms","jabber","jar","jms","keyparc","lastfm","lbry","ldap","ldaps","leaptofrogans","lid","linkid","lorawan","lpa","lvlt","machineProvisioningProgressReporter","magnet","mailto","maps","market","matrix","mdoc","mdoc-openid4vp","message","microsoft.windows.camera","microsoft.windows.camera.multipicker","microsoft.windows.camera.picker","mid","mms","mongodb","moz","moz-extension","mqtt","mqtts","ms-access","ms-appinstaller","ms-browser-extension","ms-calculator","ms-drive-to","ms-enrollment","ms-excel","ms-eyecontrolspeech","ms-gamebarservices","ms-gamingoverlay","ms-getoffice","ms-help","ms-infopath","ms-inputapp","ms-launchremotedesktop","ms-lockscreencomponent-config","ms-media-stream-id","ms-meetnow","ms-mixedrealitycapture","ms-mobileplans","ms-newsandinterests","ms-officeapp","ms-people","ms-personacard","ms-powerpoint","ms-project","ms-publisher","ms-recall","ms-remotedesktop","ms-remotedesktop-launch","ms-restoretabcompanion","ms-screenclip","ms-screensketch","ms-search","ms-search-repair","ms-secondary-screen-controller","ms-secondary-screen-setup","ms-settings","ms-settings-airplanemode","ms-settings-bluetooth","ms-settings-camera","ms-settings-cellular","ms-settings-cloudstorage","ms-settings-connectabledevices","ms-settings-displays-topology","ms-settings-emailandaccounts","ms-settings-language","ms-settings-location","ms-settings-lock","ms-settings-nfctransactions","ms-settings-notifications","ms-settings-power","ms-settings-privacy","ms-settings-proximity","ms-settings-screenrotation","ms-settings-wifi","ms-settings-workplace","ms-spd","ms-stickers","ms-sttoverlay","ms-transit-to","ms-useractivityset","ms-uup","ms-virtualtouchpad","ms-visio","ms-walk-to","ms-whiteboard","ms-whiteboard-cmd","ms-widgetboard","ms-widgets","ms-word","msnim","msrp","msrps","mss","mt","mtqp","mtrust","mumble","mupdate","musik","mvn","mvrp","mvrps","news","nfs","ni","nih","nntp","nostr","notes","npamp","num","ocf","oid","onenote","onenote-cmd","opaquelocktoken","openid","openpgp4fpr","otpauth","palm","paparazzi","payto","pkcs11","pkg","platform","pop","pres","proxy","psyc","pttp","pwid","qb","query","quic-transport","redis","rediss","reload","res","resource","rmi","rsync","rtmfp","rtmp","rtsp","rtsps","rtspu","rttp","sarif","secondlife","secret-token","seki","service","session","sftp","sgn","shc","shelter","sieve","simpleledger","simplex","sip","sips","skype","smb","smp","sms","smtp","snmp","soap.beep","soap.beeps","soldat","spacify","spiffe","spotify","ssb","ssh","sss","starknet","steam","stun","stuns","submit","svn","swh","swid","swidpath","tag","taler","teamspeak","teapot","teapots","tel","teliaeid","telnet","tftp","things","thismessage","tii","tip","tn3270","tool","tttps","turn","turns","tv","udp","unreal","upn","urn","ust","ut2004","uuaid","uuid-in-package","v-event","vemmi","ventrilo","ves","view-source","vnc","vscode","vscode-insiders","vsls","w3","wasm","wasm-js","wcr","web3","webcal","wifi","ws","wss","wtai","wyciwyg","xcompute","xcon","xcon-userid","xfire","xftp","xmlrpc.beep","xmlrpc.beeps","xmpp","xrcp","xri","ymsgr","z39.50r","z39.50s","ztdnaid"]
@@ -9,13 +9,14 @@ import { URISchemes } from './scheme.js';
9
9
  import {
10
10
  encodeBufferToBase64,
11
11
  extractDataURLComponents,
12
+ fetchBlobAsArrayBuffer,
12
13
  normalizeURL,
13
14
  parseURL,
14
15
  truncateURL
15
16
  } from './utility.js';
16
17
 
17
18
  /* constants */
18
- import { DECI, DEFAULT_OPTS, MAX_BLOB_SIZE } from './constant.js';
19
+ import { DEFAULT_OPTS } from './constant.js';
19
20
  import { REG_SCHEME, REG_SCRIPT, REG_SCRIPT_OR_BLOB } from './regexp.js';
20
21
  /* @type {string[]} */
21
22
  const URL_PROPS = Object.freeze([
@@ -44,98 +45,6 @@ const URL_PROPS = Object.freeze([
44
45
  * @typedef {SanitizeOptions & { schemes?: Set<string> }} InternalSanitizeOptions
45
46
  */
46
47
 
47
- /* blob handlers */
48
- /**
49
- * Reads a stream in chunks and generates an ArrayBuffer.
50
- * @private
51
- * @param {Response} response - The Response instance.
52
- * @param {number} maxSize - The maximum allowed size in bytes.
53
- * @returns {Promise<ArrayBuffer>} A promise resolving to the generated ArrayBuffer.
54
- */
55
- const readStreamInChunksAsArrayBuffer = async (response, maxSize) => {
56
- const reader = response.body.getReader();
57
- const chunks = [];
58
- try {
59
- let accumulatedSize = 0;
60
- while (accumulatedSize <= maxSize) {
61
- const { done, value } = await reader.read();
62
- if (done) {
63
- break;
64
- }
65
- accumulatedSize += value.byteLength;
66
- if (accumulatedSize > maxSize) {
67
- await reader.cancel('Size limit exceeded');
68
- throw new DOMException(
69
- `Payload (${accumulatedSize} bytes) exceeds max (${maxSize} bytes).`,
70
- 'NotReadableError'
71
- );
72
- }
73
- chunks.push(value);
74
- }
75
- const combined = new Uint8Array(accumulatedSize);
76
- let offset = 0;
77
- for (const chunk of chunks) {
78
- combined.set(chunk, offset);
79
- offset += chunk.byteLength;
80
- }
81
- return combined.buffer;
82
- } finally {
83
- reader.releaseLock();
84
- }
85
- };
86
-
87
- /**
88
- * Fetches a Blob URL and extracts its ArrayBuffer and MIME type.
89
- * @private
90
- * @param {string} url - The Blob URL to fetch.
91
- * @param {number} [maxBlobSize] - The maximum allowed Blob size in bytes.
92
- * @returns {Promise<{ buffer: ArrayBuffer, mimeType: string }>} A promise resolving to the buffer and MIME type.
93
- */
94
- const fetchBlobAsArrayBuffer = async (url, maxBlobSize) => {
95
- let maxSize = MAX_BLOB_SIZE;
96
- if (Number.isInteger(maxBlobSize) && maxBlobSize > 0) {
97
- maxSize = maxBlobSize;
98
- }
99
- const response = await fetch(url);
100
- if (!response.ok) {
101
- const truncatedURL = truncateURL(url);
102
- let msg = `Failed to fetch ${truncatedURL}`;
103
- if (Number.isInteger(response.status)) {
104
- if (response.statusText) {
105
- msg += `: ${response.status} ${response.statusText}`;
106
- } else {
107
- msg += `: ${response.status}`;
108
- }
109
- }
110
- throw new Error(msg);
111
- }
112
- const contentLength = response.headers.get('content-length');
113
- if (contentLength) {
114
- const parsedLength = Number.parseInt(contentLength, DECI);
115
- if (Number.isInteger(parsedLength) && parsedLength > maxSize) {
116
- throw new DOMException(
117
- `Payload (${parsedLength} bytes) exceeds max (${maxSize} bytes).`,
118
- 'NotReadableError'
119
- );
120
- }
121
- }
122
- const mimeType = response.headers.get('content-type') || '';
123
- let buffer;
124
- if (response.body) {
125
- buffer = await readStreamInChunksAsArrayBuffer(response, maxSize);
126
- } else {
127
- const blob = await response.blob();
128
- if (blob.size > maxSize) {
129
- throw new DOMException(
130
- `Payload (${blob.size} bytes) exceeds max (${maxSize} bytes).`,
131
- 'NotReadableError'
132
- );
133
- }
134
- buffer = await blob.arrayBuffer();
135
- }
136
- return { buffer, mimeType };
137
- };
138
-
139
48
  /**
140
49
  * URL sanitizer
141
50
  */
@@ -284,6 +193,40 @@ export class URLSanitizer extends URISchemes {
284
193
  return inspectedURL;
285
194
  }
286
195
 
196
+ /**
197
+ * Inspects a blob URL by reading its buffer and converting it.
198
+ * @private
199
+ * @param {string} url - The blob URL.
200
+ * @param {InternalSanitizeOptions} options - Sanitization options.
201
+ * @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL result.
202
+ */
203
+ async #inspectBlob(url, options) {
204
+ try {
205
+ const { buffer, mimeType } = await fetchBlobAsArrayBuffer(
206
+ url,
207
+ options.maxBlobSize,
208
+ options.signal
209
+ );
210
+ const base64Data = encodeBufferToBase64(buffer);
211
+ const dataURL = `data:${mimeType ? `${mimeType};base64` : 'base64'},${base64Data}`;
212
+ const result = this.#inspect(dataURL, options);
213
+ result.input = url;
214
+ return result;
215
+ } catch (e) {
216
+ if (e.name === 'AbortError' || options.signal?.aborted) {
217
+ throw e;
218
+ }
219
+ if (options.debug) {
220
+ logDebug(`Failed to inspect blob URL: ${truncateURL(url)}`, e);
221
+ }
222
+ return { input: url, valid: false, reason: e.message };
223
+ } finally {
224
+ if (options.revokeObjectURL) {
225
+ URL.revokeObjectURL(url);
226
+ }
227
+ }
228
+ }
229
+
287
230
  /**
288
231
  * Gets the list of registered URI schemes.
289
232
  * @returns {string[]} An array of registered schemes.
@@ -372,11 +315,15 @@ export class URLSanitizer extends URISchemes {
372
315
  try {
373
316
  const fetchResult = await fetchBlobAsArrayBuffer(
374
317
  url,
375
- options.maxBlobSize
318
+ options.maxBlobSize,
319
+ options.signal
376
320
  );
377
321
  fetchedBuffer = fetchResult.buffer;
378
322
  fetchedMimeType = fetchResult.mimeType;
379
323
  } catch (e) {
324
+ if (e.name === 'AbortError' || options.signal?.aborted) {
325
+ throw e;
326
+ }
380
327
  if (options.debug) {
381
328
  logDebug(
382
329
  `Failed to fetch and convert blob URL: ${truncateURL(url)}`,
@@ -438,29 +385,16 @@ export class URLSanitizer extends URISchemes {
438
385
 
439
386
  /**
440
387
  * Sanitizes the given URL and returns its parsed components.
441
- * NOTE: Blob URLs are not revoked after inspection.
442
388
  * @param {string} url - The URL string to inspect.
389
+ * @param {SanitizeOptions} [opt] - The sanitization options.
443
390
  * @returns {Promise<InspectedURLResult>} A promise resolving to the inspected URL result.
444
391
  */
445
- async inspectURL(url) {
446
- if (isString(url)) {
447
- const parsedURL = parseURL(url, null, true);
448
- if (parsedURL?.protocol === 'blob:') {
449
- try {
450
- const { buffer, mimeType } = await fetchBlobAsArrayBuffer(
451
- parsedURL.href
452
- );
453
- const base64Data = encodeBufferToBase64(buffer);
454
- const dataURL = `data:${mimeType ? `${mimeType};base64` : 'base64'},${base64Data}`;
455
- const inspectedURLResult = this.#inspect(dataURL);
456
- inspectedURLResult.input = url;
457
- return inspectedURLResult;
458
- } catch (e) {
459
- return { input: url, valid: false, reason: e.message };
460
- }
461
- }
392
+ async inspectURL(url, opt) {
393
+ const { options, scheme } = this.#normalizeOptions(url, opt);
394
+ if (scheme === 'blob') {
395
+ return this.#inspectBlob(url, options);
462
396
  }
463
- return this.#inspect(url);
397
+ return this.#inspect(url, options);
464
398
  }
465
399
 
466
400
  /**
@@ -11,6 +11,7 @@ import {
11
11
  CHUNK_SIZE,
12
12
  DECI,
13
13
  HEX,
14
+ MAX_BLOB_SIZE,
14
15
  MAX_NEST,
15
16
  MAX_URL_LENGTH,
16
17
  TRUNCATE_LENGTH
@@ -347,3 +348,116 @@ export const encodeBufferToBase64 = buffer => {
347
348
  }
348
349
  return btoa(chunks.join(''));
349
350
  };
351
+
352
+ /**
353
+ * Reads a stream in chunks and generates an ArrayBuffer.
354
+ * @param {Response} response - The Response instance.
355
+ * @param {number} maxSize - The maximum allowed size in bytes.
356
+ * @param {AbortSignal} [signal] - The abort signal.
357
+ * @returns {Promise<ArrayBuffer>} A promise resolving to the generated ArrayBuffer.
358
+ */
359
+ export const readStreamInChunksAsArrayBuffer = async (
360
+ response,
361
+ maxSize,
362
+ signal
363
+ ) => {
364
+ const reader = response.body.getReader();
365
+ const chunks = [];
366
+ const abortMsg = 'Operation aborted';
367
+ if (signal?.aborted) {
368
+ await reader.cancel(abortMsg);
369
+ reader.releaseLock();
370
+ throw signal.reason;
371
+ }
372
+ const onAbort = () => {
373
+ reader.cancel(abortMsg).catch(() => {});
374
+ };
375
+ signal?.addEventListener('abort', onAbort, { once: true });
376
+ try {
377
+ let accumulatedSize = 0;
378
+ while (accumulatedSize <= maxSize) {
379
+ if (signal?.aborted) {
380
+ await reader.cancel(abortMsg);
381
+ throw signal.reason;
382
+ }
383
+ const { done, value } = await reader.read();
384
+ if (signal?.aborted) {
385
+ throw signal.reason;
386
+ }
387
+ if (done) {
388
+ break;
389
+ }
390
+ accumulatedSize += value.byteLength;
391
+ if (accumulatedSize > maxSize) {
392
+ await reader.cancel('Size limit exceeded');
393
+ throw new DOMException(
394
+ `Payload (${accumulatedSize} bytes) exceeds max (${maxSize} bytes).`,
395
+ 'NotReadableError'
396
+ );
397
+ }
398
+ chunks.push(value);
399
+ }
400
+ const combined = new Uint8Array(accumulatedSize);
401
+ let offset = 0;
402
+ for (const chunk of chunks) {
403
+ combined.set(chunk, offset);
404
+ offset += chunk.byteLength;
405
+ }
406
+ return combined.buffer;
407
+ } finally {
408
+ signal?.removeEventListener('abort', onAbort);
409
+ reader.releaseLock();
410
+ }
411
+ };
412
+
413
+ /**
414
+ * Fetches a Blob URL and extracts its ArrayBuffer and MIME type.
415
+ * @param {string} url - The Blob URL to fetch.
416
+ * @param {number} [maxBlobSize] - The maximum allowed Blob size in bytes.
417
+ * @param {AbortSignal} [signal] - The abort signal.
418
+ * @returns {Promise<{ buffer: ArrayBuffer, mimeType: string }>} A promise resolving to the buffer and MIME type.
419
+ */
420
+ export const fetchBlobAsArrayBuffer = async (url, maxBlobSize, signal) => {
421
+ let maxSize = MAX_BLOB_SIZE;
422
+ if (Number.isInteger(maxBlobSize) && maxBlobSize > 0) {
423
+ maxSize = maxBlobSize;
424
+ }
425
+ const response = await fetch(url, { signal });
426
+ if (!response.ok) {
427
+ const truncatedURL = truncateURL(url);
428
+ let msg = `Failed to fetch ${truncatedURL}`;
429
+ if (Number.isInteger(response.status)) {
430
+ if (response.statusText) {
431
+ msg += `: ${response.status} ${response.statusText}`;
432
+ } else {
433
+ msg += `: ${response.status}`;
434
+ }
435
+ }
436
+ throw new Error(msg);
437
+ }
438
+ const contentLength = response.headers.get('content-length');
439
+ if (contentLength) {
440
+ const parsedLength = Number.parseInt(contentLength, DECI);
441
+ if (Number.isInteger(parsedLength) && parsedLength > maxSize) {
442
+ throw new DOMException(
443
+ `Payload (${parsedLength} bytes) exceeds max (${maxSize} bytes).`,
444
+ 'NotReadableError'
445
+ );
446
+ }
447
+ }
448
+ const mimeType = response.headers.get('content-type') || '';
449
+ let buffer;
450
+ if (response.body) {
451
+ buffer = await readStreamInChunksAsArrayBuffer(response, maxSize, signal);
452
+ } else {
453
+ const blob = await response.blob();
454
+ if (blob.size > maxSize) {
455
+ throw new DOMException(
456
+ `Payload (${blob.size} bytes) exceeds max (${maxSize} bytes).`,
457
+ 'NotReadableError'
458
+ );
459
+ }
460
+ buffer = await blob.arrayBuffer();
461
+ }
462
+ return { buffer, mimeType };
463
+ };
package/types/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export type InspectedURLResult = {
30
30
  hash?: string;
31
31
  };
32
32
  export type SanitizeOptions = {
33
+ signal?: AbortSignal;
33
34
  allow?: string[];
34
35
  deny?: string[];
35
36
  only?: string[];
@@ -11,6 +11,7 @@ export declare class URLSanitizer extends URISchemes {
11
11
  private #isWellFormedScheme;
12
12
  private #normalizeOptions;
13
13
  private #inspect;
14
+ private #inspectBlob;
14
15
  get(): string[];
15
16
  has(scheme: string): boolean;
16
17
  add(scheme: string): string[];
@@ -18,6 +19,6 @@ export declare class URLSanitizer extends URISchemes {
18
19
  reset(): void;
19
20
  sanitizeURL(url: string, opt?: SanitizeOptions): Promise<string | null>;
20
21
  sanitizeURLSync(url: string, opt?: SanitizeOptions): string | null;
21
- inspectURL(url: string): Promise<InspectedURLResult>;
22
+ inspectURL(url: string, opt?: SanitizeOptions): Promise<InspectedURLResult>;
22
23
  isValidURI(uri: string): boolean;
23
24
  }
@@ -16,3 +16,8 @@ export declare const parseURLEncodedNumCharRef: (str: string, nest?: number) =>
16
16
  export declare const extractDataURLComponents: (pathname: string, search?: string, hash?: string) => DataURLComponents;
17
17
  export declare const parseBase64: (data: string) => string;
18
18
  export declare const encodeBufferToBase64: (buffer: ArrayBuffer) => string;
19
+ export declare const readStreamInChunksAsArrayBuffer: (response: Response, maxSize: number, signal?: AbortSignal) => Promise<ArrayBuffer>;
20
+ export declare const fetchBlobAsArrayBuffer: (url: string, maxBlobSize?: number, signal?: AbortSignal) => Promise<{
21
+ buffer: ArrayBuffer;
22
+ mimeType: string;
23
+ }>;