/** * 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; }