files.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { existsSync } from 'node:fs'
  2. import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
  3. import path from 'node:path'
  4. import { expect, type Page, test } from '@playwright/test'
  5. import {
  6. deleteServiceAttachments,
  7. insertServiceAttachment,
  8. ownerOrganizationId,
  9. serviceAttachmentsNamed,
  10. } from '../../support/db'
  11. import { pdfContent, TINY_PNG } from '../../support/pdf'
  12. import {
  13. addPart,
  14. newWorkOrder,
  15. saveWorkOrder,
  16. seededVehicleUrl,
  17. shareLink,
  18. } from '../../support/work-order'
  19. /**
  20. * A stored file URL is data somebody typed at some point.
  21. *
  22. * Every file a workshop uploads is kept as a URL on a record, and that URL is
  23. * later turned into a path on disk and read, copied or unlinked. The audit
  24. * found it joined onto the upload folder with no containment check, so a
  25. * record carrying `../../.env` read the server's own files into a PDF. Three
  26. * layers stand in the way now: the file routes refuse a path with a dot pair,
  27. * the actions refuse to store a URL that is not one of this workshop's own
  28. * uploads, and the path resolver refuses to leave the folder for whatever is
  29. * stored already.
  30. *
  31. * And an SVG is a document with scripts in it: uploaded as a logo it ran for
  32. * every visitor on the app's origin. Uploads are decoded and re-encoded now,
  33. * and a stored SVG is offered as a download inside a sandbox.
  34. */
  35. test.describe.configure({ mode: 'serial' })
  36. const stamp = Date.now()
  37. /** A real picture of some size, so drawing it is visible in the PDF's bytes. */
  38. const LOGO = path.join('public', 'torqvoice_app_logo.png')
  39. let organizationId = ''
  40. let jobUrl = ''
  41. let jobId = ''
  42. /** The share token of the job's invoice, for the public file route. */
  43. let shareToken = ''
  44. async function workshopCopy(page: Page) {
  45. const response = await page.request.get(`/api/protected/services/${jobId}/pdf`, {
  46. timeout: 60_000,
  47. })
  48. expect(response.status(), 'the workshop can always get its invoice').toBe(200)
  49. const body = await response.body()
  50. return { bytes: body.length, ...(await pdfContent(body)) }
  51. }
  52. /** Where the app writes uploads, when the suite shares a disk with it. */
  53. function uploadDir(...segments: string[]): string {
  54. return path.join('data', 'uploads', organizationId, ...segments)
  55. }
  56. test.beforeAll(async ({ browser }) => {
  57. organizationId = await ownerOrganizationId()
  58. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  59. const vehicleUrl = await seededVehicleUrl(page)
  60. jobUrl = await newWorkOrder(page, vehicleUrl, `E2E file safety ${stamp}`)
  61. jobId = jobUrl.split('/').pop() ?? ''
  62. await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 1, unitPrice: 100 })
  63. await saveWorkOrder(page)
  64. shareToken = new URL(await shareLink(page)).pathname.split('/').pop() ?? ''
  65. await page.close()
  66. })
  67. test.describe('the file routes', () => {
  68. test('refuse a path that climbs out of the upload folder', async ({ page }) => {
  69. // Encoded, because a browser would fold a literal `..` away before the
  70. // request left it; a client that wants the traversal does not.
  71. const climbs = [
  72. '..%2F..%2F..%2F..%2Fpackage.json',
  73. '%2E%2E%2F%2E%2E%2Fpackage.json',
  74. '..%5C..%5Cpackage.json',
  75. ]
  76. for (const climb of climbs) {
  77. for (const url of [
  78. `/api/protected/files/${organizationId}/services/${climb}`,
  79. `/api/public/files/${shareToken}/services/${climb}`,
  80. ]) {
  81. const response = await page.request.get(url)
  82. expect([400, 404], `${url} is refused`).toContain(response.status())
  83. expect(await response.text(), 'and nothing of the file came back').not.toContain(
  84. '"scripts"'
  85. )
  86. }
  87. }
  88. })
  89. })
  90. test.describe('a file URL on a record', () => {
  91. test('is not stored unless it is one of this workshop’s own uploads', async ({ browser }) => {
  92. // The client uploads the file, then hands the answer's URL to the action
  93. // that puts it on the job. Here the answer is rewritten on its way back,
  94. // which is what a client that wanted to would do.
  95. const forged = [
  96. { url: `/api/protected/files/${organizationId}/services/../../../../package.json` },
  97. { url: '/api/files/../../.env' },
  98. { url: `/api/protected/files/not-this-workshop/services/${stamp}.txt` },
  99. { url: `https://example.com/${stamp}.txt` },
  100. ]
  101. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  102. await page.goto(jobUrl)
  103. for (const [i, { url }] of forged.entries()) {
  104. const name = `e2e-forged-${i}-${stamp}.txt`
  105. // The refusal is a toast, and the last file's is still up when the next
  106. // one starts. Left there it answers this file's check at once, the route
  107. // below is taken away while its upload is still in flight, and the
  108. // handler then fails with "Route is already handled" on a slow runner.
  109. await expect(page.getByText(/not an upload of this workshop/i)).toHaveCount(0, {
  110. timeout: 30_000,
  111. })
  112. await page.route('**/api/protected/upload/service-files', async (route) => {
  113. const response = await route.fetch()
  114. const json = (await response.json()) as Record<string, unknown>
  115. await route.fulfill({ response, json: { ...json, url } })
  116. })
  117. const files = page.getByTestId('files-media')
  118. const documents = files.getByRole('tab', { name: /^Documents/ })
  119. await expect(async () => {
  120. await documents.click()
  121. await expect(documents).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 })
  122. }).toPass({ timeout: 30_000 })
  123. await files
  124. .locator('input[type="file"]')
  125. .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('forged') })
  126. await expect(
  127. page.getByText(/not an upload of this workshop/i).first(),
  128. `${url} is refused, and the page says why`
  129. ).toBeVisible({ timeout: 30_000 })
  130. await page.unroute('**/api/protected/upload/service-files')
  131. expect(await serviceAttachmentsNamed(name), `${url} was not stored`).toBe(0)
  132. }
  133. await page.close()
  134. })
  135. test('that climbs out of the folder is listed on the invoice, never read', async ({ page }) => {
  136. // Rows written straight to the database, as records from before the
  137. // schema guard would be. The oracle is the printed document: a picture
  138. // that is drawn gets a "Service Images" page of its own, one that is
  139. // only listed adds its name to the invoice and nothing else. (Byte size
  140. // would not do: a flat-colour logo deflates to a kilobyte once the
  141. // renderer re-encodes it.) The picture goes up through the upload route
  142. // rather than the images tab, which re-encodes what it is given.
  143. const uploaded = await page.request.post('/api/protected/upload/service-files', {
  144. multipart: {
  145. file: {
  146. name: `e2e-real-${stamp}.png`,
  147. mimeType: 'image/png',
  148. buffer: await readFile(LOGO),
  149. },
  150. },
  151. })
  152. expect(uploaded.status()).toBe(200)
  153. const realFile = ((await uploaded.json()) as { url: string }).url.split('/').pop()
  154. const bare = await workshopCopy(page)
  155. const withRow = async (fileName: string, fileUrl: string) => {
  156. const id = await insertServiceAttachment({
  157. serviceRecordId: jobId,
  158. fileName,
  159. fileUrl,
  160. fileType: 'image/png',
  161. })
  162. try {
  163. return await workshopCopy(page)
  164. } finally {
  165. await deleteServiceAttachments([id])
  166. }
  167. }
  168. // The control: the uploaded file, reached by climbing out of the folder
  169. // and straight back in. It stays inside, so it is drawn, which proves the
  170. // climb below starts where the app's upload folder is.
  171. const control = await withRow(
  172. `e2e-control-${stamp}.png`,
  173. `/api/protected/files/${organizationId}/services/../../../../data/uploads/${organizationId}/services/${realFile}`
  174. )
  175. expect(control.pages, 'a path that stays inside the folder is drawn').toBe(bare.pages + 1)
  176. expect(control.flat).toContain('Service Images')
  177. expect(control.flat).toContain(`e2e-control-${stamp}.png`)
  178. // The escape: the same climb, ending in a real picture outside the
  179. // folder. Listed by name, and not one pixel of it in the document.
  180. const escaped = await withRow(
  181. `e2e-escape-${stamp}.png`,
  182. `/api/protected/files/${organizationId}/services/../../../../${LOGO}`
  183. )
  184. expect(escaped.flat, 'the invoice still names the file').toContain(`e2e-escape-${stamp}.png`)
  185. expect(escaped.pages, 'but did not draw it').toBe(bare.pages)
  186. expect(escaped.flat).not.toContain('Service Images')
  187. })
  188. })
  189. test.describe('an SVG', () => {
  190. test('is not accepted as a logo or a portal background, whatever it is called', async ({
  191. page,
  192. }) => {
  193. const svg = Buffer.from(
  194. '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
  195. )
  196. for (const route of ['logo', 'portal-background']) {
  197. const url = `/api/protected/upload/${route}`
  198. const declared = await page.request.post(url, {
  199. multipart: { file: { name: 'logo.svg', mimeType: 'image/svg+xml', buffer: svg } },
  200. })
  201. expect(declared.status(), `${route}: an SVG declared as one`).toBe(400)
  202. // Declared as a PNG, which is what a client that wanted it stored would
  203. // say. The bytes are decoded before anything is written, and these do
  204. // not decode.
  205. const disguised = await page.request.post(url, {
  206. multipart: { file: { name: 'logo.png', mimeType: 'image/png', buffer: svg } },
  207. })
  208. expect(disguised.status(), `${route}: an SVG declared as a PNG`).toBe(400)
  209. }
  210. })
  211. test('already on disk is a download inside a sandbox, never a page on the app’s origin', async ({
  212. page,
  213. }) => {
  214. // Written straight into the upload folder, as a file from before the
  215. // upload routes re-encoded would be. Only possible when the suite shares
  216. // a disk with the server, which it does locally and on CI.
  217. test.skip(!existsSync(uploadDir()), 'the suite does not share a disk with the app server')
  218. const name = `e2e-${stamp}.svg`
  219. await mkdir(uploadDir('logos'), { recursive: true })
  220. await writeFile(
  221. uploadDir('logos', name),
  222. '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
  223. )
  224. try {
  225. for (const url of [
  226. `/api/protected/files/${organizationId}/logos/${name}`,
  227. `/api/public/files/${shareToken}/logos/${name}`,
  228. ]) {
  229. const response = await page.request.get(url)
  230. expect(response.status(), `${url} is served`).toBe(200)
  231. const headers = response.headers()
  232. expect(headers['content-disposition'], `${url} is a download`).toBe('attachment')
  233. expect(headers['content-security-policy'], `${url} is sandboxed`).toContain('sandbox')
  234. expect(headers['x-content-type-options']).toBe('nosniff')
  235. }
  236. } finally {
  237. await unlink(uploadDir('logos', name))
  238. }
  239. })
  240. test('is refused where a picture is expected, and a picture is stored as what it is', async ({
  241. page,
  242. }) => {
  243. // The stored file's extension is what the bytes turned out to be, not
  244. // what the name said; a PNG called .svg is a .png on disk, and is served
  245. // as one. The portal background is the route without a side effect on
  246. // the workshop's current logo.
  247. const response = await page.request.post('/api/protected/upload/portal-background', {
  248. multipart: { file: { name: 'picture.svg', mimeType: 'image/png', buffer: TINY_PNG } },
  249. })
  250. expect(response.status()).toBe(200)
  251. const { url } = (await response.json()) as { url: string }
  252. expect(url).toMatch(
  253. new RegExp(`^/api/protected/files/${organizationId}/portal/[0-9a-f-]+\\.png$`)
  254. )
  255. const served = await page.request.get(url)
  256. expect(served.status()).toBe(200)
  257. expect(served.headers()['content-type']).toBe('image/png')
  258. // Tidied away when the suite shares a disk with the server; otherwise the
  259. // stray background stays where every other spec's uploads do.
  260. if (existsSync(uploadDir('portal'))) {
  261. await unlink(uploadDir('portal', url.split('/').pop() ?? ''))
  262. }
  263. })
  264. })