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');
});
});