estimateDataURLDecodedBytes.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. /**
  2. * Estimate data: URL byte lengths *without* allocating large buffers.
  3. * - Fetch percent-decodes a base64 body before decoding it.
  4. * - Node's Buffer.from(body, 'base64') sizes its backing allocation from the
  5. * raw body, including ignored characters and content after padding.
  6. * - Non-base64 data is percent-decoded and then encoded as UTF-8.
  7. */
  8. const isHexDigit = (charCode) =>
  9. (charCode >= 48 && charCode <= 57) ||
  10. (charCode >= 65 && charCode <= 70) ||
  11. (charCode >= 97 && charCode <= 102);
  12. const isPercentEncodedByte = (str, i, len) =>
  13. i + 2 < len && isHexDigit(str.charCodeAt(i + 1)) && isHexDigit(str.charCodeAt(i + 2));
  14. const hexValue = (charCode) => (charCode <= 57 ? charCode - 48 : (charCode & 0xdf) - 55);
  15. const isBase64Char = (charCode) =>
  16. (charCode >= 65 && charCode <= 90) || // A-Z
  17. (charCode >= 97 && charCode <= 122) || // a-z
  18. (charCode >= 48 && charCode <= 57) || // 0-9
  19. charCode === 43 || // +
  20. charCode === 47 || // /
  21. charCode === 45 || // - (base64url)
  22. charCode === 95; // _ (base64url)
  23. const isBase64Whitespace = (charCode) =>
  24. charCode === 9 || charCode === 10 || charCode === 12 || charCode === 13 || charCode === 32;
  25. const base64Bytes = (significant) => {
  26. const groups = Math.floor(significant / 4);
  27. const remainder = significant % 4;
  28. return groups * 3 + (remainder === 2 ? 1 : remainder === 3 ? 2 : 0);
  29. };
  30. // Buffer.byteLength(body, 'base64') uses the raw string length as an allocation
  31. // upper bound even when Buffer.from later ignores characters or stops at '='.
  32. const estimateBase64BufferAllocation = (body) => {
  33. const len = body.length;
  34. let padding = 0;
  35. if (len > 0 && body.charCodeAt(len - 1) === 61 /* '=' */) {
  36. padding++;
  37. if (len > 1 && body.charCodeAt(len - 2) === 61 /* '=' */) {
  38. padding++;
  39. }
  40. }
  41. return Math.floor(((len - padding) * 3) / 4);
  42. };
  43. const estimatePercentDecodedBase64Bytes = (body) => {
  44. const len = body.length;
  45. let significant = 0;
  46. let padding = 0;
  47. let invalid = false;
  48. for (let i = 0; i < len; i++) {
  49. let code = body.charCodeAt(i);
  50. if (code === 37 /* '%' */ && isPercentEncodedByte(body, i, len)) {
  51. code = hexValue(body.charCodeAt(i + 1)) * 16 + hexValue(body.charCodeAt(i + 2));
  52. i += 2;
  53. }
  54. if (isBase64Whitespace(code)) {
  55. continue;
  56. }
  57. if (code === 61 /* '=' */) {
  58. padding++;
  59. continue;
  60. }
  61. if (!isBase64Char(code) || padding > 0) {
  62. invalid = true;
  63. continue;
  64. }
  65. significant++;
  66. }
  67. // Fetch rejects malformed forgiving-base64 input. Returning the raw-size
  68. // allocation bound keeps that invalid input from becoming a pre-check bypass.
  69. if (
  70. invalid ||
  71. padding > 2 ||
  72. (padding > 0 && (significant + padding) % 4 !== 0) ||
  73. significant % 4 === 1
  74. ) {
  75. return estimateBase64BufferAllocation(body);
  76. }
  77. return base64Bytes(significant);
  78. };
  79. const estimateDataURLBytes = (url, estimateBase64) => {
  80. if (!url || typeof url !== 'string') return 0;
  81. if (!url.startsWith('data:')) return 0;
  82. const comma = url.indexOf(',');
  83. if (comma < 0) return 0;
  84. const meta = url.slice(5, comma);
  85. const body = url.slice(comma + 1);
  86. const isBase64 = /;base64/i.test(meta);
  87. if (isBase64) {
  88. return estimateBase64(body);
  89. }
  90. // Compute UTF-8 byte length directly from UTF-16 code units without allocating
  91. // a byte buffer (TextEncoder.encode would defeat the DoS guard on large bodies).
  92. // Valid %XX triplets count as one decoded byte; this matches the bytes that
  93. // decodeURIComponent(body) would produce before Buffer re-encodes the string.
  94. let bytes = 0;
  95. for (let i = 0, len = body.length; i < len; i++) {
  96. const c = body.charCodeAt(i);
  97. if (c === 37 /* '%' */ && isPercentEncodedByte(body, i, len)) {
  98. bytes += 1;
  99. i += 2;
  100. } else if (c < 0x80) {
  101. bytes += 1;
  102. } else if (c < 0x800) {
  103. bytes += 2;
  104. } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < len) {
  105. const next = body.charCodeAt(i + 1);
  106. if (next >= 0xdc00 && next <= 0xdfff) {
  107. bytes += 4;
  108. i++;
  109. } else {
  110. bytes += 3;
  111. }
  112. } else {
  113. bytes += 3;
  114. }
  115. }
  116. return bytes;
  117. };
  118. /**
  119. * Estimate the percent-decoded payload size used by Fetch data: URLs.
  120. *
  121. * @param {string} url
  122. * @returns {number}
  123. */
  124. export default function estimateDataURLDecodedBytes(url) {
  125. // Fetch removes URL fragments before processing a data: URL.
  126. const fragmentIndex = typeof url === 'string' ? url.indexOf('#') : -1;
  127. return estimateDataURLBytes(
  128. fragmentIndex === -1 ? url : url.slice(0, fragmentIndex),
  129. estimatePercentDecodedBase64Bytes
  130. );
  131. }
  132. /**
  133. * Estimate the Buffer backing allocation used by Node's raw base64 decoder.
  134. *
  135. * @param {string} url
  136. * @returns {number}
  137. */
  138. export function estimateDataURLBufferAllocation(url) {
  139. return estimateDataURLBytes(url, estimateBase64BufferAllocation);
  140. }