index.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import {
  2. checkChunkResponse,
  3. estimateSizeOfMultipartResponse,
  4. getMultipartBoundary,
  5. resolveMultiPartResponses,
  6. } from '../pdf-preview/util/pdf-caching'
  7. import getMeta from '../../utils/meta'
  8. import OError from '@overleaf/o-error'
  9. import { captureException } from '../../infrastructure/error-reporter'
  10. import { postJSON } from '../../infrastructure/fetch-json'
  11. import isSplitTestEnabled from '../../utils/isSplitTestEnabled'
  12. const INITIAL_DELAY_MS = 30_000
  13. const DELAY_BETWEEN_PROBES_MS = 1_000
  14. const TIMEOUT_MS = 30_000
  15. const FULL_SIZE = 739
  16. const FULL_HASH =
  17. 'b7d25591c18da373709d3d88ddf5eeab0b5089359e580f051314fd8935df0b73'
  18. const CHUNKS = [
  19. {
  20. start: 0,
  21. end: 21,
  22. hash: 'd2ad9cbf1bc669646c0dfc43fa3167d30ab75077bb46bc9e3624b9e7e168abc2',
  23. },
  24. {
  25. start: 21,
  26. end: 42,
  27. hash: 'd6d110ec0f3f4e27a4050bc2be9c5552cc9092f86b74fec75072c2c9e8483454',
  28. },
  29. {
  30. start: 42,
  31. end: 64,
  32. hash: '8278914487a3a099c9af5aa22ed836d6587ca0beb7bf9a059fb0409667b3eb3d',
  33. },
  34. ]
  35. async function sleep(ms: number) {
  36. return new Promise(resolve => setTimeout(resolve, ms))
  37. }
  38. function pickZone() {
  39. const x = Math.random()
  40. switch (true) {
  41. case x > 0.66:
  42. return 'b'
  43. case x > 0.33:
  44. return 'c'
  45. default:
  46. return 'd'
  47. }
  48. }
  49. function arrayLikeToHex(a: Uint8Array) {
  50. return Array.from(a)
  51. .map(i => i.toString(16).padStart(2, '0'))
  52. .join('')
  53. }
  54. async function hashBody(body: ArrayBuffer) {
  55. const digest = await crypto.subtle.digest('SHA-256', body)
  56. return arrayLikeToHex(new Uint8Array(digest))
  57. }
  58. async function checkHash(
  59. res: Response,
  60. data: ArrayBuffer,
  61. expectedHash: string
  62. ) {
  63. const actualHash = await hashBody(data)
  64. if (actualHash !== expectedHash) {
  65. throw new OError('content hash mismatch', {
  66. actualHash,
  67. expectedHash,
  68. headers: Object.fromEntries(res.headers.entries()),
  69. })
  70. }
  71. }
  72. function randomHex(bytes: number) {
  73. const buf = new Uint8Array(bytes)
  74. crypto.getRandomValues(buf)
  75. return arrayLikeToHex(buf)
  76. }
  77. function genBuildId() {
  78. const date = Date.now().toString(16)
  79. const random = randomHex(8)
  80. return `${date}-${random}`
  81. }
  82. async function singleCheck(
  83. url: string,
  84. init: RequestInit,
  85. estimatedSize: number,
  86. expectedHash: string,
  87. chunks?: Array<any>
  88. ) {
  89. const ac = new AbortController()
  90. setTimeout(() => ac.abort(), TIMEOUT_MS)
  91. init.signal = ac.signal
  92. init.cache = 'no-store'
  93. const res = await fetch(url, init)
  94. checkChunkResponse(res, estimatedSize, init)
  95. const body = await res.arrayBuffer()
  96. if (chunks) {
  97. const boundary = getMultipartBoundary(res, chunks)
  98. const parts = resolveMultiPartResponses({
  99. file: { size: FULL_SIZE },
  100. chunks,
  101. data: new Uint8Array(body),
  102. boundary,
  103. metrics: {},
  104. })
  105. for (const part of parts) {
  106. await checkHash(res, part.data, part.chunk.hash)
  107. }
  108. } else {
  109. await checkHash(res, body, expectedHash)
  110. }
  111. }
  112. export async function checkUserContentDomainAccess() {
  113. // Note: The ids are zero prefixed. No actual user/project uses these ids.
  114. // mongo-id 000000000000000000000000 -> 1970-01-01T00:00:00.000Z
  115. // mongo-id 000000010000000000000000 -> 1970-01-01T00:00:01.000Z
  116. // mongo-id 100000000000000000000000 -> 1978-07-04T21:24:16.000Z
  117. // This allows us to distinguish between check-traffic and regular output
  118. // traffic.
  119. const projectId = `0${randomHex(12).slice(1)}`
  120. const userId = `0${randomHex(12).slice(1)}`
  121. const buildId = genBuildId()
  122. const zone = pickZone()
  123. const urls = [
  124. `${getMeta(
  125. 'ol-compilesUserContentDomain'
  126. )}/zone/${zone}/project/${projectId}/user/${userId}/build/${buildId}/output/output.pdf`,
  127. `${getMeta(
  128. 'ol-compilesUserContentDomain'
  129. )}/zone/${zone}/project/${projectId}/build/${buildId}/output/output.pdf`,
  130. ]
  131. const cases = []
  132. for (const url of urls) {
  133. // full download
  134. cases.push({
  135. url,
  136. init: {},
  137. estimatedSize: FULL_SIZE,
  138. hash: FULL_HASH,
  139. })
  140. // range request
  141. const chunk = CHUNKS[0]
  142. cases.push({
  143. url,
  144. init: {
  145. headers: {
  146. Range: `bytes=${chunk.start}-${chunk.end - 1}`,
  147. },
  148. },
  149. estimatedSize: chunk.end - chunk.start,
  150. hash: chunk.hash,
  151. })
  152. // multipart request
  153. cases.push({
  154. url,
  155. init: {
  156. headers: {
  157. Range: `bytes=${CHUNKS.map(c => `${c.start}-${c.end - 1}`).join(
  158. ','
  159. )}`,
  160. },
  161. },
  162. estimatedSize: estimateSizeOfMultipartResponse(CHUNKS),
  163. hash: chunk.hash,
  164. chunks: CHUNKS,
  165. })
  166. }
  167. let failed = 0
  168. for (const { url, init, estimatedSize, hash, chunks } of cases) {
  169. await sleep(DELAY_BETWEEN_PROBES_MS)
  170. try {
  171. await singleCheck(url, init, estimatedSize, hash, chunks)
  172. } catch (err: any) {
  173. failed++
  174. OError.tag(err, 'user-content-domain-access-check failed', {
  175. url,
  176. init,
  177. })
  178. if (isSplitTestEnabled('report-user-content-domain-access-check-error')) {
  179. captureException(err)
  180. } else {
  181. console.error(OError.getFullStack(err), OError.getFullInfo(err))
  182. }
  183. }
  184. }
  185. try {
  186. await postJSON('/record-user-content-domain-access-check-result', {
  187. body: { failed, succeeded: cases.length - failed },
  188. })
  189. } catch (e) {}
  190. }
  191. export function scheduleUserContentDomainAccessCheck() {
  192. sleep(INITIAL_DELAY_MS).then(() => {
  193. checkUserContentDomainAccess().catch(err => {
  194. captureException(err)
  195. })
  196. })
  197. }