import { existsSync } from 'node:fs' import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' import { expect, type Page, test } from '@playwright/test' import { deleteServiceAttachments, insertServiceAttachment, ownerOrganizationId, serviceAttachmentsNamed, } from '../../support/db' import { pdfContent, TINY_PNG } from '../../support/pdf' import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl, shareLink, } from '../../support/work-order' /** * A stored file URL is data somebody typed at some point. * * Every file a workshop uploads is kept as a URL on a record, and that URL is * later turned into a path on disk and read, copied or unlinked. The audit * found it joined onto the upload folder with no containment check, so a * record carrying `../../.env` read the server's own files into a PDF. Three * layers stand in the way now: the file routes refuse a path with a dot pair, * the actions refuse to store a URL that is not one of this workshop's own * uploads, and the path resolver refuses to leave the folder for whatever is * stored already. * * And an SVG is a document with scripts in it: uploaded as a logo it ran for * every visitor on the app's origin. Uploads are decoded and re-encoded now, * and a stored SVG is offered as a download inside a sandbox. */ test.describe.configure({ mode: 'serial' }) const stamp = Date.now() /** A real picture of some size, so drawing it is visible in the PDF's bytes. */ const LOGO = path.join('public', 'torqvoice_app_logo.png') let organizationId = '' let jobUrl = '' let jobId = '' /** The share token of the job's invoice, for the public file route. */ let shareToken = '' async function workshopCopy(page: Page) { const response = await page.request.get(`/api/protected/services/${jobId}/pdf`, { timeout: 60_000, }) expect(response.status(), 'the workshop can always get its invoice').toBe(200) const body = await response.body() return { bytes: body.length, ...(await pdfContent(body)) } } /** Where the app writes uploads, when the suite shares a disk with it. */ function uploadDir(...segments: string[]): string { return path.join('data', 'uploads', organizationId, ...segments) } test.beforeAll(async ({ browser }) => { organizationId = await ownerOrganizationId() const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' }) const vehicleUrl = await seededVehicleUrl(page) jobUrl = await newWorkOrder(page, vehicleUrl, `E2E file safety ${stamp}`) jobId = jobUrl.split('/').pop() ?? '' await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 1, unitPrice: 100 }) await saveWorkOrder(page) shareToken = new URL(await shareLink(page)).pathname.split('/').pop() ?? '' await page.close() }) test.describe('the file routes', () => { test('refuse a path that climbs out of the upload folder', async ({ page }) => { // Encoded, because a browser would fold a literal `..` away before the // request left it; a client that wants the traversal does not. const climbs = [ '..%2F..%2F..%2F..%2Fpackage.json', '%2E%2E%2F%2E%2E%2Fpackage.json', '..%5C..%5Cpackage.json', ] for (const climb of climbs) { for (const url of [ `/api/protected/files/${organizationId}/services/${climb}`, `/api/public/files/${shareToken}/services/${climb}`, ]) { const response = await page.request.get(url) expect([400, 404], `${url} is refused`).toContain(response.status()) expect(await response.text(), 'and nothing of the file came back').not.toContain( '"scripts"' ) } } }) }) test.describe('a file URL on a record', () => { test('is not stored unless it is one of this workshop’s own uploads', async ({ browser }) => { // The client uploads the file, then hands the answer's URL to the action // that puts it on the job. Here the answer is rewritten on its way back, // which is what a client that wanted to would do. const forged = [ { url: `/api/protected/files/${organizationId}/services/../../../../package.json` }, { url: '/api/files/../../.env' }, { url: `/api/protected/files/not-this-workshop/services/${stamp}.txt` }, { url: `https://example.com/${stamp}.txt` }, ] const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' }) await page.goto(jobUrl) for (const [i, { url }] of forged.entries()) { const name = `e2e-forged-${i}-${stamp}.txt` // The refusal is a toast, and the last file's is still up when the next // one starts. Left there it answers this file's check at once, the route // below is taken away while its upload is still in flight, and the // handler then fails with "Route is already handled" on a slow runner. await expect(page.getByText(/not an upload of this workshop/i)).toHaveCount(0, { timeout: 30_000, }) await page.route('**/api/protected/upload/service-files', async (route) => { const response = await route.fetch() const json = (await response.json()) as Record await route.fulfill({ response, json: { ...json, url } }) }) const files = page.getByTestId('files-media') const documents = files.getByRole('tab', { name: /^Documents/ }) await expect(async () => { await documents.click() await expect(documents).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 }) }).toPass({ timeout: 30_000 }) await files .locator('input[type="file"]') .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('forged') }) await expect( page.getByText(/not an upload of this workshop/i).first(), `${url} is refused, and the page says why` ).toBeVisible({ timeout: 30_000 }) await page.unroute('**/api/protected/upload/service-files') expect(await serviceAttachmentsNamed(name), `${url} was not stored`).toBe(0) } await page.close() }) test('that climbs out of the folder is listed on the invoice, never read', async ({ page }) => { // Rows written straight to the database, as records from before the // schema guard would be. The oracle is the printed document: a picture // that is drawn gets a "Service Images" page of its own, one that is // only listed adds its name to the invoice and nothing else. (Byte size // would not do: a flat-colour logo deflates to a kilobyte once the // renderer re-encodes it.) The picture goes up through the upload route // rather than the images tab, which re-encodes what it is given. const uploaded = await page.request.post('/api/protected/upload/service-files', { multipart: { file: { name: `e2e-real-${stamp}.png`, mimeType: 'image/png', buffer: await readFile(LOGO), }, }, }) expect(uploaded.status()).toBe(200) const realFile = ((await uploaded.json()) as { url: string }).url.split('/').pop() const bare = await workshopCopy(page) const withRow = async (fileName: string, fileUrl: string) => { const id = await insertServiceAttachment({ serviceRecordId: jobId, fileName, fileUrl, fileType: 'image/png', }) try { return await workshopCopy(page) } finally { await deleteServiceAttachments([id]) } } // The control: the uploaded file, reached by climbing out of the folder // and straight back in. It stays inside, so it is drawn, which proves the // climb below starts where the app's upload folder is. const control = await withRow( `e2e-control-${stamp}.png`, `/api/protected/files/${organizationId}/services/../../../../data/uploads/${organizationId}/services/${realFile}` ) expect(control.pages, 'a path that stays inside the folder is drawn').toBe(bare.pages + 1) expect(control.flat).toContain('Service Images') expect(control.flat).toContain(`e2e-control-${stamp}.png`) // The escape: the same climb, ending in a real picture outside the // folder. Listed by name, and not one pixel of it in the document. const escaped = await withRow( `e2e-escape-${stamp}.png`, `/api/protected/files/${organizationId}/services/../../../../${LOGO}` ) expect(escaped.flat, 'the invoice still names the file').toContain(`e2e-escape-${stamp}.png`) expect(escaped.pages, 'but did not draw it').toBe(bare.pages) expect(escaped.flat).not.toContain('Service Images') }) }) test.describe('an SVG', () => { test('is not accepted as a logo or a portal background, whatever it is called', async ({ page, }) => { const svg = Buffer.from( '' ) for (const route of ['logo', 'portal-background']) { const url = `/api/protected/upload/${route}` const declared = await page.request.post(url, { multipart: { file: { name: 'logo.svg', mimeType: 'image/svg+xml', buffer: svg } }, }) expect(declared.status(), `${route}: an SVG declared as one`).toBe(400) // Declared as a PNG, which is what a client that wanted it stored would // say. The bytes are decoded before anything is written, and these do // not decode. const disguised = await page.request.post(url, { multipart: { file: { name: 'logo.png', mimeType: 'image/png', buffer: svg } }, }) expect(disguised.status(), `${route}: an SVG declared as a PNG`).toBe(400) } }) test('already on disk is a download inside a sandbox, never a page on the app’s origin', async ({ page, }) => { // Written straight into the upload folder, as a file from before the // upload routes re-encoded would be. Only possible when the suite shares // a disk with the server, which it does locally and on CI. test.skip(!existsSync(uploadDir()), 'the suite does not share a disk with the app server') const name = `e2e-${stamp}.svg` await mkdir(uploadDir('logos'), { recursive: true }) await writeFile( uploadDir('logos', name), '' ) try { for (const url of [ `/api/protected/files/${organizationId}/logos/${name}`, `/api/public/files/${shareToken}/logos/${name}`, ]) { const response = await page.request.get(url) expect(response.status(), `${url} is served`).toBe(200) const headers = response.headers() expect(headers['content-disposition'], `${url} is a download`).toBe('attachment') expect(headers['content-security-policy'], `${url} is sandboxed`).toContain('sandbox') expect(headers['x-content-type-options']).toBe('nosniff') } } finally { await unlink(uploadDir('logos', name)) } }) test('is refused where a picture is expected, and a picture is stored as what it is', async ({ page, }) => { // The stored file's extension is what the bytes turned out to be, not // what the name said; a PNG called .svg is a .png on disk, and is served // as one. The portal background is the route without a side effect on // the workshop's current logo. const response = await page.request.post('/api/protected/upload/portal-background', { multipart: { file: { name: 'picture.svg', mimeType: 'image/png', buffer: TINY_PNG } }, }) expect(response.status()).toBe(200) const { url } = (await response.json()) as { url: string } expect(url).toMatch( new RegExp(`^/api/protected/files/${organizationId}/portal/[0-9a-f-]+\\.png$`) ) const served = await page.request.get(url) expect(served.status()).toBe(200) expect(served.headers()['content-type']).toBe('image/png') // Tidied away when the suite shares a disk with the server; otherwise the // stray background stays where every other spec's uploads do. if (existsSync(uploadDir('portal'))) { await unlink(uploadDir('portal', url.split('/').pop() ?? '')) } }) })