/**
* Serving user-uploaded files, defensively.
*
* Everything under the uploads directory was put there by someone other than
* the people who wrote this application, and it is served over HTTP. #95 stops
* a dangerous file being *stored*; this stops a stored file *doing damage* if
* one ever gets there anyway — through a gap, a path added later, or a file
* written before that validation existed.
*
* The real fix for that class is a separate origin, because the origin is the
* whole unit of trust in a browser (#103). That needs a hostname and a
* certificate, which live outside this repository, so what is here is the half
* that works either way: the app's own origin stops serving anything it does
* not recognise, and serves what it does recognise in a form that cannot be
* talked into executing.
*
* These two are complementary rather than alternatives. The separate origin
* still points at these same files, so the rules below apply there too.
*/
import express, { Request, Response, NextFunction, Router } from 'express';
import path from 'path';
import { typeForExtension } from './uploadTypes';
/**
* A directly-navigated upload gets no capabilities at all.
*
* `default-src 'none'` leaves a document unable to load or run anything, and
* `sandbox` with no allowances drops it into an opaque origin, so even a file
* that somehow renders as markup cannot reach the site's cookies or DOM.
*
* This does nothing to an `
` embed, which is the only way these files are
* legitimately used — a policy on an image response constrains the image's own
* (nonexistent) subresource loads, not the page displaying it.
*/
const UPLOAD_CSP = "default-src 'none'; sandbox";
/**
* Whether a request should reach the files at all.
*
* Only GET and HEAD: express.static ignores the rest anyway, but answering 405
* says so rather than falling through to a 404 that suggests the path is wrong.
*/
function methodAllowed(method: string): boolean {
return method === 'GET' || method === 'HEAD';
}
export function uploadsRouter(directory: string): Router {
const router = express.Router();
router.use((req: Request, res: Response, next: NextFunction) => {
if (!methodAllowed(req.method)) {
res.set('Allow', 'GET, HEAD');
res.status(405).json({ error: 'method not allowed' });
return;
}
// An allowlist rather than a denylist of dangerous extensions. A denylist
// has to anticipate every type a browser might execute, which is a moving
// target across browsers and years; this only has to know the three types
// the upload path can produce, and everything else — including a `.html` or
// a `.svg` sitting on disk from before there was any validation — is simply
// not a file this application will hand out.
const contentType = typeForExtension(path.extname(req.path));
if (contentType === null) {
// 404 rather than 403: whether a file exists at that path is not
// something a stranger needs to be able to distinguish.
res.status(404).json({ error: 'not found' });
return;
}
// Set here rather than only in setHeaders below, so a request that never
// reaches a file still carries them.
res.set('X-Content-Type-Options', 'nosniff');
res.set('Content-Security-Policy', UPLOAD_CSP);
next();
});
router.use(
express.static(directory, {
// No directory listings and no index.html, both of which would be content
// this application did not write being served as if it had.
index: false,
// A dotfile in an upload directory is never something to hand out.
dotfiles: 'ignore',
setHeaders: (res: Response, filePath: string) => {
const contentType = typeForExtension(path.extname(filePath));
if (contentType !== null) {
// Stated explicitly rather than left to express.static's extension
// lookup. Paired with nosniff, the type a browser sees is then the
// one this application chose, from a list of three, and never a guess
// made from the bytes.
res.set('Content-Type', contentType);
}
// Required once these are served from a hostname of their own: without
// it a resource-policy-conscious browser refuses the cross-origin
// `
` load. Harmless while the origin is shared.
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
}
})
);
return router;
}