Fix: serve zero-size virtual files with proper content

Fixed pillarjs/send#4944 — when stat.size is 0 (e.g. /proc files), do not clamp the read stream to byte 0. 6-line fix.

The Bug

Repo: pillarjs/send Issue: #4944 on expressjs/express PR: #311 on pillarjs/send Fix scope: 6 lines changed, 2 files

response.download() (which delegates to sendFile() via the send package) returns an empty body for files under /proc — the Linux virtual filesystem. /proc/meminfo, /proc/cpuinfo, and similar files are readable with fs.readFile() but send serves them as zero-length.

Root Cause

The bug lives in SendStream.prototype.send(), which receives a stat object from fs.stat(). For /proc virtual files, stat.size is 0 — the kernel reports zero size because these files are generated on-the-fly and have no fixed length.

The send() method then does two things that produce an empty response:

  1. Limits the read stream to byte 0opts.end = Math.max(offset, offset + len - 1) evaluates to Math.max(0, -1) = 0, so fs.createReadStream(path, {start: 0, end: 0}) reads at most 1 byte.
  2. Sets Content-Length: 0res.setHeader('Content-Length', 0) tells the client the body is zero bytes.

The client sees Content-Length: 0, reads zero bytes, and the response is effectively empty. The data is on disk but the code never reads past byte 0.

Why This Pattern Is Easy to Miss

Standard test suites for HTTP file serving use real files with real sizes. Virtual filesystems like /proc, sysfs, and tracefs are rarely tested. The stat.size assumption holds for 99.9% of files — until someone serves a /proc file through Express. [1]

Systems Affected

This pattern affects any Node.js or browser application that uses stat.size to determine content length and serve files. The send package is used by Express’s res.sendFile() and res.download(), so any Express app serving virtual filesystem paths is vulnerable.

The Fix

Two guards around the read stream options and Content-Length header:

   // set read options
   opts.start = offset
-  opts.end = Math.max(offset, offset + len - 1)
+  if (len > 0) {
+    opts.end = Math.max(offset, offset + len - 1)
+  }

   // content-length
-  res.setHeader('Content-Length', len)
+  if (len > 0) {
+    res.setHeader('Content-Length', len)
+  }

When len === 0, the read stream runs until EOF (Node.js reads to end of file), and no Content-Length is set — Node.js uses chunked transfer encoding automatically. For truly empty files, the stream emits zero data and Node.js adds Content-Length: 0 on its own.

The existing test for zero-length files was updated to remove the explicit Content-Length: '0' assertion, since that’s now delegated to Node.js.

Pattern & Takeaways

Pattern: stat.size === 0 does not mean “file is empty” for virtual/pseudo filesystems. The send package assumed a file’s stat size was authoritative, but /proc, sysfs, and other virtual filesystems report size 0 while containing readable data.

Key insight: Never treat stat.size as the definitive content length for files. Always read until EOF. The Content-Length header should reflect what you actually stream, not what stat says. This is a classic leaky abstraction — fs.stat() works on real files and virtual files, but size has different semantics for each. The fix removes the assumption that stat.size is a hard limit rather than adding special-case detection for /proc.

Actionable Checklist

If your codebase serves files in Node.js, audit for these patterns:

  1. fs.createReadStream(path, {start, end}) — does end ever resolve to 0 when stat.size is 0? Guard with if (len > 0).
  2. res.setHeader('Content-Length', len) — does it set 0 when the file has no known size? Delegate to Node.js by omitting the header.
  3. Buffer.alloc(size) — does it use stat.size for allocation? This crashes on virtual files.
  4. Range requests — do Range header calculations divide by stat.size? Guard against zero.

Transfer Potential

This pattern applies broadly: any code that uses stat.size to determine how much of a file to read will break on virtual filesystems. APIs using stat.size for allocation (Buffer.alloc(size)), range calculations, or content-length headers should fall back to reading until EOF when size === 0.

When to Use This Fix

Apply this guard pattern in any codebase that serves files through HTTP in Node.js:

  • Static file servers — if they use send, express.static, or custom fs.createReadStream wrappers
  • API endpoints that serve file downloads — protect against zero-size files from /proc or sysfs
  • Proxy servers — avoid forwarding Content-Length: 0 for files that might be virtual

Rule of Thumb

If a file’s stat.size is 0, don’t assume it’s empty. Read until EOF and let Node.js determine the actual length. This single rule protects against all virtual filesystem edge cases without needing to detect which filesystem a file lives on.


Auto-generated from PR #4944. View all patches on GitHub.

References

  • [1] (citation needed)