PDFPipe

Document security / Who can reach the document

Serving a document so the browser treats it as a document

The headers that decide whether a file downloads, opens in a viewer, or is interpreted as something else entirely by a browser guessing at its type.

The exposure

How a browser treats a served file depends on headers rather than on the bytes, and a wrong or missing header produces two distinct problems. The mild one is behavioural: the document opens inline when the user wanted a download, or downloads when they wanted to read it. The serious one is that a browser allowed to guess a content type can decide a file is HTML and render it, which turns a file-serving endpoint into a way of executing content in your origin. That matters most where any part of the file is influenced by a user.

The decisions

Reasons rather than a description of the code. Each one has a default that is fine for a page and wrong for a file somebody keeps.

  • Always set an explicit content type. A missing type invites the browser to guess, and guessing is the failure.
  • Always send nosniff, which tells the browser to trust the type you declared rather than inspecting the bytes.
  • Choose the disposition deliberately. Inline for something a person reads in place, attachment for something they keep. The API returns inline with a generic filename by default, so serving onward is where the decision is made.
  • Quote the filename in the header and strip anything that could break out of it, because a filename with a newline in it is a header injection.
  • Serve user-influenced files from a separate origin where the content type cannot be trusted at all, so a mistake is contained.
  • Set a cache policy that matches the sensitivity. A document served with a public cache header can end up in a shared cache.

In practice

A fragment, with the thing that goes wrong kept in a comment where it is the more instructive half.

js
res.setHeader("Content-Type", "application/pdf");

// Trust the type I declared; do not inspect the bytes and decide.
res.setHeader("X-Content-Type-Options", "nosniff");

// Deliberate, not default. The render returns inline with a generic
// name; this is where the real decision is made.
res.setHeader(
  "Content-Disposition",
  `attachment; filename="${safeFilename(name)}"`,
);

// Sensitivity, not convenience.
res.setHeader("Cache-Control", "private, no-store");

function safeFilename(name) {
  // A newline here is a header injection, not a cosmetic problem.
  return name.replace(/[\r\n"\\]/g, "").slice(0, 200);
}

/* Why nosniff matters specifically: without it, a browser may inspect
   the bytes, decide a file is HTML and render it in your origin. For a
   document whose content is influenced by a user, that is script
   execution rather than a display quirk.

   Where any part of the file is user-influenced, serve it from a
   separate origin so a mistake cannot reach your session cookies. */

What people do instead

Relying on the file extension. Browsers key off headers, not the URL, so a path ending in .pdf served with no content type and no nosniff is exactly the case where sniffing decides what happens.

How this is found out

The behavioural half is reported by users immediately. The security half is found by a review or an assessment, because nothing about a correctly rendered document looks wrong in a log.

Frequently asked

Does this page tell me what the law requires?

No, and deliberately not. Retention periods, erasure obligations and residency rules vary by jurisdiction, by industry and by the kind of document, and they change. What these pages describe is the shape of the problem and the mechanisms a system needs in order to implement whatever answer your own advisers give you. Where a genuine tension exists, such as an immutable record against a right to erasure, it is named as a tension rather than resolved.

Why is so much of this about the contents rather than access control?

Because access control decides who can obtain a copy and has no opinion at all about what happens to the copy. Once a document is on somebody's laptop, forwarded to a colleague or printed, every control listed here has already stopped applying to it. What is inside the file is therefore the part that keeps mattering, which is the opposite of the balance you would strike for a page.

How much of this applies at a small volume?

Most of it, because these are decisions rather than infrastructure. Redacting by omitting rather than covering costs nothing. Deciding what goes on a template costs one review. Classification is one object in code. Legal hold and an audit trail are the two that take real work, and both are far cheaper to build before they are requested than under the deadline that comes with the request.

Related security topics

The decisions that depend on each other, then the rest of the same group.

Most of these are decisions rather than features, and the cheapest time to make them is before the first document is delivered rather than after one reaches the wrong person.