Hiding a File Server Behind a Blog

Contents
  1. The shape of it
  2. Why a redirect is not enough
  3. Not following redirects into the open
  4. Range requests, or: the part people forget
  5. The manifest is a firewall
  6. Errors that say nothing
  7. The result

Static hosting is wonderful right up until you have a two-gigabyte archive to hand someone. Uploading it to the host is either impossible or expensive, and the obvious workaround — pointing people at a machine you own — means telling the world where that machine is.

This site does neither. Large files live on a computer in my apartment, and the address of that computer appears nowhere: not in the HTML, not in a redirect, not in a response header, not in the network tab.

The shape of it

Three pieces, each doing one thing.

visitor  →  blog/dl/big.zip

          Pages Function            ← the real address lives here, in an env var
                  │  (server to server)
          cloudflared tunnel        ← outbound only; no open ports at home

          my machine, 127.0.0.1

The key property is that the second hop is made by Cloudflare’s server, not by the visitor’s browser. A redirect would hand the address to the browser. A proxy never does.

Why a redirect is not enough

The tempting shortcut is to have the Function issue a 302 to the tunnel hostname. It is one line of code and it works. It also completely defeats the purpose: the browser has to know where it is going in order to go there, so the address ends up in the Location header, in the address bar, and in anyone’s devtools.

Even a short-lived signed URL only limits how long the address is useful. It does not stop the address from being learned.

Not following redirects into the open

Inside a Worker, fetch with redirect: 'follow' resolves redirects server-side. The browser sees the final bytes and none of the hops. That is the behaviour we want — but it also means the origin could, in principle, bounce the proxy somewhere unexpected. So the response headers are rebuilt from scratch rather than forwarded:

const PASS_THROUGH = [
  'content-type',
  'content-length',
  'content-range',
  'accept-ranges',
  'etag',
  'last-modified',
];

const headers = new Headers();
for (const name of PASS_THROUGH) {
  const value = upstreamResponse.headers.get(name);
  if (value) headers.set(name, value);
}

An allowlist, not a denylist. Anything the origin invents — Location, Set-Cookie, Server, a stray X-Powered-By naming the machine — is dropped because it was never on the list.

Range requests, or: the part people forget

A proxy that buffers the whole file before responding will fall over on anything large, and it will make resuming a download impossible. Forwarding the Range header costs four lines and turns the proxy into something you can actually put a 4 GB file behind:

for (const name of ['range', 'if-range', 'if-none-match', 'if-modified-since']) {
  const value = request.headers.get(name);
  if (value) outHeaders.set(name, value);
}

Returning upstreamResponse.body directly streams it through. Memory usage stays flat regardless of file size.

The manifest is a firewall

The proxy will not fetch an arbitrary path. Every downloadable file is listed in a manifest, and a request for anything else is refused before a packet leaves Cloudflare:

if (env.ALLOW_UNLISTED !== '1' && !ALLOWED.has(rel)) {
  return fail(404, 'Not found.');
}

This is the difference between “a tunnel to my file server” and “a tunnel to my computer”. Path traversal, drive letters, and encoded .. are all rejected separately, but the allowlist is what makes those checks a second line of defence rather than the only one.

Errors that say nothing

Every failure returns a fixed string. Not the upstream status text, not the exception message, not the hostname:

catch {
  return fail(502, 'The file server is temporarily unavailable.');
}

A proxy that leaks getaddrinfo ENOTFOUND files-abc123.example.com in an error page has undone all of the above.

The result

Hello (sample)1 KBA tiny text file used to verify that the private file proxy works end to end.

That link points at my machine. You cannot tell where my machine is.

If my computer is switched off, the link returns a polite 502 and nothing else. When it comes back on, the same link works again — no republishing, no changed URLs, nothing on the blog to update.