import request from 'supertest'; import { promises as fs } from 'fs'; import path from 'path'; import app from '../../src/app'; import { pool } from '../../src/db'; import { closeDb } from './setup/testDb'; /** * What the application will and will not hand back out of the uploads * directory. * * #95 covers what can be *stored*; this covers what can be *served*, which is a * separate question because a file can reach that directory without going * through the upload route — one written before the validation existed, one * restored from a backup, one put there by a path added later. The uploads * directory is the only place in this application where content someone else * authored is served over HTTP, so it gets its own rules (#103). * * The files here are written straight to disk rather than uploaded, precisely * because the interesting cases are the ones the upload route would refuse. */ const UPLOADS_DIR = process.env.UPLOADS_DIR as string; const REAL_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64' ); // What a stored cross-site scripting payload looks like: same-origin markup // carrying script, reachable by navigating straight to it. const HOSTILE_HTML = Buffer.from('', 'utf8'); const HOSTILE_SVG = Buffer.from( '', 'utf8' ); const written: string[] = []; async function place(name: string, bytes: Buffer): Promise { await fs.writeFile(path.join(UPLOADS_DIR, name), bytes); written.push(name); return `/uploads/${name}`; } beforeAll(async () => { await fs.mkdir(UPLOADS_DIR, { recursive: true }); }); afterAll(async () => { await Promise.all( written.map((name) => fs.rm(path.join(UPLOADS_DIR, name), { force: true })) ); await pool.end(); await closeDb(); }); describe('serving uploaded files', () => { it('serves a stored image', async () => { const url = await place('serving-ok.png', REAL_PNG); const res = await request(app).get(url); expect(res.status).toBe(200); expect(res.body).toEqual(REAL_PNG); }); // Stated by this application from a list of three, rather than sniffed from // the bytes or guessed from a name someone else chose. it('states the content type explicitly', async () => { const url = await place('serving-type.png', REAL_PNG); const res = await request(app).get(url); expect(res.headers['content-type']).toContain('image/png'); expect(res.headers['x-content-type-options']).toBe('nosniff'); }); // Not a header for the image's own sake — an image loads no subresources. // It constrains the document a browser makes when someone navigates directly // to the file, which is the only way one of these can do harm. it('sends a policy that leaves a directly-navigated file with no capabilities', async () => { const url = await place('serving-csp.png', REAL_PNG); const res = await request(app).get(url); expect(res.headers['content-security-policy']).toContain("default-src 'none'"); expect(res.headers['content-security-policy']).toContain('sandbox'); }); // Needed the moment these are served from a hostname of their own, or a // browser refuses the cross-origin load. it('allows the file to be embedded cross-origin', async () => { const url = await place('serving-corp.png', REAL_PNG); const res = await request(app).get(url); expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin'); }); // The case the whole file exists for. Both of these execute script when // navigated to, and neither can be produced by the upload route — so a file // like this on disk means an earlier control failed, and this is the one that // still holds. it('refuses to serve markup that would run as the site', async () => { const html = await place('serving-hostile.html', HOSTILE_HTML); const svg = await place('serving-hostile.svg', HOSTILE_SVG); expect((await request(app).get(html)).status).toBe(404); expect((await request(app).get(svg)).status).toBe(404); }); it('refuses an extension it does not recognise, whatever the file contains', async () => { // Genuinely a PNG, and still not served: the rule is about the name the // browser will judge the response by, not about the bytes. const url = await place('serving-mislabelled.txt', REAL_PNG); expect((await request(app).get(url)).status).toBe(404); }); // 404 rather than 403, so a stranger cannot use the response to learn which // paths exist. it('answers the same for a refused type as for a file that is not there', async () => { const missing = await request(app).get('/uploads/serving-absent.png'); const refused = await request(app).get('/uploads/serving-absent.html'); expect(missing.status).toBe(404); expect(refused.status).toBe(404); }); it('accepts an extension in any case', async () => { const url = await place('serving-shouted.PNG', REAL_PNG); const res = await request(app).get(url); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('image/png'); }); it('refuses to be written to', async () => { const res = await request(app).post('/uploads/serving-ok.png').send('anything'); expect(res.status).toBe(405); expect(res.headers.allow).toBe('GET, HEAD'); }); }); describe('GET /api/config', () => { // Runtime rather than built in, so one image serves every environment. Empty // is the default and means the app's own origin, which is what local // development has. it('names the origin uploaded images should be fetched from', async () => { const res = await request(app).get('/api/config'); expect(res.status).toBe(200); expect(res.body).toHaveProperty('uploadsBaseUrl'); expect(typeof res.body.uploadsBaseUrl).toBe('string'); }); });