files.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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. await page.route('**/api/protected/upload/service-files', async (route) => {
  106. const response = await route.fetch()
  107. const json = (await response.json()) as Record<string, unknown>
  108. await route.fulfill({ response, json: { ...json, url } })
  109. })
  110. await expect(async () => {
  111. await page.getByRole('button', { name: /^Documents/ }).click()
  112. await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
  113. }).toPass({ timeout: 30_000 })
  114. await page
  115. .locator('input[type="file"][accept=".pdf,.csv,.txt"]')
  116. .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('forged') })
  117. await expect(
  118. page.getByText(/not an upload of this workshop/i).first(),
  119. `${url} is refused, and the page says why`
  120. ).toBeVisible({ timeout: 30_000 })
  121. await page.unroute('**/api/protected/upload/service-files')
  122. expect(await serviceAttachmentsNamed(name), `${url} was not stored`).toBe(0)
  123. }
  124. await page.close()
  125. })
  126. test('that climbs out of the folder is listed on the invoice, never read', async ({ page }) => {
  127. // Rows written straight to the database, as records from before the
  128. // schema guard would be. The oracle is the printed document: a picture
  129. // that is drawn gets a "Service Images" page of its own, one that is
  130. // only listed adds its name to the invoice and nothing else. (Byte size
  131. // would not do: a flat-colour logo deflates to a kilobyte once the
  132. // renderer re-encodes it.) The picture goes up through the upload route
  133. // rather than the images tab, which re-encodes what it is given.
  134. const uploaded = await page.request.post('/api/protected/upload/service-files', {
  135. multipart: {
  136. file: {
  137. name: `e2e-real-${stamp}.png`,
  138. mimeType: 'image/png',
  139. buffer: await readFile(LOGO),
  140. },
  141. },
  142. })
  143. expect(uploaded.status()).toBe(200)
  144. const realFile = ((await uploaded.json()) as { url: string }).url.split('/').pop()
  145. const bare = await workshopCopy(page)
  146. const withRow = async (fileName: string, fileUrl: string) => {
  147. const id = await insertServiceAttachment({
  148. serviceRecordId: jobId,
  149. fileName,
  150. fileUrl,
  151. fileType: 'image/png',
  152. })
  153. try {
  154. return await workshopCopy(page)
  155. } finally {
  156. await deleteServiceAttachments([id])
  157. }
  158. }
  159. // The control: the uploaded file, reached by climbing out of the folder
  160. // and straight back in. It stays inside, so it is drawn, which proves the
  161. // climb below starts where the app's upload folder is.
  162. const control = await withRow(
  163. `e2e-control-${stamp}.png`,
  164. `/api/protected/files/${organizationId}/services/../../../../data/uploads/${organizationId}/services/${realFile}`
  165. )
  166. expect(control.pages, 'a path that stays inside the folder is drawn').toBe(bare.pages + 1)
  167. expect(control.flat).toContain('Service Images')
  168. expect(control.flat).toContain(`e2e-control-${stamp}.png`)
  169. // The escape: the same climb, ending in a real picture outside the
  170. // folder. Listed by name, and not one pixel of it in the document.
  171. const escaped = await withRow(
  172. `e2e-escape-${stamp}.png`,
  173. `/api/protected/files/${organizationId}/services/../../../../${LOGO}`
  174. )
  175. expect(escaped.flat, 'the invoice still names the file').toContain(`e2e-escape-${stamp}.png`)
  176. expect(escaped.pages, 'but did not draw it').toBe(bare.pages)
  177. expect(escaped.flat).not.toContain('Service Images')
  178. })
  179. })
  180. test.describe('an SVG', () => {
  181. test('is not accepted as a logo or a portal background, whatever it is called', async ({
  182. page,
  183. }) => {
  184. const svg = Buffer.from(
  185. '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
  186. )
  187. for (const route of ['logo', 'portal-background']) {
  188. const url = `/api/protected/upload/${route}`
  189. const declared = await page.request.post(url, {
  190. multipart: { file: { name: 'logo.svg', mimeType: 'image/svg+xml', buffer: svg } },
  191. })
  192. expect(declared.status(), `${route}: an SVG declared as one`).toBe(400)
  193. // Declared as a PNG, which is what a client that wanted it stored would
  194. // say. The bytes are decoded before anything is written, and these do
  195. // not decode.
  196. const disguised = await page.request.post(url, {
  197. multipart: { file: { name: 'logo.png', mimeType: 'image/png', buffer: svg } },
  198. })
  199. expect(disguised.status(), `${route}: an SVG declared as a PNG`).toBe(400)
  200. }
  201. })
  202. test('already on disk is a download inside a sandbox, never a page on the app’s origin', async ({
  203. page,
  204. }) => {
  205. // Written straight into the upload folder, as a file from before the
  206. // upload routes re-encoded would be. Only possible when the suite shares
  207. // a disk with the server, which it does locally and on CI.
  208. test.skip(!existsSync(uploadDir()), 'the suite does not share a disk with the app server')
  209. const name = `e2e-${stamp}.svg`
  210. await mkdir(uploadDir('logos'), { recursive: true })
  211. await writeFile(
  212. uploadDir('logos', name),
  213. '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
  214. )
  215. try {
  216. for (const url of [
  217. `/api/protected/files/${organizationId}/logos/${name}`,
  218. `/api/public/files/${shareToken}/logos/${name}`,
  219. ]) {
  220. const response = await page.request.get(url)
  221. expect(response.status(), `${url} is served`).toBe(200)
  222. const headers = response.headers()
  223. expect(headers['content-disposition'], `${url} is a download`).toBe('attachment')
  224. expect(headers['content-security-policy'], `${url} is sandboxed`).toContain('sandbox')
  225. expect(headers['x-content-type-options']).toBe('nosniff')
  226. }
  227. } finally {
  228. await unlink(uploadDir('logos', name))
  229. }
  230. })
  231. test('is refused where a picture is expected, and a picture is stored as what it is', async ({
  232. page,
  233. }) => {
  234. // The stored file's extension is what the bytes turned out to be, not
  235. // what the name said; a PNG called .svg is a .png on disk, and is served
  236. // as one. The portal background is the route without a side effect on
  237. // the workshop's current logo.
  238. const response = await page.request.post('/api/protected/upload/portal-background', {
  239. multipart: { file: { name: 'picture.svg', mimeType: 'image/png', buffer: TINY_PNG } },
  240. })
  241. expect(response.status()).toBe(200)
  242. const { url } = (await response.json()) as { url: string }
  243. expect(url).toMatch(
  244. new RegExp(`^/api/protected/files/${organizationId}/portal/[0-9a-f-]+\\.png$`)
  245. )
  246. const served = await page.request.get(url)
  247. expect(served.status()).toBe(200)
  248. expect(served.headers()['content-type']).toBe('image/png')
  249. // Tidied away when the suite shares a disk with the server; otherwise the
  250. // stray background stays where every other spec's uploads do.
  251. if (existsSync(uploadDir('portal'))) {
  252. await unlink(uploadDir('portal', url.split('/').pop() ?? ''))
  253. }
  254. })
  255. })